Files
alexei.dolgolyov 410a131cec 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.
2026-05-29 02:09:54 +03:00

195 lines
6.7 KiB
Go

package docker
import (
"archive/tar"
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/moby/moby/api/types/build"
"github.com/moby/moby/client"
)
// BuildImage builds a Docker image from a directory containing a Dockerfile
// at the context root. Kept as a thin wrapper around BuildImageAt for the
// static-site plugin which always emits its generated Dockerfile at the
// context root. New code should prefer BuildImageAt so the Dockerfile path
// is explicit.
func (c *Client) BuildImage(ctx context.Context, contextDir, tag string) error {
return c.BuildImageAt(ctx, contextDir, "Dockerfile", tag, nil)
}
// BuildImageAt builds a Docker image from a tar of contextDir, using the
// Dockerfile at `dockerfile` *inside* the context (typically "Dockerfile"
// but may be e.g. "docker/Dockerfile" when the user-supplied repo layout
// keeps Dockerfiles in a subfolder).
//
// The dockerfile argument is the path *relative to contextDir*. Empty
// strings are normalised to "Dockerfile" so callers can pass through a
// user config value without sanitising twice.
//
// logFn, if non-nil, is invoked for every non-empty `stream` line the
// daemon emits during the build. Callers use this to forward live build
// progress (e.g. SSE bus). Errors from the daemon are NOT delivered via
// logFn — they surface as the returned error so the caller's failure
// path stays the single source of truth.
func (c *Client) BuildImageAt(ctx context.Context, contextDir, dockerfile, tag string, logFn func(line string)) error {
if dockerfile == "" {
dockerfile = "Dockerfile"
}
// Normalise to forward slashes — the tar entry names use them and the
// Docker daemon expects the same.
dockerfile = filepath.ToSlash(dockerfile)
// Defence-in-depth: the dockerfile path is relative to contextDir and
// is increasingly user/config-supplied (subfolder Dockerfiles). Reject
// absolute paths and any `..` traversal at the boundary so a value like
// "../../etc/passwd" can never be handed to the daemon's build options,
// regardless of which builder backend resolves it.
if filepath.IsAbs(dockerfile) || strings.HasPrefix(dockerfile, "/") ||
dockerfile == ".." || strings.HasPrefix(dockerfile, "../") || strings.Contains(dockerfile, "/../") {
return fmt.Errorf("docker build: invalid dockerfile path %q (must be relative to the build context, no traversal)", dockerfile)
}
// Create tar archive of the build context.
pr, pw := io.Pipe()
go func() {
tw := tar.NewWriter(pw)
err := filepath.Walk(contextDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(contextDir, path)
if err != nil {
return fmt.Errorf("rel path: %w", err)
}
relPath = filepath.ToSlash(relPath)
if relPath == "." {
return nil
}
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return fmt.Errorf("tar header for %s: %w", relPath, err)
}
header.Name = relPath
if err := tw.WriteHeader(header); err != nil {
return fmt.Errorf("write tar header for %s: %w", relPath, err)
}
if info.IsDir() {
return nil
}
// Per-file close, NOT defer. `defer file.Close()` inside the
// WalkFunc only runs when the outer goroutine returns — for a
// build context with thousands of files (node_modules-heavy
// repo) that leaks one fd per file until the walk completes
// and trips EMFILE on default ulimit=1024 systems.
if err := streamFileIntoTar(tw, path, relPath); err != nil {
return err
}
return nil
})
if closeErr := tw.Close(); closeErr != nil && err == nil {
err = closeErr
}
pw.CloseWithError(err)
}()
// Pin the legacy builder explicitly. On Docker Engine 23+ BuildKit
// is the default for the CLI, but the daemon honours the explicit
// Version field on ImageBuildOptions. Legacy builder does NOT support
// `RUN --mount=type=bind,source=/host` so a malicious Dockerfile
// cannot mount host paths into the build context. Switching to
// BuildKit later requires (a) Dockerfile-content validation to
// reject bind-mount hints, or (b) an explicit per-workload opt-in.
resp, err := c.api.ImageBuild(ctx, pr, client.ImageBuildOptions{
Version: build.BuilderV1,
Dockerfile: dockerfile,
Tags: []string{tag},
Remove: true,
ForceRemove: true,
})
if err != nil {
return fmt.Errorf("build image %s: %w", tag, err)
}
defer resp.Body.Close()
// Drain the daemon's NDJSON stream to completion. The stream MUST
// be read for the build to finish — closing the body early aborts
// the build. We parse line-by-line into the {Stream, Error} shape
// the daemon emits so an honest `{"error":"..."}` line surfaces
// without false positives from informational `{"stream":"error
// handling: retrying..."}` chatter that the old strings.Contains
// path would have flagged.
type buildLine struct {
Stream string `json:"stream,omitempty"`
Error string `json:"error,omitempty"`
}
scanner := bufio.NewScanner(resp.Body)
// Some build steps emit single lines exceeding the default 64 KiB
// (e.g. a fat go-mod-download dump). Bump to 1 MiB so we don't
// silently truncate and miss the trailing error line.
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
var firstErr string
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var bl buildLine
if err := json.Unmarshal(line, &bl); err != nil {
// Non-JSON line — daemon shouldn't produce these, but
// don't fail the build over a parse hiccup.
continue
}
if bl.Error != "" && firstErr == "" {
firstErr = bl.Error
}
if logFn != nil && bl.Stream != "" {
logFn(bl.Stream)
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("read build output for %s: %w", tag, err)
}
if firstErr != "" {
return fmt.Errorf("build image %s: %s", tag, firstErr)
}
return nil
}
// streamFileIntoTar opens path, copies its contents into the tar writer
// under the given relPath header, and closes the file *before returning*
// — i.e. once per file, not deferred to the end of the entire walk.
// Extracted so the per-iteration close discipline is obvious at the
// callsite and the file handle isn't accidentally hoisted into the
// caller's defer stack via a future refactor.
func streamFileIntoTar(tw *tar.Writer, path, relPath string) error {
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("open %s: %w", path, err)
}
_, copyErr := io.Copy(tw, file)
// Close BEFORE returning so the fd is released even on copy
// failure. Capture both errors so the more-specific copy error
// wins when both fire.
if cerr := file.Close(); cerr != nil && copyErr == nil {
copyErr = cerr
}
if copyErr != nil {
return fmt.Errorf("copy %s to tar: %w", relPath, copyErr)
}
return nil
}