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:
@@ -0,0 +1,141 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user