feat(apps): stepped creation wizard, branch previews, and app-creation fixes
This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
+ {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
/apps/[id] edit form onto the same components (removes the duplication). Add
vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
label hints; dashboard + /apps "Total workloads" count only source_kind workloads
(drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.
Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
This commit is contained in:
+232
-2
@@ -55,11 +55,20 @@ func New(dbPath string) (*Store, error) {
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetConnMaxLifetime(0)
|
||||
|
||||
// Enable WAL mode and foreign keys for better concurrency and referential integrity.
|
||||
// Enable WAL mode and foreign keys for better concurrency and
|
||||
// referential integrity. `synchronous=NORMAL` pairs with WAL to skip
|
||||
// the per-write fsync — the OS still flushes on checkpoint, durability
|
||||
// is preserved across clean shutdowns, and crashes lose at most the
|
||||
// last few committed transactions (acceptable for a tinyforge box).
|
||||
// cache_size=-20000 = 20 MiB page cache, temp_store=MEMORY keeps
|
||||
// indexer scratch off disk; both are pure perf knobs.
|
||||
pragmas := []string{
|
||||
"PRAGMA journal_mode=WAL",
|
||||
"PRAGMA synchronous=NORMAL",
|
||||
"PRAGMA foreign_keys=ON",
|
||||
"PRAGMA busy_timeout=5000",
|
||||
"PRAGMA cache_size=-20000",
|
||||
"PRAGMA temp_store=MEMORY",
|
||||
}
|
||||
for _, p := range pragmas {
|
||||
if _, err := db.Exec(p); err != nil {
|
||||
@@ -284,6 +293,24 @@ func (s *Store) runMigrations() error {
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
// workload_notifications: per-workload notification destinations.
|
||||
// Each row is one route (Slack channel, Discord webhook, generic
|
||||
// receiver, ...). event_types is a comma-separated allow-list —
|
||||
// empty means "all events". When zero rows exist for a workload
|
||||
// the dispatcher falls back to the legacy single notification_url
|
||||
// column on workloads so existing setups keep working unchanged.
|
||||
`CREATE TABLE IF NOT EXISTS workload_notifications (
|
||||
id TEXT PRIMARY KEY,
|
||||
workload_id TEXT NOT NULL REFERENCES workloads(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL,
|
||||
secret TEXT NOT NULL DEFAULT '',
|
||||
event_types TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
// workload_trigger_bindings: many-to-many between workloads and
|
||||
// triggers. binding_config is the per-binding override applied on
|
||||
// top of trigger.config (top-level JSON merge, binding wins).
|
||||
@@ -427,6 +454,7 @@ func (s *Store) runMigrations() error {
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_triggers_webhook_secret ON triggers(webhook_secret) WHERE webhook_secret != ''`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_bindings_workload ON workload_trigger_bindings(workload_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_bindings_trigger ON workload_trigger_bindings(trigger_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_workload_notifs_workload ON workload_notifications(workload_id)`,
|
||||
}
|
||||
for _, idx := range indexes {
|
||||
if _, err := s.db.Exec(idx); err != nil {
|
||||
@@ -434,13 +462,215 @@ func (s *Store) runMigrations() error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.backfillTriggersFromWorkloads(); err != nil {
|
||||
// schema_versions table gates one-shot data migrations like the
|
||||
// trigger backfill below. Without this, the backfill scan ran on
|
||||
// every boot even on fully-migrated DBs — wasted I/O and (more
|
||||
// importantly) made it impossible to tell whether a "no rows
|
||||
// processed" was a clean state or a missed-migration bug.
|
||||
if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS schema_versions (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("create schema_versions: %w", err)
|
||||
}
|
||||
|
||||
if err := s.runOnce(1, "trigger backfill", s.backfillTriggersFromWorkloads); err != nil {
|
||||
// Backfill failure is non-fatal — we log and let the operator
|
||||
// retry. The version is only recorded on success.
|
||||
slog.Warn("trigger backfill", "error", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// runOnce executes fn at most one time per database lifetime, recording
|
||||
// success in schema_versions. Useful for data migrations whose source
|
||||
// table eventually disappears (so re-running becomes pointless or
|
||||
// dangerous).
|
||||
func (s *Store) runOnce(version int, label string, fn func() error) error {
|
||||
var applied int
|
||||
if err := s.db.QueryRow(`SELECT COUNT(*) FROM schema_versions WHERE version = ?`, version).Scan(&applied); err != nil {
|
||||
return fmt.Errorf("check %s: %w", label, err)
|
||||
}
|
||||
if applied > 0 {
|
||||
return nil
|
||||
}
|
||||
if err := fn(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.db.Exec(`INSERT INTO schema_versions (version) VALUES (?)`, version); err != nil {
|
||||
return fmt.Errorf("mark %s applied: %w", label, err)
|
||||
}
|
||||
slog.Info("schema migration applied", "version", version, "label", label)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunOnce is the public counterpart of runOnce, exposed so cmd/server can
|
||||
// gate post-store-open migrations (e.g. crypto re-encryption that needs
|
||||
// the ENCRYPTION_KEY which Store does not own) through the same
|
||||
// schema_versions ledger.
|
||||
func (s *Store) RunOnce(version int, label string, fn func() error) error {
|
||||
return s.runOnce(version, label, fn)
|
||||
}
|
||||
|
||||
// EnvelopeMigrator describes the contract a crypto package implements to
|
||||
// rewrite legacy unprefixed-hex ciphertext as versioned envelope values.
|
||||
// hasEnvelope reports whether a value already carries the new prefix.
|
||||
// decrypt returns plaintext for either form; encrypt always produces the
|
||||
// new envelope form. By accepting closures the store stays free of any
|
||||
// import on internal/crypto, mirroring the rest of the package layout.
|
||||
type EnvelopeMigrator struct {
|
||||
HasEnvelope func(value string) bool
|
||||
Decrypt func(ciphertext string) (string, error)
|
||||
Encrypt func(plaintext string) (string, error)
|
||||
}
|
||||
|
||||
// MigrateSecretsToEnvelope walks every column known to carry an encrypted
|
||||
// secret and rewrites legacy unprefixed-hex values into the new
|
||||
// envelope form using the current encryption key.
|
||||
//
|
||||
// Behaviour, per-row:
|
||||
// - empty value → skip (no secret stored)
|
||||
// - already-envelope value → skip (already migrated)
|
||||
// - decrypt fails → skip (value is either plaintext from a v0 boot
|
||||
// OR ciphertext from a rotated key; either way we cannot safely
|
||||
// re-encrypt and leaving it alone preserves the existing read
|
||||
// semantics)
|
||||
// - decrypt succeeds → encrypt to envelope form + UPDATE
|
||||
//
|
||||
// The whole sweep runs in a single transaction so a power-loss
|
||||
// mid-migration leaves the DB in either the pre- or post-migration
|
||||
// state, never half. Idempotent via schema_versions version 2 — the
|
||||
// next boot is a no-op.
|
||||
//
|
||||
// Columns covered:
|
||||
// - settings.npm_password
|
||||
// - settings.cloudflare_api_token
|
||||
// - auth_settings.oidc_client_secret
|
||||
// - registries.token
|
||||
// - workload_env.value WHERE encrypted=1
|
||||
func (s *Store) MigrateSecretsToEnvelope(m EnvelopeMigrator) error {
|
||||
return s.runOnce(2, "secrets envelope migration", func() error {
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
// Single-row tables (settings, auth_settings) — read-update inline.
|
||||
singleRowColumns := []struct {
|
||||
table, column string
|
||||
}{
|
||||
{"settings", "npm_password"},
|
||||
{"settings", "cloudflare_api_token"},
|
||||
{"auth_settings", "oidc_client_secret"},
|
||||
}
|
||||
for _, c := range singleRowColumns {
|
||||
var v string
|
||||
err := tx.QueryRow(
|
||||
fmt.Sprintf(`SELECT %s FROM %s LIMIT 1`, c.column, c.table),
|
||||
).Scan(&v)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
continue
|
||||
}
|
||||
// auth_settings may not exist on a brand-new DB until
|
||||
// the OIDC code touches it; treat as nothing-to-migrate.
|
||||
slog.Debug("envelope migration: column read skipped",
|
||||
"table", c.table, "column", c.column, "error", err)
|
||||
continue
|
||||
}
|
||||
migrated, ok := tryMigrate(m, v)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
fmt.Sprintf(`UPDATE %s SET %s = ?`, c.table, c.column),
|
||||
migrated,
|
||||
); err != nil {
|
||||
return fmt.Errorf("update %s.%s: %w", c.table, c.column, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-row: registries.token
|
||||
if err := migrateRowColumn(tx, m,
|
||||
`SELECT id, token FROM registries WHERE token != ''`,
|
||||
`UPDATE registries SET token = ? WHERE id = ?`,
|
||||
); err != nil {
|
||||
return fmt.Errorf("registries.token: %w", err)
|
||||
}
|
||||
|
||||
// Multi-row: workload_env.value WHERE encrypted=1
|
||||
if err := migrateRowColumn(tx, m,
|
||||
`SELECT id, value FROM workload_env WHERE encrypted = 1 AND value != ''`,
|
||||
`UPDATE workload_env SET value = ? WHERE id = ?`,
|
||||
); err != nil {
|
||||
return fmt.Errorf("workload_env.value: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// migrateRowColumn applies the envelope rewrite to every (id, value)
|
||||
// pair returned by selectQ. updateQ takes (newValue, id) as parameters.
|
||||
// Each row is its own attempt; one row failing migration (decrypt fail)
|
||||
// does not abort the others.
|
||||
func migrateRowColumn(tx *sql.Tx, m EnvelopeMigrator, selectQ, updateQ string) error {
|
||||
rows, err := tx.Query(selectQ)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
type pending struct{ id, newValue string }
|
||||
var updates []pending
|
||||
for rows.Next() {
|
||||
var id, value string
|
||||
if err := rows.Scan(&id, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
newValue, ok := tryMigrate(m, value)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
updates = append(updates, pending{id, newValue})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, u := range updates {
|
||||
if _, err := tx.Exec(updateQ, u.newValue, u.id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tryMigrate returns the envelope-form ciphertext + true when the input
|
||||
// is a legacy unprefixed value that decrypts successfully with the
|
||||
// current key. Returns ("", false) for anything else: empty, already
|
||||
// envelope, plaintext, or decrypt-failed (rotated-key case).
|
||||
func tryMigrate(m EnvelopeMigrator, v string) (string, bool) {
|
||||
if v == "" {
|
||||
return "", false
|
||||
}
|
||||
if m.HasEnvelope(v) {
|
||||
return "", false
|
||||
}
|
||||
plaintext, err := m.Decrypt(v)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
enc, err := m.Encrypt(plaintext)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return enc, true
|
||||
}
|
||||
|
||||
// backfillTriggersFromWorkloads converts embedded trigger config on
|
||||
// workload rows into standalone trigger + binding rows. Runs once per
|
||||
// boot and is idempotent — only workloads with non-empty trigger_kind
|
||||
|
||||
Reference in New Issue
Block a user