diff --git a/workspace-server/internal/handlers/plugins_install.go b/workspace-server/internal/handlers/plugins_install.go index b0031bfb..32232727 100644 --- a/workspace-server/internal/handlers/plugins_install.go +++ b/workspace-server/internal/handlers/plugins_install.go @@ -91,6 +91,14 @@ func (h *PluginsHandler) Install(c *gin.Context) { return } + // Record the install in workspace_plugins (core#113 — version-subscription + // foundation). Best-effort: DB write failure is logged but doesn't fail + // the install — the plugin IS in the container; surfacing a 500 here + // would mislead the caller about the install state. + if err := recordWorkspacePluginInstall(ctx, workspaceID, result.PluginName, result.Source.Raw(), req.Track); err != nil { + log.Printf("Plugin install: failed to record %s for %s in workspace_plugins: %v (install succeeded; tracking row missing)", result.PluginName, workspaceID, err) + } + log.Printf("Plugin install: %s via %s → workspace %s (restarting)", result.PluginName, result.Source.Scheme, workspaceID) c.JSON(http.StatusOK, gin.H{ "status": "installed", diff --git a/workspace-server/internal/handlers/plugins_install_pipeline.go b/workspace-server/internal/handlers/plugins_install_pipeline.go index 8062e78f..31d1239e 100644 --- a/workspace-server/internal/handlers/plugins_install_pipeline.go +++ b/workspace-server/internal/handlers/plugins_install_pipeline.go @@ -114,6 +114,15 @@ type installRequest struct { // When present, resolveAndStage verifies the fetched content matches // before allowing the install to proceed (SAFE-T1102 supply-chain hardening). SHA256 string `json:"sha256,omitempty"` + // Track is the version-subscription mode for this install (core#113): + // "none" — no auto-update tracking (default) + // "tag:vX.Y.Z" — track a specific version tag + // "tag:latest" — track latest tag, drift on every new tag + // "sha:" — pinned, no drift ever + // The drift detector (separate component, follow-up) reads + // workspace_plugins rows where tracked_ref != 'none' and queues + // updates when upstream resolves to a different SHA. + Track string `json:"track,omitempty"` } // stageResult bundles the outputs of resolveAndStage for the caller. diff --git a/workspace-server/internal/handlers/plugins_tracking.go b/workspace-server/internal/handlers/plugins_tracking.go new file mode 100644 index 00000000..56831a06 --- /dev/null +++ b/workspace-server/internal/handlers/plugins_tracking.go @@ -0,0 +1,78 @@ +package handlers + +// plugins_tracking.go — workspace_plugins DB tracking for the +// version-subscription model (core#113). +// +// Schema lives in migration 20260508160000_workspace_plugins_tracking.up.sql. +// This file is the Go-side write surface used at install time to record +// each plugin's install record. Drift detection / queue / apply are +// follow-up scope (filed as a separate issue once this lands). + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/Molecule-AI/molecule-monorepo/platform/internal/db" +) + +// trackedRefValues is the closed set of bare-string values the +// workspace_plugins.tracked_ref column accepts. Prefixed values +// ("tag:..." / "sha:...") are validated structurally below. +var trackedRefValues = map[string]bool{ + "none": true, +} + +// validateTrackedRef returns the canonical form of a track string, or +// an error if the input is malformed. Empty input → "none" (default). +// +// Accepted shapes: +// +// "" — defaults to "none" +// "none" — no tracking +// "tag:vX.Y.Z" — track a specific tag +// "tag:latest" — track latest tag, drift on every new tag +// "sha:" — pinned to commit SHA +func validateTrackedRef(s string) (string, error) { + s = strings.TrimSpace(s) + if s == "" { + return "none", nil + } + if trackedRefValues[s] { + return s, nil + } + if strings.HasPrefix(s, "tag:") && len(s) > 4 { + return s, nil + } + if strings.HasPrefix(s, "sha:") && len(s) > 4 { + return s, nil + } + return "", fmt.Errorf("invalid track value %q: expected 'none' | 'tag:vX.Y.Z' | 'tag:latest' | 'sha:'", s) +} + +// recordWorkspacePluginInstall upserts the workspace_plugins row for a +// plugin install. ON CONFLICT (workspace_id, plugin_name) DO UPDATE so +// reinstalling the same plugin name (with a possibly-different source or +// track value) updates the existing row rather than failing. +func recordWorkspacePluginInstall( + ctx context.Context, workspaceID, pluginName, sourceRaw, track string, +) error { + if workspaceID == "" || pluginName == "" || sourceRaw == "" { + return errors.New("recordWorkspacePluginInstall: missing required field") + } + canonicalTrack, err := validateTrackedRef(track) + if err != nil { + return err + } + _, err = db.DB.ExecContext(ctx, ` + INSERT INTO workspace_plugins (workspace_id, plugin_name, source_raw, tracked_ref) + VALUES ($1, $2, $3, $4) + ON CONFLICT (workspace_id, plugin_name) + DO UPDATE SET + source_raw = EXCLUDED.source_raw, + tracked_ref = EXCLUDED.tracked_ref, + updated_at = NOW() + `, workspaceID, pluginName, sourceRaw, canonicalTrack) + return err +} diff --git a/workspace-server/internal/handlers/plugins_tracking_test.go b/workspace-server/internal/handlers/plugins_tracking_test.go new file mode 100644 index 00000000..8c33023d --- /dev/null +++ b/workspace-server/internal/handlers/plugins_tracking_test.go @@ -0,0 +1,54 @@ +package handlers + +import "testing" + +// TestValidateTrackedRef: pin the exact set of accepted track values +// the install endpoint stores. Drift detector reads this column; any +// value that slips through here without structural validation would +// silently fail at drift-check time. +func TestValidateTrackedRef(t *testing.T) { + cases := []struct { + in string + want string + err bool + }{ + // Defaults + {"", "none", false}, + {" ", "none", false}, + {"none", "none", false}, + + // Tag shape + {"tag:v1.0.0", "tag:v1.0.0", false}, + {"tag:v0.4.0-gitea.1", "tag:v0.4.0-gitea.1", false}, + {"tag:latest", "tag:latest", false}, + + // SHA shape + {"sha:abc123", "sha:abc123", false}, + {"sha:0123456789abcdef0123456789abcdef01234567", "sha:0123456789abcdef0123456789abcdef01234567", false}, + + // Reject malformed + {"tag:", "", true}, // empty after prefix + {"sha:", "", true}, // empty after prefix + {"latest", "", true}, // bare 'latest' is ambiguous (tag? branch?) + {"main", "", true}, // bare branch name not allowed + {"v1.0.0", "", true}, // missing tag: prefix + {"random", "", true}, // not in allowlist + {"tag", "", true}, // prefix without separator + } + for _, tc := range cases { + got, err := validateTrackedRef(tc.in) + if tc.err { + if err == nil { + t.Errorf("validateTrackedRef(%q) = (%q, nil); want error", tc.in, got) + } + continue + } + if err != nil { + t.Errorf("validateTrackedRef(%q) error: %v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("validateTrackedRef(%q) = %q; want %q", tc.in, got, tc.want) + } + } +} diff --git a/workspace-server/migrations/20260508160000_workspace_plugins_tracking.down.sql b/workspace-server/migrations/20260508160000_workspace_plugins_tracking.down.sql new file mode 100644 index 00000000..9beee5cf --- /dev/null +++ b/workspace-server/migrations/20260508160000_workspace_plugins_tracking.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS workspace_plugins_tracked_not_none; +DROP INDEX IF EXISTS workspace_plugins_workspace_name; +DROP TABLE IF EXISTS workspace_plugins; diff --git a/workspace-server/migrations/20260508160000_workspace_plugins_tracking.up.sql b/workspace-server/migrations/20260508160000_workspace_plugins_tracking.up.sql new file mode 100644 index 00000000..a6164832 --- /dev/null +++ b/workspace-server/migrations/20260508160000_workspace_plugins_tracking.up.sql @@ -0,0 +1,39 @@ +-- workspace_plugins: per-workspace record of installed plugins, with the +-- tracked-ref needed for the version-subscription model (core#113). +-- +-- Today plugin install state is filesystem-only — `/configs/plugins//` +-- inside the workspace container. There's no DB record of "what's installed +-- where, from what source, pinned to what." That's fine until you want +-- drift detection (compare upstream tag's resolved SHA vs the installed +-- one) and that's the foundation this table provides. +-- +-- This migration is purely additive: existing install paths keep working; +-- they'll write to this table on next install. Workspaces with plugins +-- already installed before this migration won't have rows until they're +-- re-installed (acceptable — the tracking is forward-looking). +-- +-- tracked_ref values: +-- 'none' — no auto-update tracking (default) +-- 'tag:vX.Y.Z' — track a specific version tag +-- 'tag:latest' — track the latest tag (drift on every new tag) +-- 'sha:' — pinned to a specific commit SHA (no drift ever) +-- +-- A subsequent migration adds the plugin_update_queue table once drift +-- detection lands. + +CREATE TABLE IF NOT EXISTS workspace_plugins ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + plugin_name TEXT NOT NULL, + source_raw TEXT NOT NULL, + tracked_ref TEXT NOT NULL DEFAULT 'none', + installed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE UNIQUE INDEX IF NOT EXISTS workspace_plugins_workspace_name + ON workspace_plugins(workspace_id, plugin_name); + +-- Partial index for the drift detector: only scan rows opted into tracking. +CREATE INDEX IF NOT EXISTS workspace_plugins_tracked_not_none + ON workspace_plugins(tracked_ref) WHERE tracked_ref != 'none';