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.
142 lines
4.6 KiB
Go
142 lines
4.6 KiB
Go
package dockerfile
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// resolveContextDir picks the directory the Docker build context will
|
|
// be packed from, defensively. Returns an error rather than a directory
|
|
// outside the cloned tree even if ContextPath contains a tricky
|
|
// sequence — Validate already rejects ".." and leading "/", but
|
|
// EvalSymlinks here is the second wall.
|
|
//
|
|
// ctx may be "" (use cloneRoot as-is) or a relative subpath like
|
|
// "./api" or "services/api".
|
|
func resolveContextDir(cloneRoot, ctx string) (string, error) {
|
|
cloneRoot, err := filepath.Abs(cloneRoot)
|
|
if err != nil {
|
|
return "", fmt.Errorf("abs cloneRoot: %w", err)
|
|
}
|
|
if real, err := filepath.EvalSymlinks(cloneRoot); err == nil {
|
|
cloneRoot = real
|
|
}
|
|
if ctx == "" || ctx == "." || ctx == "./" {
|
|
return cloneRoot, nil
|
|
}
|
|
candidate := filepath.Join(cloneRoot, filepath.FromSlash(ctx))
|
|
candidate, err = filepath.Abs(candidate)
|
|
if err != nil {
|
|
return "", fmt.Errorf("abs candidate: %w", err)
|
|
}
|
|
// Resolve symlinks BEFORE the prefix check so a planted symlink
|
|
// inside the clone cannot escape the build context.
|
|
if real, err := filepath.EvalSymlinks(candidate); err == nil {
|
|
candidate = real
|
|
}
|
|
if candidate != cloneRoot && !strings.HasPrefix(candidate, cloneRoot+string(filepath.Separator)) {
|
|
return "", fmt.Errorf("context path %q escapes clone root", ctx)
|
|
}
|
|
info, err := os.Stat(candidate)
|
|
if err != nil {
|
|
return "", fmt.Errorf("stat context_path %q: %w", ctx, err)
|
|
}
|
|
if !info.IsDir() {
|
|
return "", fmt.Errorf("context_path %q is not a directory", ctx)
|
|
}
|
|
return candidate, nil
|
|
}
|
|
|
|
// verifyDockerfileExists checks that the named Dockerfile is present in
|
|
// the resolved context. Returns a focused error for the operator instead
|
|
// of letting the daemon error out with a less obvious message later.
|
|
//
|
|
// dockerfilePath is the value from Config.DockerfilePath — relative to
|
|
// the context dir, "Dockerfile" by default.
|
|
func verifyDockerfileExists(contextDir, dockerfilePath string) error {
|
|
if dockerfilePath == "" {
|
|
dockerfilePath = "Dockerfile"
|
|
}
|
|
if strings.HasPrefix(dockerfilePath, "/") || strings.Contains(dockerfilePath, "..") {
|
|
return fmt.Errorf("dockerfile_path %q must be relative and contain no '..'", dockerfilePath)
|
|
}
|
|
full := filepath.Join(contextDir, filepath.FromSlash(dockerfilePath))
|
|
info, err := os.Stat(full)
|
|
if err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return fmt.Errorf("Dockerfile not found at %s/%s", filepath.Base(contextDir), dockerfilePath)
|
|
}
|
|
return fmt.Errorf("stat Dockerfile %q: %w", dockerfilePath, err)
|
|
}
|
|
if info.IsDir() {
|
|
return fmt.Errorf("dockerfile_path %q points at a directory, not a file", dockerfilePath)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sanitizeError clamps an error string before it lands in
|
|
// containers.extra_json (last_error) or echoes through an outbound
|
|
// notification webhook. Mirrors the static-plugin helper of the same
|
|
// name so both plugins agree on the surface area they expose to
|
|
// operators.
|
|
func sanitizeError(msg, accessToken string) string {
|
|
return sanitizeErrorWithSecrets(msg, accessToken, nil)
|
|
}
|
|
|
|
// sanitizeErrorWithSecrets is the dockerfile-plugin-specific extension:
|
|
// when capturing container build/runtime logs into last_error we ALSO
|
|
// need to redact decrypted env-var values, because a malicious or
|
|
// debug-laden Dockerfile can `RUN echo $SECRET` and land a runtime
|
|
// secret in operator-readable state via /api/workloads/{id}/runtime-state.
|
|
//
|
|
// envKV is the same []string the docker client receives — entries shaped
|
|
// "KEY=VALUE". We split on the first '=' and redact every non-empty
|
|
// VALUE longer than 3 chars (shorter values produce too many false-
|
|
// positive substring matches against words like "is" / "of").
|
|
func sanitizeErrorWithSecrets(msg, accessToken string, envKV []string) string {
|
|
if msg == "" {
|
|
return ""
|
|
}
|
|
if accessToken != "" {
|
|
msg = strings.ReplaceAll(msg, accessToken, "[REDACTED]")
|
|
}
|
|
for _, kv := range envKV {
|
|
eq := strings.IndexByte(kv, '=')
|
|
if eq < 0 {
|
|
continue
|
|
}
|
|
value := kv[eq+1:]
|
|
if len(value) < 4 {
|
|
continue
|
|
}
|
|
msg = strings.ReplaceAll(msg, value, "[REDACTED]")
|
|
}
|
|
msg = strings.Map(func(r rune) rune {
|
|
switch r {
|
|
case '\n', '\r', '\t':
|
|
return ' '
|
|
}
|
|
return r
|
|
}, msg)
|
|
const maxLen = 240
|
|
if len(msg) > maxLen {
|
|
// Rune-aware truncation: walk back to the previous rune
|
|
// boundary so multi-byte chars at the cap don't tear.
|
|
cut := maxLen
|
|
for cut > 0 && !isRuneStart(msg[cut]) {
|
|
cut--
|
|
}
|
|
msg = msg[:cut] + "…"
|
|
}
|
|
return msg
|
|
}
|
|
|
|
// isRuneStart reports whether b is a leading byte of a UTF-8 sequence.
|
|
// Used to walk back from a byte-offset cut to a rune boundary.
|
|
func isRuneStart(b byte) bool {
|
|
return b&0xC0 != 0x80
|
|
}
|