forked from molecule-ai/molecule-core
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d72f21da09 | |||
| cc28cc6607 | |||
| 120b3a25aa | |||
| b7f3b270a3 | |||
| 72b0d4b1ab |
@@ -195,6 +195,19 @@ services:
|
||||
# App private key — read-only bind-mount. The host-side path is
|
||||
# gitignored per .gitignore rules (/.secrets/ + *.pem).
|
||||
- ./.secrets/github-app.pem:/secrets/github-app.pem:ro
|
||||
# Per-role persona credentials (molecule-core#242 local surface).
|
||||
# Sourced at workspace creation time by org_import.go::loadPersonaEnvFile
|
||||
# when a workspace.yaml carries `role: <name>`. The host-side dir is
|
||||
# populated by the operator-host bootstrap kit (28 dev-tree personas);
|
||||
# /etc/molecule-bootstrap/personas is the in-container path the
|
||||
# platform expects (matches the prod tenant-EC2 path so the same code
|
||||
# works in both modes).
|
||||
#
|
||||
# Read-only mount — workspace-server only reads, never writes here.
|
||||
# If the host dir is empty/missing the platform's loadPersonaEnvFile
|
||||
# silently no-ops per its existing semantics, so this mount is safe
|
||||
# even on a fresh machine that hasn't run the bootstrap kit yet.
|
||||
- ${MOLECULE_PERSONA_ROOT_HOST:-${HOME}/.molecule-ai/personas}:/etc/molecule-bootstrap/personas:ro
|
||||
ports:
|
||||
- "${PLATFORM_PUBLISH_PORT:-8080}:${PLATFORM_PORT:-8080}"
|
||||
networks:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP INDEX IF EXISTS workspaces_update_tier_canary;
|
||||
ALTER TABLE workspaces DROP COLUMN IF EXISTS update_tier;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- workspaces.update_tier — canary vs production filter for plugin updates
|
||||
-- (core#115). Composes with the version-subscription DB foundation
|
||||
-- (core#113, merged) and the upcoming drift+queue+apply endpoint
|
||||
-- (core#123).
|
||||
--
|
||||
-- Tiers:
|
||||
-- 'production' (default) — fan-out target; only updated AFTER canary soak
|
||||
-- 'canary' — early-adopter target; updates land here first
|
||||
--
|
||||
-- Default 'production' so existing customers (Reno-Stars + any future
|
||||
-- live tenant) are default-safe. Synthetic dogfooding workspaces opt
|
||||
-- INTO 'canary' explicitly.
|
||||
--
|
||||
-- The column is just metadata at this layer; the actual filter logic
|
||||
-- ('apply this update only to canary tier first') lives in the future
|
||||
-- POST /admin/plugin-updates/:id/apply endpoint (core#123).
|
||||
|
||||
ALTER TABLE workspaces
|
||||
ADD COLUMN IF NOT EXISTS update_tier TEXT NOT NULL DEFAULT 'production'
|
||||
CHECK (update_tier IN ('canary', 'production'));
|
||||
|
||||
-- Partial index for the apply endpoint's canary-tier scan: only
|
||||
-- index canary rows since the apply path queries them most often
|
||||
-- and the production set is the much larger default.
|
||||
CREATE INDEX IF NOT EXISTS workspaces_update_tier_canary
|
||||
ON workspaces(update_tier) WHERE update_tier = 'canary';
|
||||
Reference in New Issue
Block a user