410a131cec
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.
195 lines
6.7 KiB
Go
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
|
|
}
|