Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 7s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 6s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 14s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 35s
CI / Detect changes (pull_request) Successful in 43s
Check migration collisions / Migration version collision check (pull_request) Successful in 44s
E2E API Smoke Test / detect-changes (pull_request) Successful in 31s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 28s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 27s
Harness Replays / detect-changes (pull_request) Successful in 33s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 30s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 22s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 9s
CI / Canvas (Next.js) (pull_request) Successful in 12s
CI / Python Lint & Test (pull_request) Successful in 15s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 14s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 12s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
Harness Replays / Harness Replays (pull_request) Failing after 29s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m20s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 7m1s
CI / Platform (Go) (pull_request) Successful in 14m52s
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>
79 lines
2.5 KiB
Go
79 lines
2.5 KiB
Go
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
|
|
}
|