uploads: dual-write uploads to DB, startup backfill, and audit log
Store now indexes every Save into the upload_files table via SetRepo. DB failures are logged as warnings and do not fail the upload because .meta.json remains the source of truth and startup backfill recovers. Add Store.Backfill to walk Root at startup and insert index rows for any pre-existing .meta.json sidecars, swallowing duplicate-key races. The upload_file MCP Tool now writes an audit_log entry on success. Tests cover dual-write args, repo-error non-failure, and backfill skipping existing rows. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"spark-mcp-go/internal/analyzer"
|
||||
"spark-mcp-go/internal/audit"
|
||||
"spark-mcp-go/internal/httpclient"
|
||||
"spark-mcp-go/internal/storage"
|
||||
"spark-mcp-go/internal/uploads"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
// Deps bundles the dependencies shared by all MCP Tool handlers.
|
||||
type Deps struct {
|
||||
Logger *slog.Logger
|
||||
AuditRepo *audit.Repo
|
||||
ClusterRepo *storage.ClusterRepo
|
||||
SparkSubmitTimeout time.Duration
|
||||
HTTPClient *httpclient.Client
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
|
||||
"spark-mcp-go/internal/audit"
|
||||
"spark-mcp-go/internal/cluster"
|
||||
"spark-mcp-go/internal/httpclient"
|
||||
"spark-mcp-go/internal/storage"
|
||||
@@ -24,7 +25,7 @@ import (
|
||||
|
||||
// testDepsWithDataDir returns dependencies backed by an in-memory DB and a
|
||||
// temporary data directory.
|
||||
func testDepsWithDataDir(t *testing.T) (*Deps, *storage.ClusterRepo) {
|
||||
func testDepsWithDataDir(t *testing.T) (*Deps, *storage.ClusterRepo, *audit.Repo) {
|
||||
t.Helper()
|
||||
db, err := storage.Open(":memory:")
|
||||
if err != nil {
|
||||
@@ -43,11 +44,12 @@ func testDepsWithDataDir(t *testing.T) (*Deps, *storage.ClusterRepo) {
|
||||
Timeout: 5 * time.Second,
|
||||
MaxResponseBytes: 1 << 20,
|
||||
}),
|
||||
AuditRepo: audit.NewRepo(db),
|
||||
ClusterRepo: db.Clusters(),
|
||||
MaxResponseBytes: 1 << 20,
|
||||
DataDir: dataDir,
|
||||
UploadStore: uploadStore,
|
||||
}, db.Clusters()
|
||||
}, db.Clusters(), audit.NewRepo(db)
|
||||
}
|
||||
|
||||
// createCluster creates a cluster in the repository with the given fields.
|
||||
@@ -98,7 +100,7 @@ func TestFetchURL(t *testing.T) {
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
deps, repo := testDepsWithDataDir(t)
|
||||
deps, repo, _ := testDepsWithDataDir(t)
|
||||
createCluster(t, repo, &cluster.Cluster{
|
||||
ID: "cluster-a",
|
||||
Name: "Cluster A",
|
||||
@@ -292,7 +294,7 @@ func TestFetchURL_RedirectPreservesAuth(t *testing.T) {
|
||||
}))
|
||||
defer server1.Close()
|
||||
|
||||
deps, repo := testDepsWithDataDir(t)
|
||||
deps, repo, _ := testDepsWithDataDir(t)
|
||||
createCluster(t, repo, &cluster.Cluster{
|
||||
ID: "cluster-redirect",
|
||||
Name: "Redirect",
|
||||
@@ -321,7 +323,7 @@ func TestFetchURL_RedirectPreservesAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUploadFile(t *testing.T) {
|
||||
deps, _ := testDepsWithDataDir(t)
|
||||
deps, _, auditRepo := testDepsWithDataDir(t)
|
||||
|
||||
req := newToolRequest(UploadFileName, map[string]any{
|
||||
"filename": "hello.txt",
|
||||
@@ -367,10 +369,25 @@ func TestUploadFile(t *testing.T) {
|
||||
if info.Mode().Perm() != 0o640 {
|
||||
t.Errorf("mode=%o, want %o", info.Mode().Perm(), 0o640)
|
||||
}
|
||||
|
||||
entries, err := auditRepo.List(context.Background(), 100)
|
||||
if err != nil {
|
||||
t.Fatalf("list audit: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, e := range entries {
|
||||
if e.Action == audit.ActionUploadCreate && e.Actor == "tool:upload_file" && e.ClusterID == fileID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("missing upload.create audit entry for file_id %s", fileID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadFile_PathTraversal(t *testing.T) {
|
||||
deps, _ := testDepsWithDataDir(t)
|
||||
deps, _, _ := testDepsWithDataDir(t)
|
||||
|
||||
cases := []string{
|
||||
"../../../etc/passwd",
|
||||
@@ -401,7 +418,7 @@ func TestUploadFile_PathTraversal(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUploadFile_Base64(t *testing.T) {
|
||||
deps, _ := testDepsWithDataDir(t)
|
||||
deps, _, _ := testDepsWithDataDir(t)
|
||||
|
||||
req := newToolRequest(UploadFileName, map[string]any{
|
||||
"filename": "hello.bin",
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
)
|
||||
|
||||
func TestSparkSubmit_StructuredCommand(t *testing.T) {
|
||||
deps, repo := testDepsWithDataDir(t)
|
||||
deps, repo, _ := testDepsWithDataDir(t)
|
||||
deps.SparkSubmitTimeout = 5 * time.Second
|
||||
store := deps.UploadStore
|
||||
|
||||
@@ -98,7 +98,7 @@ func TestSparkSubmit_StructuredCommand(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSparkSubmit_MissingRequiredField(t *testing.T) {
|
||||
deps, _ := testDepsWithDataDir(t)
|
||||
deps, _, _ := testDepsWithDataDir(t)
|
||||
store := deps.UploadStore
|
||||
_, _, _, _, mintedPath, err := store.Save([]byte("# dummy\n"), "dummy.py")
|
||||
if err != nil {
|
||||
@@ -131,7 +131,7 @@ func TestSparkSubmit_MissingRequiredField(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSparkSubmit_BadSparkConfValue(t *testing.T) {
|
||||
deps, _ := testDepsWithDataDir(t)
|
||||
deps, _, _ := testDepsWithDataDir(t)
|
||||
store := deps.UploadStore
|
||||
_, _, _, _, mintedPath, err := store.Save([]byte("# dummy\n"), "dummy.py")
|
||||
if err != nil {
|
||||
@@ -166,7 +166,7 @@ func TestSparkSubmit_BadSparkConfValue(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSparkSubmit_RejectsNonMintedPath(t *testing.T) {
|
||||
deps, _ := testDepsWithDataDir(t)
|
||||
deps, _, _ := testDepsWithDataDir(t)
|
||||
|
||||
unminted := filepath.Join(t.TempDir(), "unminted.py")
|
||||
if err := os.WriteFile(unminted, []byte("print('not from upload_file')\n"), 0o644); err != nil {
|
||||
@@ -200,7 +200,7 @@ func TestSparkSubmit_RejectsNonMintedPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSparkSubmit_EmptyMaster(t *testing.T) {
|
||||
deps, _ := testDepsWithDataDir(t)
|
||||
deps, _, _ := testDepsWithDataDir(t)
|
||||
store := deps.UploadStore
|
||||
_, _, _, _, mintedPath, err := store.Save([]byte("# dummy\n"), "dummy.py")
|
||||
if err != nil {
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"regexp"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
|
||||
"spark-mcp-go/internal/audit"
|
||||
)
|
||||
|
||||
const UploadFileName = "upload_file"
|
||||
@@ -76,6 +78,21 @@ func (d *Deps) UploadFileHandler(ctx context.Context, req mcp.CallToolRequest) (
|
||||
return errResult("upload_file: save upload: " + err.Error()), nil
|
||||
}
|
||||
|
||||
if d.AuditRepo != nil {
|
||||
details, _ := audit.MarshalDetails(map[string]any{
|
||||
"file_id": fileID,
|
||||
"name": filename,
|
||||
"size": size,
|
||||
"sha256": sha256Hex,
|
||||
})
|
||||
_ = d.AuditRepo.Insert(ctx, &audit.Entry{
|
||||
Actor: "tool:upload_file",
|
||||
Action: audit.ActionUploadCreate,
|
||||
ClusterID: fileID,
|
||||
Details: details,
|
||||
})
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"file_id": fileID,
|
||||
"path": absPath,
|
||||
|
||||
Reference in New Issue
Block a user