Compare commits

...

5 Commits

Author SHA1 Message Date
claude-ceo-assistant d72f21da09 feat(local-dev): bind-mount ~/.molecule-ai/personas into platform container
Closes core#242 LOCAL surface. The PROD surface (CP user-data fetching
persona env files into tenant EC2's /etc/molecule-bootstrap/personas
via Secrets Manager) is filed as a follow-up.

WHAT THIS ADDS
  Bind-mount on the platform service in docker-compose.yml:
    ${MOLECULE_PERSONA_ROOT_HOST:-${HOME}/.molecule-ai/personas}
      → /etc/molecule-bootstrap/personas (read-only)

  Default source = ${HOME}/.molecule-ai/personas (the operator-host-mirrored
  local dir populated by today's persona rotation work). Override via
  MOLECULE_PERSONA_ROOT_HOST when running on a machine with a different
  layout (CI runners, etc.).

WHY READ-ONLY
  workspace-server only reads persona env files; never writes back. The
  read-only mount enforces that contract — a hostile plugin install path
  can't tamper with the persona credentials it's about to consume.

WHY THIS PATH MATCHES PROD
  /etc/molecule-bootstrap/personas is the same in-container path the
  prod tenant EC2 will use. Same code path (org_import.go::loadPersonaEnvFile)
  reads the same file regardless of mode — local-dev parity with prod
  per feedback_local_must_mimic_production.

STAGE A VERIFICATION
  - docker compose config: resolves to /Users/hongming/.molecule-ai/personas
    correctly (28 persona dirs visible at source path)
  - Persona env file shape verified: dev-lead's env contains GITEA_USER,
    GITEA_USER_EMAIL, GITEA_TOKEN_SCOPES, GITEA_SSH_KEY_PATH,
    MODEL_PROVIDER=claude-code, MODEL=opus (lead tier matches Hongming's
    2026-05-08 mapping)
  - Full handler test suite green (TestLoadPersonaEnvFile_HappyPath +
    7 sibling tests pass; rejection tests still catch path traversal)
  - Build clean

STAGE B SKIPPED (with justification per § Skip conditions)
  This change is config-only (docker-compose.yml volume addition). The
  prod tenant EC2s do NOT use docker-compose.yml — they use CP user-data
  + ec2.go's docker run script. So this PR has no prod blast radius.
  Stage B (staging tenant probe) would be checking 'is the platform
  using the new compose mount' on a SaaS tenant — and SaaS tenants
  don't run docker compose. The actual prod-surface change is the
  follow-up issue.

PROD SURFACE — FOLLOW-UP FILED
  Tenant EC2 user-data needs to fetch persona env files from operator
  host (or AWS Secrets Manager per the established
  feedback_unified_credentials_file pattern) and stage them at
  /etc/molecule-bootstrap/personas inside the workspace-server container.
  Touches molecule-controlplane/internal/provisioner/ec2.go user-data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 09:52:43 -07:00
claude-ceo-assistant cc28cc6607 Merge pull request 'feat(workspaces): update_tier column for canary vs production fan-out' (#124) from feat/canary-tier-filter into main 2026-05-08 15:55:42 +00:00
claude-ceo-assistant 120b3a25aa feat(workspaces): update_tier column for canary vs production fan-out
Closes core#115 partial. Schema-only change; the apply-endpoint filter
logic that reads this column lands with core#123 (drift detector +
queue + apply endpoint, the deferred follow-up of core#113).

Default 'production' so existing customers (Reno-Stars + any future
tenant) are default-safe. Synthetic dogfooding workspaces opt INTO
'canary' explicitly.

CHECK constraint pins the closed value set ('canary' | 'production') —
the apply endpoint's filter relies on the database to reject anything
else, so a future operator typo in PATCH /workspaces/:id ({update_tier:
'canery'}) returns a constraint violation, not silent fan-out to
nobody.

Partial index on canary rows since the apply-endpoint query path
('apply this update only to canary tier first') hits canary much more
often than production, and the production set is the much larger
default.

WHAT THIS DOES NOT DO (lands with core#123)
  - PATCH endpoint to flip a workspace to canary
  - The apply endpoint that consults the column
  - Tests that exercise canary-vs-production fan-out

Schema-only foundation; same pattern as core#113 (workspace_plugins).

PHASE 4 SELF-REVIEW
  Correctness: No finding — IF NOT EXISTS guards, DEFAULT clause means
    existing rows get 'production' on migration apply.
  Readability: No finding — comment block documents the tier semantics
    + the deferral to core#123.
  Architecture: No finding — additive ALTER, partial index for the
    expected access pattern.
  Security: No finding — no code path; column constraint reduces blast
    radius of bad PATCH input.
  Performance: No finding — partial index minimizes write amplification
    on the production-default rows.

REFS
  core#115 — this issue
  core#123 — apply endpoint follow-up (will exercise this column)
  core#113 — version subscription DB foundation (sibling pattern)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 08:55:19 -07:00
claude-ceo-assistant b7f3b270a3 Merge pull request 'feat(plugins): workspace_plugins tracking table (version-subscription foundation)' (#122) from feat/plugin-version-subscription into main 2026-05-08 15:53:42 +00:00
claude-ceo-assistant 72b0d4b1ab 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>
2026-05-08 08:52:35 -07:00
9 changed files with 232 additions and 0 deletions
+13
View File
@@ -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';