Files
tiny-forge/internal/workload/plugin/source/dockerfile/helpers.go
T
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

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
}