Merge pull request 'feat(plugins): workspace_plugins tracking table (version-subscription foundation)' (#122) from feat/plugin-version-subscription into main
Some checks failed
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Harness Replays / Harness Replays (push) Blocked by required conditions
CodeQL / Analyze (${{ matrix.language }}) (go) (push) Successful in 8s
Block internal-flavored paths / Block forbidden paths (push) Successful in 28s
CodeQL / Analyze (${{ matrix.language }}) (python) (push) Successful in 15s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (push) Successful in 16s
CI / Detect changes (push) Successful in 27s
E2E API Smoke Test / detect-changes (push) Successful in 20s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 20s
Handlers Postgres Integration / detect-changes (push) Successful in 19s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 31s
publish-workspace-server-image / build-and-push (push) Failing after 31s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 33s
Harness Replays / detect-changes (push) Successful in 33s
CI / Python Lint & Test (push) Successful in 12s
CI / Canvas (Next.js) (push) Has been cancelled
CI / Shellcheck (E2E scripts) (push) Has been cancelled
CI / Canvas Deploy Reminder (push) Has been skipped
CI / Platform (Go) (push) Failing after 14m35s
Some checks failed
E2E API Smoke Test / E2E API Smoke Test (push) Blocked by required conditions
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Blocked by required conditions
Handlers Postgres Integration / Handlers Postgres Integration (push) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (push) Blocked by required conditions
Harness Replays / Harness Replays (push) Blocked by required conditions
CodeQL / Analyze (${{ matrix.language }}) (go) (push) Successful in 8s
Block internal-flavored paths / Block forbidden paths (push) Successful in 28s
CodeQL / Analyze (${{ matrix.language }}) (python) (push) Successful in 15s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (push) Successful in 16s
CI / Detect changes (push) Successful in 27s
E2E API Smoke Test / detect-changes (push) Successful in 20s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 20s
Handlers Postgres Integration / detect-changes (push) Successful in 19s
Runtime PR-Built Compatibility / detect-changes (push) Successful in 31s
publish-workspace-server-image / build-and-push (push) Failing after 31s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 33s
Harness Replays / detect-changes (push) Successful in 33s
CI / Python Lint & Test (push) Successful in 12s
CI / Canvas (Next.js) (push) Has been cancelled
CI / Shellcheck (E2E scripts) (push) Has been cancelled
CI / Canvas Deploy Reminder (push) Has been skipped
CI / Platform (Go) (push) Failing after 14m35s
This commit is contained in:
commit
b7f3b270a3
@ -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