// 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 }