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:
2026-05-29 02:09:54 +03:00
parent 956943edbb
commit 410a131cec
112 changed files with 13285 additions and 2765 deletions
+13 -3
View File
@@ -92,17 +92,27 @@ func (c *Compose) Ps(ctx context.Context, projectName, yamlPath string) ([]Servi
}
// Logs runs `docker compose -p <projectName> logs --no-color --tail=<n> <service>`.
// If service is empty, logs for all services are returned.
// If service is empty, logs for all services are returned. The service arg
// is preceded by `--` so a service name that begins with `-` cannot be
// re-parsed as a flag by the docker CLI (flag-injection guard).
func (c *Compose) Logs(ctx context.Context, projectName, service string, tail int) (string, error) {
args := []string{"logs", "--no-color", fmt.Sprintf("--tail=%d", tail)}
if service != "" {
args = append(args, service)
args = append(args, "--", service)
}
return c.run(ctx, projectName, args...)
}
// run executes `docker compose -p <projectName> <args...>` and returns combined output.
// run executes `docker compose -p <projectName> <args...>` and returns
// combined output. projectName is verified not to begin with `-` because
// `docker compose -p '--foo'` would otherwise be re-parsed as a flag —
// the callers already sanitize project names through projectNameSanitizer,
// but a belt-and-braces refusal here means any future caller cannot
// accidentally bypass the sanitizer.
func (c *Compose) run(ctx context.Context, projectName string, args ...string) (string, error) {
if projectName == "" || strings.HasPrefix(projectName, "-") {
return "", fmt.Errorf("docker compose: refusing project name %q", projectName)
}
full := append([]string{"compose", "-p", projectName}, args...)
cmd := exec.CommandContext(ctx, c.binary, full...)
var buf bytes.Buffer
+146 -6
View File
@@ -2,6 +2,7 @@ package stack
import (
"fmt"
"strings"
"gopkg.in/yaml.v3"
)
@@ -15,11 +16,25 @@ type ComposeSpec struct {
}
// ServiceSpec captures the subset of compose service fields we inspect.
//
// All host-escape-adjacent fields are decoded here even though Tinyforge
// itself never reads them at runtime — surfacing them to Validate() is the
// only way to *reject* them. Add new fields here when blocking a new
// escape vector.
type ServiceSpec struct {
Image string `yaml:"image,omitempty"`
Ports []any `yaml:"ports,omitempty"`
Labels map[string]string `yaml:"labels,omitempty"`
Privileged bool `yaml:"privileged,omitempty"`
Image string `yaml:"image,omitempty"`
Build any `yaml:"build,omitempty"` // banned — see Validate
Ports []any `yaml:"ports,omitempty"`
Labels map[string]string `yaml:"labels,omitempty"`
Privileged bool `yaml:"privileged,omitempty"`
Volumes []any `yaml:"volumes,omitempty"`
NetworkMode string `yaml:"network_mode,omitempty"`
Pid string `yaml:"pid,omitempty"`
Ipc string `yaml:"ipc,omitempty"`
UsernsMode string `yaml:"userns_mode,omitempty"`
CapAdd []string `yaml:"cap_add,omitempty"`
Devices []any `yaml:"devices,omitempty"`
SecurityOpt []string `yaml:"security_opt,omitempty"`
}
// Parse decodes YAML into a ComposeSpec. Returns a descriptive error on failure.
@@ -35,10 +50,20 @@ func Parse(yamlText string) (ComposeSpec, error) {
}
// Validate enforces Tinyforge-level constraints beyond compose schema validity.
// All blocked fields below are documented host-escape vectors: any one of
// them on its own gives the container root on the host. Tinyforge already
// owns the docker socket, so the threat model is "any admin == host root,"
// and these blocks raise the bar for any *future* viewer-to-admin
// escalation as well as honest-mistake guardrails.
//
// Current rules:
// - No service may set `privileged: true`.
// - Every service must declare an image (compose supports build: too, but
// Tinyforge v1 disallows building from context to avoid arbitrary-code exec).
// - Every service must declare an image (build contexts disallowed).
// - No host-IPC / host-PID / host-userns / host networking.
// - No `cap_add`, `security_opt`, `devices`.
// - `volumes` may not bind-mount the docker socket, /, /etc, /var, /proc,
// /sys, /root, or /home — list is conservative; operators with real
// bind-mount needs should ship a Source plugin or a dedicated wizard.
func Validate(spec ComposeSpec) error {
for name, svc := range spec.Services {
if svc.Privileged {
@@ -47,6 +72,121 @@ func Validate(spec ComposeSpec) error {
if svc.Image == "" {
return fmt.Errorf("service %q: image is required (build contexts not supported)", name)
}
if svc.Build != nil {
return fmt.Errorf("service %q: build: is not supported (use image:)", name)
}
if isBlockedNamespaceMode(svc.NetworkMode) {
return fmt.Errorf("service %q: network_mode %q is not allowed", name, svc.NetworkMode)
}
if isBlockedNamespaceMode(svc.Pid) {
return fmt.Errorf("service %q: pid: %q is not allowed", name, svc.Pid)
}
if isBlockedNamespaceMode(svc.Ipc) {
return fmt.Errorf("service %q: ipc: %q is not allowed", name, svc.Ipc)
}
if isHostMode(svc.UsernsMode) {
return fmt.Errorf("service %q: userns_mode %q is not allowed", name, svc.UsernsMode)
}
if len(svc.CapAdd) > 0 {
return fmt.Errorf("service %q: cap_add is not allowed", name)
}
if len(svc.SecurityOpt) > 0 {
return fmt.Errorf("service %q: security_opt is not allowed", name)
}
if len(svc.Devices) > 0 {
return fmt.Errorf("service %q: devices is not allowed", name)
}
for _, v := range svc.Volumes {
if host, ok := bindMountHostPath(v); ok {
if isBlockedBindMount(host) {
return fmt.Errorf("service %q: bind-mounting %q is not allowed", name, host)
}
}
}
}
return nil
}
// isHostMode reports a host-namespace share, i.e. network_mode / pid / ipc /
// userns_mode set to "host". (It deliberately does NOT match "host-gateway",
// which is an extra_hosts value, not a namespace mode — matching it here only
// produced misleading rejections.)
func isHostMode(v string) bool {
return v == "host"
}
// isBlockedNamespaceMode reports a namespace mode that must be rejected for
// network_mode / pid / ipc: either host sharing ("host") or joining another
// container's / compose service's namespace ("container:<id>",
// "service:<name>"). The container/service joins are a lateral-movement and
// sandbox-escape vector — a malicious service could attach to a victim
// container's network or PID namespace.
func isBlockedNamespaceMode(v string) bool {
return isHostMode(v) ||
strings.HasPrefix(v, "container:") ||
strings.HasPrefix(v, "service:")
}
// bindMountHostPath extracts the host-side path from a compose volume
// declaration. Compose accepts two shapes: a short string "src:dst[:mode]"
// and a long form map with a "source" key. Returns ok=false for named
// volumes (no host source).
func bindMountHostPath(v any) (string, bool) {
switch t := v.(type) {
case string:
// "named:/in/container" has no '/' or '.' prefix on the source.
if t == "" {
return "", false
}
parts := strings.SplitN(t, ":", 3)
src := parts[0]
if strings.HasPrefix(src, "/") || strings.HasPrefix(src, ".") || strings.HasPrefix(src, "~") {
return src, true
}
return "", false
case map[string]any:
if typ, _ := t["type"].(string); typ != "" && typ != "bind" {
return "", false
}
if src, ok := t["source"].(string); ok {
if strings.HasPrefix(src, "/") || strings.HasPrefix(src, ".") || strings.HasPrefix(src, "~") {
return src, true
}
}
}
return "", false
}
// isBlockedBindMount returns true for paths that obviously escape the
// container's intended sandbox. Conservative deny-list — operators with
// legitimate bind-mount needs should write a dedicated Source plugin
// rather than tunnel them through compose.
func isBlockedBindMount(host string) bool {
// Normalize trailing slash so "/var" and "/var/" both match.
clean := strings.TrimRight(host, "/")
if clean == "" || clean == "/" {
return true
}
// Relative ("./x", "../x", ".") and home-relative ("~/...") sources are
// resolved by Docker against the compose working directory (which
// Tinyforge controls and never intends as a host-bind source) or left
// unexpanded — and "../" can climb out of that directory entirely. The
// absolute-prefix deny-list below can't see these, so reject them
// outright rather than give a false sense of coverage.
if strings.HasPrefix(clean, ".") || strings.HasPrefix(clean, "~") {
return true
}
// Specific blocked files / sockets.
switch clean {
case "/var/run/docker.sock", "/run/docker.sock":
return true
}
// Blocked prefixes (cover sub-paths too).
blocked := []string{"/etc", "/var", "/proc", "/sys", "/root", "/home", "/boot", "/dev"}
for _, p := range blocked {
if clean == p || strings.HasPrefix(clean, p+"/") {
return true
}
}
return false
}