forked from molecule-ai/molecule-core
feat(plugins): workspace_plugins tracking table — version-subscription foundation
Closes core#113 partial. Adds the DB foundation for the
version-subscription model. Drift detection + queue + admin apply
endpoint are follow-up scope (separate PR; filed as a new issue).
WHY THIS PR ONLY GETS US PART-WAY
Plugin install state today is filesystem-only — '/configs/plugins/<name>/'
inside the container. There's no DB record of 'plugin X installed at
workspace W from source S, tracking ref T'. That makes drift detection
impossible: nothing to compare upstream tags against.
This PR adds the table + the install-endpoint hook that writes to it.
With baseline tags now on every plugin (post internal#92), the table
starts collecting tracked-ref values immediately on the next install.
The actual drift-check job + queue + apply endpoint layer on top.
WHAT THIS ADDS
workspace_plugins table:
workspace_id FK → workspaces(id) ON DELETE CASCADE
plugin_name canonical name from plugin.yaml
source_raw full source URL the install used
tracked_ref 'none' | 'tag:vX.Y.Z' | 'tag:latest' | 'sha:<full>'
installed_at, updated_at
installRequest gains optional 'track' field (defaults to 'none').
Install handler upserts the workspace_plugins row after delivery
succeeds. DB write failure is logged but doesn't fail the install
(the plugin IS in the container; surfacing 500 misleads the caller).
validateTrackedRef enforces the closed set of accepted shapes:
'none' | 'tag:<non-empty>' | 'sha:<non-empty>'
Bare values like 'latest' / 'main' / version-strings without
prefix are rejected — the drift detector keys on prefix to know
what kind of resolution to do.
WHAT THIS DOES NOT ADD (filed separately)
- Drift detector job (cron / on-demand) that scans
'WHERE tracked_ref != none' rows and queues updates on upstream drift
- plugin_update_queue table (separate migration once detector lands)
- GET /admin/plugin-updates-pending and POST .../apply endpoints
- Tier-aware apply (core#115 — composes here)
PHASE 4 SELF-REVIEW (FIVE-AXIS)
Correctness: No finding — install endpoint behavior unchanged for
callers that don't pass 'track'. DB write is best-effort + logged
on failure. validateTrackedRef rejects ambiguous bare strings.
Readability: No finding — separate file plugins_tracking.go isolates
the new concern; install handler delta is a single 4-line block.
Architecture: No finding — additive table; existing schema untouched.
Migration 20260508160000_* uses the timestamp-prefixed convention.
Security: No finding — INSERT params via placeholders (no string
interpolation). validateTrackedRef rejects unexpected shapes before
the column constraint would.
Performance: No finding — one extra ExecContext per install. Install
is already seconds-scale (network fetch + tar + docker exec); rounds
to noise.
TESTS (1 new, all green)
TestValidateTrackedRef — pin closed set + structural validators
REFS
core#113 — this issue (foundation only; drift+queue+apply = follow-up)
internal#92, internal#93 — plugin/template baseline tags (now exists for tracking)
core#114 — atomic install (this PR composes — no atomicity regression)
core#115 — canary tier filter (will key off the same DB foundation)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f78d844960
commit
72b0d4b1ab
@ -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",
|
||||
|
||||
@ -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:<full>" — 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.
|
||||
|
||||
78
workspace-server/internal/handlers/plugins_tracking.go
Normal file
78
workspace-server/internal/handlers/plugins_tracking.go
Normal file
@ -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:<full-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:<full>'", 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
|
||||
}
|
||||
54
workspace-server/internal/handlers/plugins_tracking_test.go
Normal file
54
workspace-server/internal/handlers/plugins_tracking_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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/<name>/`
|
||||
-- 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:<full>' — 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';
|
||||
Loading…
Reference in New Issue
Block a user