// 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"` // ReportCommitStatus, when true, pushes the deploy outcome back to the // git provider as a commit status (pending/success/failure) on the // built SHA. Best-effort — a reporting failure never fails a deploy. ReportCommitStatus bool `json:"report_commit_status"` // DeployStrategy selects how a redeploy cuts over. "" (default) and // "recreate" stop the old container before starting the new one (a brief // downtime window). "blue-green" starts the new build alongside the old, // gates it, swaps the proxy route in place, then reaps the old — // zero-downtime under NPM. Validated via plugin.ValidateStrategy. DeployStrategy string `json:"deploy_strategy,omitempty"` } // effectiveStrategy resolves the configured strategy for the dockerfile // source. Empty maps to recreate — the source's historical behavior — so // existing workloads are unchanged. func effectiveStrategy(cfg Config) string { if cfg.DeployStrategy == "" { return plugin.StrategyRecreate } return cfg.DeployStrategy } 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) } } if err := plugin.ValidateStrategy(c.DeployStrategy, true); err != nil { return fmt.Errorf("dockerfile source: %w", err) } 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) }