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,131 @@
|
||||
// Package dockerfile implements the "dockerfile" source: a git-repo-backed
|
||||
// deployable that builds a Docker image from a user-supplied Dockerfile
|
||||
// and runs one container. This is the "self-hosted Vercel" Source —
|
||||
// users point at a Git repo containing a Dockerfile and Tinyforge
|
||||
// handles clone → build → run → proxy in one shot, with no external CI
|
||||
// pipeline.
|
||||
//
|
||||
// Architecturally the plugin sits between `static` (clones a Git repo,
|
||||
// builds an image, runs one container) and `image` (richer runtime
|
||||
// shape: ports, healthcheck, env, volumes). The deploy pipeline mirrors
|
||||
// static — same git-fetch, same image-tag/container-name shape, same
|
||||
// container-row state persistence — but the build step uses the
|
||||
// operator's Dockerfile instead of generating one.
|
||||
//
|
||||
// The full pipeline is implemented inline in this package
|
||||
// (deploy.go / teardown.go / reconcile.go) so a new dockerfile source
|
||||
// kind is usable immediately on init() — no separate registration step
|
||||
// in the deployer.
|
||||
package dockerfile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/alexei/tinyforge/internal/workload/plugin"
|
||||
)
|
||||
|
||||
// Config is the per-workload source config blob. Mirrors the shape of
|
||||
// the static plugin's Config so the UI wizard can largely reuse the
|
||||
// existing Git-discovery + branch-picker + repo-picker components.
|
||||
//
|
||||
// Build-side fields:
|
||||
//
|
||||
// - DockerfilePath: path to the Dockerfile *within the context*
|
||||
// directory. Defaults to "Dockerfile". Use e.g. "docker/Dockerfile"
|
||||
// when the operator's repo keeps Dockerfiles in a subfolder.
|
||||
// - ContextPath: subfolder of the cloned repo to use as the build
|
||||
// context. Defaults to "" (repo root). Use e.g. "./api" when the
|
||||
// repo's Dockerfile lives next to a backend service in a monorepo.
|
||||
//
|
||||
// Runtime-side fields:
|
||||
//
|
||||
// - Port: container port the workload listens on. Required.
|
||||
// - Healthcheck: optional curl-style probe; empty disables.
|
||||
//
|
||||
// Env vars and volume mounts are handled out-of-band via the
|
||||
// workload_env and workload_volumes tables, mirroring the image source.
|
||||
type Config struct {
|
||||
Provider string `json:"provider"` // "gitea" | "github" | "gitlab"; "" = autodetect
|
||||
BaseURL string `json:"base_url"` // e.g. https://git.example.com
|
||||
RepoOwner string `json:"repo_owner"`
|
||||
RepoName string `json:"repo_name"`
|
||||
Branch string `json:"branch"`
|
||||
ContextPath string `json:"context_path"` // path within repo (root by default)
|
||||
DockerfilePath string `json:"dockerfile_path"` // relative to context_path; "Dockerfile" by default
|
||||
AccessToken string `json:"access_token"` // encrypted; optional for public repos
|
||||
|
||||
Port int `json:"port"`
|
||||
Healthcheck string `json:"healthcheck,omitempty"`
|
||||
}
|
||||
|
||||
type source struct{}
|
||||
|
||||
// Eager registration — the deploy pipeline lives entirely inside this
|
||||
// package, so the kind is usable as soon as init() fires.
|
||||
func init() { plugin.RegisterSource(&source{}) }
|
||||
|
||||
func (*source) Kind() string { return "dockerfile" }
|
||||
|
||||
func (*source) SchemaSample() any {
|
||||
return Config{
|
||||
Provider: "gitea",
|
||||
BaseURL: "https://git.example.com",
|
||||
RepoOwner: "owner",
|
||||
RepoName: "myservice",
|
||||
Branch: "main",
|
||||
ContextPath: "",
|
||||
DockerfilePath: "Dockerfile",
|
||||
Port: 8080,
|
||||
}
|
||||
}
|
||||
|
||||
// Validate rejects obviously-malformed configs before the deploy
|
||||
// pipeline materializes a temp dir, downloads a repo, and burns
|
||||
// minutes of build time on input that was never going to work.
|
||||
func (*source) Validate(cfg json.RawMessage) error {
|
||||
var c Config
|
||||
if len(cfg) == 0 {
|
||||
return fmt.Errorf("dockerfile source: config is required")
|
||||
}
|
||||
if err := json.Unmarshal(cfg, &c); err != nil {
|
||||
return fmt.Errorf("dockerfile source: invalid json: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(c.RepoOwner) == "" || strings.TrimSpace(c.RepoName) == "" {
|
||||
return fmt.Errorf("dockerfile source: repo_owner and repo_name are required")
|
||||
}
|
||||
if c.Port <= 0 || c.Port > 65535 {
|
||||
return fmt.Errorf("dockerfile source: port must be between 1 and 65535 (got %d)", c.Port)
|
||||
}
|
||||
// Defense in depth: a leading "/" or any ".." segment in
|
||||
// DockerfilePath / ContextPath would escape the build context. The
|
||||
// plugin's deploy() does its own normalization too; rejecting here
|
||||
// gives the operator a clear error at save-time instead of a
|
||||
// confusing "no such file" mid-build.
|
||||
for _, p := range []string{c.DockerfilePath, c.ContextPath} {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(p, "/") {
|
||||
return fmt.Errorf("dockerfile source: %q must be relative", p)
|
||||
}
|
||||
if strings.Contains(p, "..") {
|
||||
return fmt.Errorf("dockerfile source: %q must not contain '..'", p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*source) Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) error {
|
||||
return deploy(ctx, deps, w, intent)
|
||||
}
|
||||
|
||||
func (*source) Teardown(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
|
||||
return teardown(ctx, deps, w)
|
||||
}
|
||||
|
||||
func (*source) Reconcile(ctx context.Context, deps plugin.Deps, w plugin.Workload) error {
|
||||
return reconcile(ctx, deps, w)
|
||||
}
|
||||
Reference in New Issue
Block a user