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:
+119
-20
@@ -2,20 +2,58 @@ 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.
|
||||
// The directory is packaged as a tar archive and sent to the Docker daemon.
|
||||
// The tag parameter is the image name:tag to apply (e.g., "dw-site-myapp:latest").
|
||||
// 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()
|
||||
|
||||
@@ -50,16 +88,14 @@ func (c *Client) BuildImage(ctx context.Context, contextDir, tag string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", path, err)
|
||||
// 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
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if _, err := io.Copy(tw, file); err != nil {
|
||||
return fmt.Errorf("copy %s to tar: %w", relPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -69,8 +105,16 @@ func (c *Client) BuildImage(ctx context.Context, contextDir, tag string) error {
|
||||
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{
|
||||
Dockerfile: "Dockerfile",
|
||||
Version: build.BuilderV1,
|
||||
Dockerfile: dockerfile,
|
||||
Tags: []string{tag},
|
||||
Remove: true,
|
||||
ForceRemove: true,
|
||||
@@ -80,16 +124,71 @@ func (c *Client) BuildImage(ctx context.Context, contextDir, tag string) error {
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read the build output to completion (required for the build to finish).
|
||||
output, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
// 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)
|
||||
}
|
||||
|
||||
// Check for error in build output.
|
||||
if strings.Contains(string(output), `"error"`) {
|
||||
return fmt.Errorf("build image %s: build errors in output", tag)
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user