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:
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user