Phase 0 (项目骨架):
- main.go 改为最小骨架 (config + logging + gin + /healthz + 优雅退出)
- internal/{config,logging,storage,cluster,...}/ 目录占位
Phase 1 (配置 + 日志):
- internal/config: env 解析 + 必填校验 + token 隐藏的 String()
- internal/logging: slog multi-handler 双输出 (终端 text + 主文件 JSON)
+ StartToolCall per-tool 独立文件 (0600), tools 目录 0700
Phase 2 续 (存储层):
- internal/cluster: 16 字段 Cluster struct (AuthPassword json:"-")
- internal/storage: modernc.org/sqlite 接入, WAL 模式, schema 自动迁移
- ClusterRepo CRUD: Create/Get/List/Update/Delete + ErrNotFound
+ AuthPassword 空字符串 = 保留旧密码 (核心约定)
- 7 个单元测试全绿 (:memory: DB)
- created_at/updated_at 改纳秒精度, 消除 List 测试的 sleep 特殊 case
Co-Authored-By: Claude <noreply@anthropic.com>
253 lines
8.2 KiB
Go
253 lines
8.2 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"spark-mcp-go/internal/cluster"
|
|
)
|
|
|
|
// ErrNotFound is returned when a requested cluster does not exist.
|
|
var ErrNotFound = errors.New("storage: cluster not found")
|
|
|
|
// ClusterRepo provides CRUD operations for clusters.
|
|
type ClusterRepo struct {
|
|
db *DB
|
|
}
|
|
|
|
// Clusters returns a repository bound to this DB handle.
|
|
func (d *DB) Clusters() *ClusterRepo {
|
|
return &ClusterRepo{db: d}
|
|
}
|
|
|
|
// Create inserts a new cluster record.
|
|
//
|
|
// Timestamps are stored as Unix nanoseconds (INTEGER) — sub-second precision
|
|
// keeps created_at unique across rapid inserts and makes ORDER BY DESC
|
|
// stable without artificial delays.
|
|
//
|
|
// If a cluster with the same id already exists, the underlying SQLite
|
|
// UNIQUE constraint error is wrapped and returned.
|
|
func (r *ClusterRepo) Create(ctx context.Context, c *cluster.Cluster) error {
|
|
now := time.Now().UnixNano()
|
|
c.CreatedAt = time.Unix(0, now)
|
|
c.UpdatedAt = c.CreatedAt
|
|
|
|
allowlistJSON, err := json.Marshal(c.URLAllowlist)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: create cluster %s: marshal url_allowlist: %w", c.ID, err)
|
|
}
|
|
defaultArgsJSON, err := json.Marshal(c.DefaultSubmitArgs)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: create cluster %s: marshal default_submit_args: %w", c.ID, err)
|
|
}
|
|
|
|
// TODO(phase-5): encrypt auth_password_enc instead of storing plaintext.
|
|
pwdEnc := []byte(c.AuthPassword)
|
|
|
|
_, err = r.db.sqlDB.ExecContext(ctx, `
|
|
INSERT INTO clusters (
|
|
id, name, rm_url, shs_url, spark_submit_execute_bin,
|
|
is_active, auth_type, auth_username, auth_password_enc,
|
|
ssl_verify, ssl_ca_bundle, url_allowlist, default_submit_args,
|
|
rate_limit_per_min, created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
c.ID, c.Name, c.RMURL, c.SHSURL, c.SparkSubmitExecuteBin,
|
|
boolToInt(c.IsActive), string(c.AuthType), c.AuthUsername, pwdEnc,
|
|
boolToInt(c.SSLVerify), c.SSLCABundle, string(allowlistJSON), string(defaultArgsJSON),
|
|
c.RateLimitPerMin, now, now,
|
|
)
|
|
if err != nil {
|
|
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
|
return fmt.Errorf("storage: create cluster %s: primary key conflict: %w", c.ID, err)
|
|
}
|
|
return fmt.Errorf("storage: create cluster %s: %w", c.ID, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Get retrieves a cluster by id. If the cluster does not exist, it returns
|
|
// ErrNotFound.
|
|
func (r *ClusterRepo) Get(ctx context.Context, id string) (*cluster.Cluster, error) {
|
|
row := r.db.sqlDB.QueryRowContext(ctx, `
|
|
SELECT id, name, rm_url, shs_url, spark_submit_execute_bin,
|
|
is_active, auth_type, auth_username, auth_password_enc,
|
|
ssl_verify, ssl_ca_bundle, url_allowlist, default_submit_args,
|
|
rate_limit_per_min, created_at, updated_at
|
|
FROM clusters
|
|
WHERE id = ?`, id)
|
|
|
|
c, err := scanCluster(row.Scan)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: get cluster %s: %w", id, err)
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// List returns every cluster, including inactive ones, ordered by created_at
|
|
// descending.
|
|
func (r *ClusterRepo) List(ctx context.Context) ([]*cluster.Cluster, error) {
|
|
rows, err := r.db.sqlDB.QueryContext(ctx, `
|
|
SELECT id, name, rm_url, shs_url, spark_submit_execute_bin,
|
|
is_active, auth_type, auth_username, auth_password_enc,
|
|
ssl_verify, ssl_ca_bundle, url_allowlist, default_submit_args,
|
|
rate_limit_per_min, created_at, updated_at
|
|
FROM clusters
|
|
ORDER BY created_at DESC`)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: list clusters: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []*cluster.Cluster
|
|
for rows.Next() {
|
|
c, err := scanCluster(rows.Scan)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("storage: list clusters: %w", err)
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, fmt.Errorf("storage: list clusters: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Update modifies an existing cluster. An empty AuthPassword means "do not
|
|
// touch the stored password" — the existing auth_password_enc blob is kept
|
|
// intact.
|
|
func (r *ClusterRepo) Update(ctx context.Context, c *cluster.Cluster) error {
|
|
c.UpdatedAt = time.Unix(0, time.Now().UnixNano())
|
|
|
|
allowlistJSON, err := json.Marshal(c.URLAllowlist)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: update cluster %s: marshal url_allowlist: %w", c.ID, err)
|
|
}
|
|
defaultArgsJSON, err := json.Marshal(c.DefaultSubmitArgs)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: update cluster %s: marshal default_submit_args: %w", c.ID, err)
|
|
}
|
|
|
|
var res sql.Result
|
|
if c.AuthPassword == "" {
|
|
// Preserve the existing password blob.
|
|
res, err = r.db.sqlDB.ExecContext(ctx, `
|
|
UPDATE clusters
|
|
SET name = ?, rm_url = ?, shs_url = ?, spark_submit_execute_bin = ?,
|
|
is_active = ?, auth_type = ?, auth_username = ?,
|
|
ssl_verify = ?, ssl_ca_bundle = ?, url_allowlist = ?,
|
|
default_submit_args = ?, rate_limit_per_min = ?, updated_at = ?
|
|
WHERE id = ?`,
|
|
c.Name, c.RMURL, c.SHSURL, c.SparkSubmitExecuteBin,
|
|
boolToInt(c.IsActive), string(c.AuthType), c.AuthUsername,
|
|
boolToInt(c.SSLVerify), c.SSLCABundle, string(allowlistJSON),
|
|
string(defaultArgsJSON), c.RateLimitPerMin, c.UpdatedAt.UnixNano(), c.ID,
|
|
)
|
|
} else {
|
|
// TODO(phase-5): encrypt auth_password_enc instead of storing plaintext.
|
|
pwdEnc := []byte(c.AuthPassword)
|
|
res, err = r.db.sqlDB.ExecContext(ctx, `
|
|
UPDATE clusters
|
|
SET name = ?, rm_url = ?, shs_url = ?, spark_submit_execute_bin = ?,
|
|
is_active = ?, auth_type = ?, auth_username = ?, auth_password_enc = ?,
|
|
ssl_verify = ?, ssl_ca_bundle = ?, url_allowlist = ?,
|
|
default_submit_args = ?, rate_limit_per_min = ?, updated_at = ?
|
|
WHERE id = ?`,
|
|
c.Name, c.RMURL, c.SHSURL, c.SparkSubmitExecuteBin,
|
|
boolToInt(c.IsActive), string(c.AuthType), c.AuthUsername, pwdEnc,
|
|
boolToInt(c.SSLVerify), c.SSLCABundle, string(allowlistJSON),
|
|
string(defaultArgsJSON), c.RateLimitPerMin, c.UpdatedAt.UnixNano(), c.ID,
|
|
)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("storage: update cluster %s: %w", c.ID, err)
|
|
}
|
|
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("storage: update cluster %s: rows affected: %w", c.ID, err)
|
|
}
|
|
if n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Delete removes a cluster by id. If the cluster does not exist, it returns
|
|
// ErrNotFound.
|
|
func (r *ClusterRepo) Delete(ctx context.Context, id string) error {
|
|
res, err := r.db.sqlDB.ExecContext(ctx, `DELETE FROM clusters WHERE id = ?`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("storage: delete cluster %s: %w", id, err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("storage: delete cluster %s: rows affected: %w", id, err)
|
|
}
|
|
if n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// scanFunc abstracts *sql.Row.Scan and *sql.Rows.Scan so a single mapping
|
|
// function can serve both QueryRow and Query paths.
|
|
type scanFunc func(dest ...any) error
|
|
|
|
// scanCluster maps a single SQL row into a cluster.Cluster value.
|
|
func scanCluster(scan scanFunc) (*cluster.Cluster, error) {
|
|
var c cluster.Cluster
|
|
var allowlistJSON, defaultArgsJSON sql.NullString
|
|
var pwdEnc []byte
|
|
var createdAt, updatedAt int64
|
|
var isActive, sslVerify int
|
|
|
|
if err := scan(
|
|
&c.ID, &c.Name, &c.RMURL, &c.SHSURL, &c.SparkSubmitExecuteBin,
|
|
&isActive, &c.AuthType, &c.AuthUsername, &pwdEnc,
|
|
&sslVerify, &c.SSLCABundle, &allowlistJSON, &defaultArgsJSON,
|
|
&c.RateLimitPerMin, &createdAt, &updatedAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
c.IsActive = intToBool(isActive)
|
|
c.SSLVerify = intToBool(sslVerify)
|
|
c.CreatedAt = time.Unix(0, createdAt)
|
|
c.UpdatedAt = time.Unix(0, updatedAt)
|
|
// TODO(phase-5): decrypt pwdEnc once encryption is introduced.
|
|
c.AuthPassword = string(pwdEnc)
|
|
|
|
if allowlistJSON.Valid && allowlistJSON.String != "" {
|
|
if err := json.Unmarshal([]byte(allowlistJSON.String), &c.URLAllowlist); err != nil {
|
|
return nil, fmt.Errorf("storage: scan cluster %s: unmarshal url_allowlist: %w", c.ID, err)
|
|
}
|
|
}
|
|
if defaultArgsJSON.Valid && defaultArgsJSON.String != "" {
|
|
if err := json.Unmarshal([]byte(defaultArgsJSON.String), &c.DefaultSubmitArgs); err != nil {
|
|
return nil, fmt.Errorf("storage: scan cluster %s: unmarshal default_submit_args: %w", c.ID, err)
|
|
}
|
|
}
|
|
|
|
return &c, nil
|
|
}
|
|
|
|
func boolToInt(b bool) int {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func intToBool(i int) bool {
|
|
return i != 0
|
|
}
|