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:
@@ -0,0 +1,239 @@
|
||||
// Package preview implements branch-pattern preview deploys. A "template"
|
||||
// workload is one whose git trigger has a BranchPattern configured; when
|
||||
// an inbound push event names a branch other than the template's primary
|
||||
// Branch, the dispatcher materializes (or reuses) a child workload via
|
||||
// MaterializeForBranch and dispatches the deploy against the child. The
|
||||
// child is then torn down on a matching branch-delete event.
|
||||
//
|
||||
// The package is intentionally narrow:
|
||||
// - it does not know about Docker, the proxy, or any plugin internals
|
||||
// - it operates over a Store interface so the webhook handler can mock
|
||||
// it in tests
|
||||
// - it owns the per-branch naming + subdomain mangling so the wiring
|
||||
// code (trigger fan-out) stays a pure dispatch path
|
||||
package preview
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/store"
|
||||
)
|
||||
|
||||
// Store is the slice of the persistence layer the preview package needs.
|
||||
// Defined locally so tests can fake it without dragging the full Store.
|
||||
type Store interface {
|
||||
GetWorkloadByID(id string) (store.Workload, error)
|
||||
ListChildrenByParent(parentID string) ([]store.Workload, error)
|
||||
CreateWorkload(w store.Workload) (store.Workload, error)
|
||||
DeleteWorkload(id string) error
|
||||
}
|
||||
|
||||
// branchSlugPattern strips characters that are unsafe inside a Docker
|
||||
// container name, hostname label, or filesystem path. Compiled once.
|
||||
var branchSlugPattern = regexp.MustCompile(`[^a-z0-9-]+`)
|
||||
|
||||
// slugifyBranch converts a git ref-component into a safe slug. Lowercase,
|
||||
// hyphen-only, length-capped to 32 so name + slug fit inside the Docker
|
||||
// 63-char container-name and 63-char DNS-label limits with room for the
|
||||
// `tf-build-` prefix.
|
||||
func slugifyBranch(branch string) string {
|
||||
b := strings.ToLower(branch)
|
||||
b = strings.ReplaceAll(b, "/", "-")
|
||||
b = branchSlugPattern.ReplaceAllString(b, "-")
|
||||
b = strings.Trim(b, "-")
|
||||
if b == "" {
|
||||
return "branch"
|
||||
}
|
||||
if len(b) > 32 {
|
||||
b = strings.Trim(b[:32], "-")
|
||||
if b == "" {
|
||||
b = "branch"
|
||||
}
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// findExistingPreview returns the child workload whose source_config
|
||||
// already names `branch`, if any. Linear scan over the children list —
|
||||
// fine because the bound is "branches a single team keeps open at once"
|
||||
// which is in the dozens, not thousands.
|
||||
func findExistingPreview(children []store.Workload, branch string) (store.Workload, bool) {
|
||||
for _, c := range children {
|
||||
var cfg struct {
|
||||
Branch string `json:"branch"`
|
||||
}
|
||||
if c.SourceConfig != "" {
|
||||
_ = json.Unmarshal([]byte(c.SourceConfig), &cfg)
|
||||
}
|
||||
if cfg.Branch == branch {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return store.Workload{}, false
|
||||
}
|
||||
|
||||
// patchSourceConfigBranch returns a copy of the template's source_config
|
||||
// with the `branch` field replaced. Unknown keys round-trip so plugin-
|
||||
// specific config (port, dockerfile path, storage settings, ...) survive.
|
||||
// A malformed source_config is replaced rather than propagated so the
|
||||
// preview workload has a clean baseline.
|
||||
func patchSourceConfigBranch(sourceConfig, branch string) (string, error) {
|
||||
if branch == "" {
|
||||
return "", fmt.Errorf("preview: branch is empty")
|
||||
}
|
||||
m := map[string]json.RawMessage{}
|
||||
if sourceConfig != "" && sourceConfig != "{}" {
|
||||
if err := json.Unmarshal([]byte(sourceConfig), &m); err != nil {
|
||||
m = map[string]json.RawMessage{}
|
||||
}
|
||||
}
|
||||
enc, err := json.Marshal(branch)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("preview: encode branch: %w", err)
|
||||
}
|
||||
m["branch"] = enc
|
||||
out, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("preview: encode source_config: %w", err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// patchPublicFacesSubdomain prefixes every public face's Subdomain with
|
||||
// the branch slug so two preview deploys never collide on the same FQDN.
|
||||
// Faces with no subdomain are left untouched — the operator clearly
|
||||
// didn't want a per-branch host carved out for that face.
|
||||
func patchPublicFacesSubdomain(publicFaces, slug string) (string, error) {
|
||||
if publicFaces == "" || publicFaces == "[]" {
|
||||
return publicFaces, nil
|
||||
}
|
||||
var faces []map[string]any
|
||||
if err := json.Unmarshal([]byte(publicFaces), &faces); err != nil {
|
||||
// Malformed faces MUST fail loudly: returning the template's faces
|
||||
// verbatim would give the preview the SAME subdomains as the
|
||||
// template, so the preview's proxy route would clobber the template's
|
||||
// (the exact collision the slug prefix exists to prevent).
|
||||
return "", fmt.Errorf("preview: parse public_faces: %w", err)
|
||||
}
|
||||
for _, f := range faces {
|
||||
sub, ok := f["subdomain"].(string)
|
||||
if !ok || sub == "" {
|
||||
continue
|
||||
}
|
||||
f["subdomain"] = slug + "-" + sub
|
||||
}
|
||||
out, err := json.Marshal(faces)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("preview: re-encode public_faces: %w", err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// IsPreviewChild reports whether child was materialized as a branch preview
|
||||
// of template (vs. an operator-created stage-chain member that merely shares
|
||||
// the parent link — both use parent_workload_id). It reverses the exact
|
||||
// MaterializeForBranch naming formula — name == template.Name + "/" +
|
||||
// slugifyBranch(child's branch) — so a hand-named stage workload under the
|
||||
// same parent is never mistaken for a preview and cascade-deleted.
|
||||
func IsPreviewChild(template, child store.Workload) bool {
|
||||
if child.ParentWorkloadID != template.ID {
|
||||
return false
|
||||
}
|
||||
var cfg struct {
|
||||
Branch string `json:"branch"`
|
||||
}
|
||||
if child.SourceConfig != "" {
|
||||
_ = json.Unmarshal([]byte(child.SourceConfig), &cfg)
|
||||
}
|
||||
if cfg.Branch == "" {
|
||||
return false
|
||||
}
|
||||
return child.Name == template.Name+"/"+slugifyBranch(cfg.Branch)
|
||||
}
|
||||
|
||||
// ListPreviewChildren returns every preview workload materialized from
|
||||
// template. Used by the delete path to cascade-teardown previews so deleting
|
||||
// a template does not orphan their containers, proxy routes, and rows.
|
||||
func ListPreviewChildren(s Store, template store.Workload) ([]store.Workload, error) {
|
||||
children, err := s.ListChildrenByParent(template.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("preview: list children: %w", err)
|
||||
}
|
||||
out := make([]store.Workload, 0, len(children))
|
||||
for _, c := range children {
|
||||
if IsPreviewChild(template, c) {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// MaterializeForBranch returns the existing preview workload for
|
||||
// (template, branch) or creates one if none exists. The new workload
|
||||
// inherits the template's source kind, trigger kind, notification
|
||||
// settings, and public faces (with the branch slug prefixed onto each
|
||||
// subdomain). Idempotent: a second call with the same arguments returns
|
||||
// the same workload row.
|
||||
func MaterializeForBranch(s Store, template store.Workload, branch string) (store.Workload, error) {
|
||||
if branch == "" {
|
||||
return store.Workload{}, fmt.Errorf("preview: branch is required")
|
||||
}
|
||||
|
||||
children, err := s.ListChildrenByParent(template.ID)
|
||||
if err != nil {
|
||||
return store.Workload{}, fmt.Errorf("preview: list children: %w", err)
|
||||
}
|
||||
if existing, ok := findExistingPreview(children, branch); ok {
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
slug := slugifyBranch(branch)
|
||||
newCfg, err := patchSourceConfigBranch(template.SourceConfig, branch)
|
||||
if err != nil {
|
||||
return store.Workload{}, err
|
||||
}
|
||||
newFaces, err := patchPublicFacesSubdomain(template.PublicFaces, slug)
|
||||
if err != nil {
|
||||
return store.Workload{}, err
|
||||
}
|
||||
|
||||
// Webhook + notification secrets are NOT copied to the preview. The
|
||||
// trigger dispatch reaches previews via the parent's trigger binding,
|
||||
// not via a per-preview inbound webhook, so the preview never needs
|
||||
// its own signing secret. Keeping these empty also stops the preview
|
||||
// from masquerading as a first-class workload in webhook routes.
|
||||
child := store.Workload{
|
||||
Kind: template.Kind,
|
||||
Name: template.Name + "/" + slug,
|
||||
AppID: template.AppID,
|
||||
SourceKind: template.SourceKind,
|
||||
SourceConfig: newCfg,
|
||||
TriggerKind: template.TriggerKind,
|
||||
TriggerConfig: template.TriggerConfig,
|
||||
PublicFaces: newFaces,
|
||||
ParentWorkloadID: template.ID,
|
||||
}
|
||||
created, err := s.CreateWorkload(child)
|
||||
if err != nil {
|
||||
return store.Workload{}, fmt.Errorf("preview: create child: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
// FindPreviewForBranch looks up an existing preview without creating
|
||||
// one. Returns (Workload{}, false, nil) when no preview exists. Errors
|
||||
// only on a store failure.
|
||||
func FindPreviewForBranch(s Store, templateID, branch string) (store.Workload, bool, error) {
|
||||
if templateID == "" || branch == "" {
|
||||
return store.Workload{}, false, nil
|
||||
}
|
||||
children, err := s.ListChildrenByParent(templateID)
|
||||
if err != nil {
|
||||
return store.Workload{}, false, fmt.Errorf("preview: list children: %w", err)
|
||||
}
|
||||
w, ok := findExistingPreview(children, branch)
|
||||
return w, ok, nil
|
||||
}
|
||||
Reference in New Issue
Block a user