diff --git a/internal/staticsite/commit_status_reporter.go b/internal/staticsite/commit_status_reporter.go new file mode 100644 index 0000000..7df656e --- /dev/null +++ b/internal/staticsite/commit_status_reporter.go @@ -0,0 +1,55 @@ +package staticsite + +import ( + "context" + "log/slog" +) + +// CommitStatusReporter pushes deploy outcomes back to the git provider as a +// commit status, gated on the per-workload report_commit_status flag. It is +// strictly best-effort: every call is wrapped so a reporting failure is logged +// at Warn and NEVER propagates to fail or block the deploy. +// +// The provider + identifiers are captured once at deploy start so the hot +// transition points (pending/success/failure) read as one-liners. A nil +// receiver (reporting disabled) makes Report a no-op, so callers don't have to +// guard each transition. +// +// It lives in the staticsite package (alongside GitProvider / CommitStatus) +// rather than the plugin package so the source plugins can share it without +// staticsite taking a dependency on plugin. It is parameterized on primitives +// (not plugin.Workload) for the same reason. +type CommitStatusReporter struct { + provider GitProvider + owner string + repo string + sha string + targetURL string + enabled bool +} + +// NewCommitStatusReporter builds a reporter from the resolved deploy inputs. +// When enabled is false (report_commit_status off) or the SHA is empty, the +// returned reporter's Report method is inert. +func NewCommitStatusReporter(provider GitProvider, owner, repo, sha, targetURL string, enabled bool) *CommitStatusReporter { + return &CommitStatusReporter{ + provider: provider, + owner: owner, + repo: repo, + sha: sha, + targetURL: targetURL, + enabled: enabled, + } +} + +// Report sends one commit status, swallowing (and logging) any error. Safe to +// call on a nil/disabled reporter or with a nil provider/empty SHA. +func (r *CommitStatusReporter) Report(ctx context.Context, workloadName, workloadID string, status CommitStatus, description string) { + if r == nil || !r.enabled || r.provider == nil || r.sha == "" { + return + } + if err := r.provider.SetCommitStatus(ctx, r.owner, r.repo, r.sha, status, r.targetURL, description); err != nil { + slog.Warn("commit-status report failed (ignored)", + "workload", workloadName, "workload_id", workloadID, "status", string(status), "error", err) + } +} diff --git a/internal/staticsite/commit_status_reporter_test.go b/internal/staticsite/commit_status_reporter_test.go new file mode 100644 index 0000000..dc478ae --- /dev/null +++ b/internal/staticsite/commit_status_reporter_test.go @@ -0,0 +1,122 @@ +package staticsite + +import ( + "context" + "errors" + "testing" +) + +// fakeReporterProvider is a stub GitProvider that records SetCommitStatus +// calls. Only the methods the reporter exercises are meaningful; the rest +// satisfy the interface and panic if ever hit so a mis-wired test is loud. +type fakeReporterProvider struct { + calls []reporterStatusCall + failErr error // when set, SetCommitStatus returns it (best-effort path) +} + +type reporterStatusCall struct { + owner, repo, sha string + status CommitStatus + targetURL, descr string +} + +func (f *fakeReporterProvider) SetCommitStatus(_ context.Context, owner, repo, sha string, status CommitStatus, targetURL, description string) error { + f.calls = append(f.calls, reporterStatusCall{owner, repo, sha, status, targetURL, description}) + return f.failErr +} + +func (*fakeReporterProvider) Name() string { return "fake" } +func (*fakeReporterProvider) TestConnection(context.Context, string, string) error { + panic("unused") +} +func (*fakeReporterProvider) ListRepos(context.Context, string) ([]RepoInfo, error) { + panic("unused") +} +func (*fakeReporterProvider) ListBranches(context.Context, string, string) ([]string, error) { + panic("unused") +} +func (*fakeReporterProvider) GetLatestCommitSHA(context.Context, string, string, string) (string, error) { + panic("unused") +} +func (*fakeReporterProvider) ListTree(context.Context, string, string, string) ([]FolderEntry, error) { + panic("unused") +} +func (*fakeReporterProvider) DownloadFolder(context.Context, string, string, string, string, string) error { + panic("unused") +} + +// Enabled: forwards to the provider with the captured identifiers + target. +func TestCommitStatusReporter_Enabled_Calls(t *testing.T) { + fp := &fakeReporterProvider{} + r := NewCommitStatusReporter(fp, "owner", "pages", "abc123", "https://app.example.com", true) + + r.Report(context.Background(), "site", "wid-1", CommitStatusPending, "Tinyforge: deploying") + r.Report(context.Background(), "site", "wid-1", CommitStatusSuccess, "Tinyforge: deployed") + + if len(fp.calls) != 2 { + t.Fatalf("calls = %d, want 2", len(fp.calls)) + } + first := fp.calls[0] + if first.owner != "owner" || first.repo != "pages" || first.sha != "abc123" { + t.Errorf("identifiers wrong: %+v", first) + } + if first.status != CommitStatusPending { + t.Errorf("first status = %q, want pending", first.status) + } + if first.targetURL != "https://app.example.com" { + t.Errorf("targetURL = %q", first.targetURL) + } + if fp.calls[1].status != CommitStatusSuccess { + t.Errorf("second status = %q, want success", fp.calls[1].status) + } +} + +// Disabled: the reporter is inert. +func TestCommitStatusReporter_Disabled_NoCalls(t *testing.T) { + fp := &fakeReporterProvider{} + r := NewCommitStatusReporter(fp, "owner", "pages", "abc123", "", false) + + r.Report(context.Background(), "site", "wid-1", CommitStatusSuccess, "x") + if len(fp.calls) != 0 { + t.Fatalf("expected no calls when disabled, got %d", len(fp.calls)) + } +} + +// An empty SHA (e.g. a provider that couldn't resolve the branch) must not +// produce a status call even when reporting is enabled. +func TestCommitStatusReporter_EmptySHA_NoCalls(t *testing.T) { + fp := &fakeReporterProvider{} + r := NewCommitStatusReporter(fp, "owner", "pages", "", "", true) + + r.Report(context.Background(), "site", "wid-1", CommitStatusPending, "x") + if len(fp.calls) != 0 { + t.Fatalf("expected no calls with empty SHA, got %d", len(fp.calls)) + } +} + +// A provider error must be swallowed (best-effort) — Report never panics or +// propagates. We assert it returns normally after a failing provider call. +func TestCommitStatusReporter_ProviderError_Swallowed(t *testing.T) { + fp := &fakeReporterProvider{failErr: errors.New("boom")} + r := NewCommitStatusReporter(fp, "owner", "pages", "abc123", "", true) + + // Should not panic / propagate. + r.Report(context.Background(), "site", "wid-1", CommitStatusFailure, "Tinyforge: deploy failed") + if len(fp.calls) != 1 { + t.Fatalf("expected the failing call to still be recorded, got %d", len(fp.calls)) + } +} + +// A nil reporter (constructed only when needed in some call paths) is safe. +func TestCommitStatusReporter_NilSafe(t *testing.T) { + var r *CommitStatusReporter + // Must not panic. + r.Report(context.Background(), "site", "wid-1", CommitStatusSuccess, "x") +} + +// A nil provider on an enabled reporter is also a no-op (defensive guard). +func TestCommitStatusReporter_NilProvider_NoPanic(t *testing.T) { + r := NewCommitStatusReporter(nil, "owner", "pages", "abc123", "", true) + // Must not panic. + r.Report(context.Background(), "site", "wid-1", CommitStatusSuccess, "x") +} diff --git a/internal/workload/plugin/env.go b/internal/workload/plugin/env.go new file mode 100644 index 0000000..d60aebe --- /dev/null +++ b/internal/workload/plugin/env.go @@ -0,0 +1,43 @@ +package plugin + +import ( + "log/slog" + + "github.com/alexei/tinyforge/internal/crypto" +) + +// BuildWorkloadEnv flattens workload_env rows into the KEY=VALUE list Docker +// expects. Shared by the source plugins (static, dockerfile) so they all +// handle decrypt failures the same way. +// +// Encrypted rows are decrypted lazily so plaintext never lives in the store +// output. A decrypt failure logs and skips the entry rather than failing the +// whole deploy: bricking a sync/build because one rotated key missed an env +// entry would be worse than running with the variable unset and surfacing the +// warning. +// +// sourceName is the slog prefix the caller wants on the two warning lines +// (e.g. "static source" / "dockerfile source") so existing log scrapers keep +// matching the per-source message text. +func BuildWorkloadEnv(deps Deps, workloadID, sourceName string) []string { + rows, err := deps.Store.ListWorkloadEnv(workloadID) + if err != nil { + slog.Warn(sourceName+": list workload env", "workload", workloadID, "error", err) + return nil + } + out := make([]string, 0, len(rows)) + for _, e := range rows { + value := e.Value + if e.Encrypted { + decrypted, err := crypto.Decrypt(deps.EncKey, e.Value) + if err != nil { + slog.Warn(sourceName+": decrypt env value", + "workload", workloadID, "key", e.Key, "error", err) + continue + } + value = decrypted + } + out = append(out, e.Key+"="+value) + } + return out +} diff --git a/internal/workload/plugin/naming.go b/internal/workload/plugin/naming.go new file mode 100644 index 0000000..8b5619b --- /dev/null +++ b/internal/workload/plugin/naming.go @@ -0,0 +1,17 @@ +package plugin + +// IDShort returns the first 8 chars of a workload ID, used as the uniqueness +// suffix on the Docker resources (container, image, volume) a source plugin +// materializes. Workload names are not UNIQUE in the schema today; including +// the ID short prevents two workloads with the same name from clobbering each +// other's container, image, or storage volume. +// +// Shared by the source plugins (static, dockerfile). Each plugin still owns +// its own container/image NAME format (the human-readable prefix differs by +// source kind) — only the ID-short derivation is common. +func IDShort(w Workload) string { + if len(w.ID) < 8 { + return w.ID + } + return w.ID[:8] +} diff --git a/internal/workload/plugin/source/dockerfile/commit_status_test.go b/internal/workload/plugin/source/dockerfile/commit_status_test.go index 7eb915f..72f9fc7 100644 --- a/internal/workload/plugin/source/dockerfile/commit_status_test.go +++ b/internal/workload/plugin/source/dockerfile/commit_status_test.go @@ -1,119 +1,11 @@ package dockerfile -import ( - "context" - "errors" - "testing" +import "testing" - "github.com/alexei/tinyforge/internal/staticsite" - "github.com/alexei/tinyforge/internal/workload/plugin" -) - -// fakeProvider is a stub staticsite.GitProvider that records SetCommitStatus -// calls. Unused interface methods panic so a mis-wired test is loud. -type fakeProvider struct { - calls []statusCall - failErr error -} - -type statusCall struct { - owner, repo, sha string - status staticsite.CommitStatus - targetURL, descr string -} - -func (f *fakeProvider) SetCommitStatus(_ context.Context, owner, repo, sha string, status staticsite.CommitStatus, targetURL, description string) error { - f.calls = append(f.calls, statusCall{owner, repo, sha, status, targetURL, description}) - return f.failErr -} - -func (*fakeProvider) Name() string { return "fake" } -func (*fakeProvider) TestConnection(context.Context, string, string) error { panic("unused") } -func (*fakeProvider) ListRepos(context.Context, string) ([]staticsite.RepoInfo, error) { - panic("unused") -} -func (*fakeProvider) ListBranches(context.Context, string, string) ([]string, error) { - panic("unused") -} -func (*fakeProvider) GetLatestCommitSHA(context.Context, string, string, string) (string, error) { - panic("unused") -} -func (*fakeProvider) ListTree(context.Context, string, string, string) ([]staticsite.FolderEntry, error) { - panic("unused") -} -func (*fakeProvider) DownloadFolder(context.Context, string, string, string, string, string) error { - panic("unused") -} - -func sampleCfg() Config { - return Config{RepoOwner: "owner", RepoName: "svc", Branch: "main", Port: 8080} -} - -// Enabled: forwards to the provider with the captured identifiers + target. -func TestReporter_Enabled_Calls(t *testing.T) { - fp := &fakeProvider{} - cfg := sampleCfg() - cfg.ReportCommitStatus = true - r := newCommitStatusReporter(fp, cfg, "deadbeef", statusTargetURL("svc.example.com")) - - w := plugin.Workload{Name: "svc"} - r.report(context.Background(), w, staticsite.CommitStatusPending, "Tinyforge: deploying") - r.report(context.Background(), w, staticsite.CommitStatusSuccess, "Tinyforge: deployed") - - if len(fp.calls) != 2 { - t.Fatalf("calls = %d, want 2", len(fp.calls)) - } - c := fp.calls[0] - if c.owner != "owner" || c.repo != "svc" || c.sha != "deadbeef" { - t.Errorf("identifiers wrong: %+v", c) - } - if c.targetURL != "https://svc.example.com" { - t.Errorf("targetURL = %q", c.targetURL) - } - if fp.calls[1].status != staticsite.CommitStatusSuccess { - t.Errorf("second status = %q, want success", fp.calls[1].status) - } -} - -// Disabled: inert. -func TestReporter_Disabled_NoCalls(t *testing.T) { - fp := &fakeProvider{} - cfg := sampleCfg() // ReportCommitStatus false - r := newCommitStatusReporter(fp, cfg, "deadbeef", "") - r.report(context.Background(), plugin.Workload{Name: "svc"}, staticsite.CommitStatusFailure, "x") - if len(fp.calls) != 0 { - t.Fatalf("expected no calls when disabled, got %d", len(fp.calls)) - } -} - -// Empty SHA: no call even when enabled. -func TestReporter_EmptySHA_NoCalls(t *testing.T) { - fp := &fakeProvider{} - cfg := sampleCfg() - cfg.ReportCommitStatus = true - r := newCommitStatusReporter(fp, cfg, "", "") - r.report(context.Background(), plugin.Workload{Name: "svc"}, staticsite.CommitStatusPending, "x") - if len(fp.calls) != 0 { - t.Fatalf("expected no calls with empty SHA, got %d", len(fp.calls)) - } -} - -// Provider error is swallowed (best-effort). -func TestReporter_ProviderError_Swallowed(t *testing.T) { - fp := &fakeProvider{failErr: errors.New("boom")} - cfg := sampleCfg() - cfg.ReportCommitStatus = true - r := newCommitStatusReporter(fp, cfg, "deadbeef", "") - r.report(context.Background(), plugin.Workload{Name: "svc"}, staticsite.CommitStatusFailure, "Tinyforge: build failed") - if len(fp.calls) != 1 { - t.Fatalf("expected the failing call to still be recorded, got %d", len(fp.calls)) - } -} - -func TestReporter_NilSafe(t *testing.T) { - var r *commitStatusReporter - r.report(context.Background(), plugin.Workload{Name: "svc"}, staticsite.CommitStatusSuccess, "x") -} +// The commit-status reporter itself now lives in internal/staticsite +// (staticsite.CommitStatusReporter) and is covered by +// internal/staticsite/commit_status_reporter_test.go. This file retains only +// the dockerfile plugin's local statusTargetURL coverage. func TestStatusTargetURL(t *testing.T) { if got := statusTargetURL(""); got != "" { diff --git a/internal/workload/plugin/source/dockerfile/deploy.go b/internal/workload/plugin/source/dockerfile/deploy.go index 076536a..fc84afa 100644 --- a/internal/workload/plugin/source/dockerfile/deploy.go +++ b/internal/workload/plugin/source/dockerfile/deploy.go @@ -95,16 +95,16 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu // (deployStarted). The unchanged-SHA short-circuit below returns via // healUnchanged before that flips, so no status is reported when // nothing was built. retErr is the named return the defer inspects. - reporter := newCommitStatusReporter(provider, cfg, latestSHA, statusTargetURL(domain)) + reporter := staticsite.NewCommitStatusReporter(provider, cfg.RepoOwner, cfg.RepoName, latestSHA, statusTargetURL(domain), cfg.ReportCommitStatus) deployStarted := false defer func() { if !deployStarted { return } if retErr != nil { - reporter.report(ctx, w, staticsite.CommitStatusFailure, "Tinyforge: build failed") + reporter.Report(ctx, w.Name, w.ID, staticsite.CommitStatusFailure, "Tinyforge: build failed") } else { - reporter.report(ctx, w, staticsite.CommitStatusSuccess, "Tinyforge: deployed") + reporter.Report(ctx, w.Name, w.ID, staticsite.CommitStatusSuccess, "Tinyforge: deployed") } }() @@ -153,13 +153,13 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu updateStatus(deps, w, "syncing", prev.LastCommitSHA, "") publishEvent(deps, w, "syncing") deployStarted = true - reporter.report(ctx, w, staticsite.CommitStatusPending, "Tinyforge: deploying") + reporter.Report(ctx, w.Name, w.ID, staticsite.CommitStatusPending, "Tinyforge: deploying") // Clone the repo into a temp dir. We always download the entire // repo tree (folderPath = ""); a ContextPath subset is applied // at build time, not at download time, so a Dockerfile in // `./docker/Dockerfile` with `ContextPath=""` still works. - cloneDir, err := os.MkdirTemp("", "tf-build-"+idShort(w)+"-*") + cloneDir, err := os.MkdirTemp("", "tf-build-"+plugin.IDShort(w)+"-*") if err != nil { updateStatus(deps, w, "failed", prev.LastCommitSHA, sanitizeError(fmt.Sprintf("create clone dir: %v", err), token)) @@ -205,7 +205,7 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu return fmt.Errorf("docker build: %w", err) } - env := buildEnv(deps, w.ID) + env := plugin.BuildWorkloadEnv(deps, w.ID, "dockerfile source") containerPort := strconv.Itoa(cfg.Port) settings, err := deps.Store.GetSettings() @@ -394,46 +394,6 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu return nil } -// commitStatusReporter pushes deploy outcomes back to the git provider as -// a commit status, gated on the per-workload report_commit_status flag. -// Strictly best-effort: every call is wrapped so a reporting failure is -// logged at Warn and NEVER propagates to fail or block the deploy. Mirrors -// the static plugin's reporter of the same name. -type commitStatusReporter struct { - provider staticsite.GitProvider - owner string - repo string - sha string - targetURL string - enabled bool -} - -// newCommitStatusReporter builds a reporter from the decoded config. When -// report_commit_status is off (or the SHA is empty) the returned reporter's -// report method is inert. -func newCommitStatusReporter(provider staticsite.GitProvider, cfg Config, sha, targetURL string) *commitStatusReporter { - return &commitStatusReporter{ - provider: provider, - owner: cfg.RepoOwner, - repo: cfg.RepoName, - sha: sha, - targetURL: targetURL, - enabled: cfg.ReportCommitStatus, - } -} - -// report sends one commit status, swallowing (and logging) any error. Safe -// to call on a disabled reporter or with a nil provider/empty SHA. -func (r *commitStatusReporter) report(ctx context.Context, w plugin.Workload, status staticsite.CommitStatus, description string) { - if r == nil || !r.enabled || r.provider == nil || r.sha == "" { - return - } - if err := r.provider.SetCommitStatus(ctx, r.owner, r.repo, r.sha, status, r.targetURL, description); err != nil { - slog.Warn("dockerfile: commit-status report failed (ignored)", - "workload", w.Name, "status", string(status), "error", err) - } -} - // statusTargetURL derives the https URL the commit status links back to — // the workload's primary public face, or "" when it has none. func statusTargetURL(domain string) string { diff --git a/internal/workload/plugin/source/dockerfile/env.go b/internal/workload/plugin/source/dockerfile/env.go deleted file mode 100644 index b900948..0000000 --- a/internal/workload/plugin/source/dockerfile/env.go +++ /dev/null @@ -1,37 +0,0 @@ -package dockerfile - -import ( - "log/slog" - - "github.com/alexei/tinyforge/internal/crypto" - "github.com/alexei/tinyforge/internal/workload/plugin" -) - -// buildEnv flattens workload_env rows into the KEY=VALUE list Docker -// expects. Mirrors the static plugin's env helper exactly so the two -// plugins handle decrypt failures the same way: log + skip the one -// entry rather than fail the deploy. Bricking a build because one -// rotated key missed an env entry would be worse than running with -// the variable unset and a single warning in the operator's log. -func buildEnv(deps plugin.Deps, workloadID string) []string { - rows, err := deps.Store.ListWorkloadEnv(workloadID) - if err != nil { - slog.Warn("dockerfile source: list workload env", "workload", workloadID, "error", err) - return nil - } - out := make([]string, 0, len(rows)) - for _, e := range rows { - value := e.Value - if e.Encrypted { - decrypted, err := crypto.Decrypt(deps.EncKey, e.Value) - if err != nil { - slog.Warn("dockerfile source: decrypt env value", - "workload", workloadID, "key", e.Key, "error", err) - continue - } - value = decrypted - } - out = append(out, e.Key+"="+value) - } - return out -} diff --git a/internal/workload/plugin/source/dockerfile/naming.go b/internal/workload/plugin/source/dockerfile/naming.go index f3582a8..4fb79d7 100644 --- a/internal/workload/plugin/source/dockerfile/naming.go +++ b/internal/workload/plugin/source/dockerfile/naming.go @@ -6,27 +6,16 @@ import ( "github.com/alexei/tinyforge/internal/workload/plugin" ) -// idShort is the first 8 chars of the workload ID. Same shape as the -// static plugin — workload names are not UNIQUE in the schema, the ID -// short suffix is what keeps two same-named workloads from clobbering -// each other's container/image artifacts. -func idShort(w plugin.Workload) string { - if len(w.ID) < 8 { - return w.ID - } - return w.ID[:8] -} - // containerNameFor is the deterministic container name. Prefix `tf-build-` // distinguishes a dockerfile-built container from `dw-site-` (static) and // per-stage image names at a glance in `docker ps`. func containerNameFor(w plugin.Workload) string { - return fmt.Sprintf("tf-build-%s-%s", w.Name, idShort(w)) + return fmt.Sprintf("tf-build-%s-%s", w.Name, plugin.IDShort(w)) } // imageTagFor is the deterministic image tag the build step emits. Same // shape as the container name so `docker images` shows the linkage at a // glance. func imageTagFor(w plugin.Workload) string { - return fmt.Sprintf("tf-build-%s-%s:latest", w.Name, idShort(w)) + return fmt.Sprintf("tf-build-%s-%s:latest", w.Name, plugin.IDShort(w)) } diff --git a/internal/workload/plugin/source/static/commit_status_test.go b/internal/workload/plugin/source/static/commit_status_test.go index 1ffe6ec..fad308f 100644 --- a/internal/workload/plugin/source/static/commit_status_test.go +++ b/internal/workload/plugin/source/static/commit_status_test.go @@ -1,134 +1,11 @@ package static -import ( - "context" - "errors" - "testing" +import "testing" - "github.com/alexei/tinyforge/internal/staticsite" - "github.com/alexei/tinyforge/internal/workload/plugin" -) - -// fakeProvider is a stub staticsite.GitProvider that records SetCommitStatus -// calls. Only the methods the reporter exercises are meaningful; the rest -// satisfy the interface and panic if ever hit so a mis-wired test is loud. -type fakeProvider struct { - calls []statusCall - failErr error // when set, SetCommitStatus returns it (best-effort path) -} - -type statusCall struct { - owner, repo, sha string - status staticsite.CommitStatus - targetURL, descr string -} - -func (f *fakeProvider) SetCommitStatus(_ context.Context, owner, repo, sha string, status staticsite.CommitStatus, targetURL, description string) error { - f.calls = append(f.calls, statusCall{owner, repo, sha, status, targetURL, description}) - return f.failErr -} - -func (*fakeProvider) Name() string { return "fake" } -func (*fakeProvider) TestConnection(context.Context, string, string) error { - panic("unused") -} -func (*fakeProvider) ListRepos(context.Context, string) ([]staticsite.RepoInfo, error) { - panic("unused") -} -func (*fakeProvider) ListBranches(context.Context, string, string) ([]string, error) { - panic("unused") -} -func (*fakeProvider) GetLatestCommitSHA(context.Context, string, string, string) (string, error) { - panic("unused") -} -func (*fakeProvider) ListTree(context.Context, string, string, string) ([]staticsite.FolderEntry, error) { - panic("unused") -} -func (*fakeProvider) DownloadFolder(context.Context, string, string, string, string, string) error { - panic("unused") -} - -func sampleCfg() Config { - return Config{RepoOwner: "owner", RepoName: "pages", Branch: "main"} -} - -// When report_commit_status is enabled, the reporter forwards to the -// provider with the owner/repo/sha + target URL it was built with. -func TestReporter_Enabled_Calls(t *testing.T) { - fp := &fakeProvider{} - cfg := sampleCfg() - cfg.ReportCommitStatus = true - r := newCommitStatusReporter(fp, cfg, "abc123", statusTargetURL("app.example.com")) - - w := plugin.Workload{Name: "site"} - r.report(context.Background(), w, staticsite.CommitStatusPending, "Tinyforge: deploying") - r.report(context.Background(), w, staticsite.CommitStatusSuccess, "Tinyforge: deployed") - - if len(fp.calls) != 2 { - t.Fatalf("calls = %d, want 2", len(fp.calls)) - } - first := fp.calls[0] - if first.owner != "owner" || first.repo != "pages" || first.sha != "abc123" { - t.Errorf("identifiers wrong: %+v", first) - } - if first.status != staticsite.CommitStatusPending { - t.Errorf("first status = %q, want pending", first.status) - } - if first.targetURL != "https://app.example.com" { - t.Errorf("targetURL = %q", first.targetURL) - } - if fp.calls[1].status != staticsite.CommitStatusSuccess { - t.Errorf("second status = %q, want success", fp.calls[1].status) - } -} - -// When report_commit_status is disabled, the reporter is inert. -func TestReporter_Disabled_NoCalls(t *testing.T) { - fp := &fakeProvider{} - cfg := sampleCfg() // ReportCommitStatus defaults to false - r := newCommitStatusReporter(fp, cfg, "abc123", "") - - r.report(context.Background(), plugin.Workload{Name: "site"}, staticsite.CommitStatusSuccess, "x") - if len(fp.calls) != 0 { - t.Fatalf("expected no calls when disabled, got %d", len(fp.calls)) - } -} - -// An empty SHA (e.g. a provider that couldn't resolve the branch) must not -// produce a status call even when reporting is enabled. -func TestReporter_EmptySHA_NoCalls(t *testing.T) { - fp := &fakeProvider{} - cfg := sampleCfg() - cfg.ReportCommitStatus = true - r := newCommitStatusReporter(fp, cfg, "", "") - - r.report(context.Background(), plugin.Workload{Name: "site"}, staticsite.CommitStatusPending, "x") - if len(fp.calls) != 0 { - t.Fatalf("expected no calls with empty SHA, got %d", len(fp.calls)) - } -} - -// A provider error must be swallowed (best-effort) — report never panics or -// propagates. We assert it returns normally after a failing provider call. -func TestReporter_ProviderError_Swallowed(t *testing.T) { - fp := &fakeProvider{failErr: errors.New("boom")} - cfg := sampleCfg() - cfg.ReportCommitStatus = true - r := newCommitStatusReporter(fp, cfg, "abc123", "") - - // Should not panic / propagate. - r.report(context.Background(), plugin.Workload{Name: "site"}, staticsite.CommitStatusFailure, "Tinyforge: deploy failed") - if len(fp.calls) != 1 { - t.Fatalf("expected the failing call to still be recorded, got %d", len(fp.calls)) - } -} - -// A nil reporter (constructed only when needed in some call paths) is safe. -func TestReporter_NilSafe(t *testing.T) { - var r *commitStatusReporter - // Must not panic. - r.report(context.Background(), plugin.Workload{Name: "site"}, staticsite.CommitStatusSuccess, "x") -} +// The commit-status reporter itself now lives in internal/staticsite +// (staticsite.CommitStatusReporter) and is covered by +// internal/staticsite/commit_status_reporter_test.go. This file retains only +// the static plugin's local statusTargetURL coverage. func TestStatusTargetURL(t *testing.T) { if got := statusTargetURL(""); got != "" { diff --git a/internal/workload/plugin/source/static/deploy.go b/internal/workload/plugin/source/static/deploy.go index 6481b38..7fb4111 100644 --- a/internal/workload/plugin/source/static/deploy.go +++ b/internal/workload/plugin/source/static/deploy.go @@ -93,16 +93,16 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu // short-circuit below returns before that flips, so no status is // reported when nothing was deployed. retErr is the named return the // defer inspects. - reporter := newCommitStatusReporter(provider, cfg, latestSHA, statusTargetURL(domain)) + reporter := staticsite.NewCommitStatusReporter(provider, cfg.RepoOwner, cfg.RepoName, latestSHA, statusTargetURL(domain), cfg.ReportCommitStatus) deployStarted := false defer func() { if !deployStarted { return } if retErr != nil { - reporter.report(ctx, w, staticsite.CommitStatusFailure, "Tinyforge: deploy failed") + reporter.Report(ctx, w.Name, w.ID, staticsite.CommitStatusFailure, "Tinyforge: deploy failed") } else { - reporter.report(ctx, w, staticsite.CommitStatusSuccess, "Tinyforge: deployed") + reporter.Report(ctx, w.Name, w.ID, staticsite.CommitStatusSuccess, "Tinyforge: deployed") } }() @@ -140,10 +140,10 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu updateStatus(deps, w, "syncing", prev.LastCommitSHA, "") publishEvent(deps, w, "syncing") deployStarted = true - reporter.report(ctx, w, staticsite.CommitStatusPending, "Tinyforge: deploying") + reporter.Report(ctx, w.Name, w.ID, staticsite.CommitStatusPending, "Tinyforge: deploying") // Build context — temp dir cleaned up on every exit path. - buildDir, err := os.MkdirTemp("", "dw-site-"+idShort(w)+"-*") + buildDir, err := os.MkdirTemp("", "dw-site-"+plugin.IDShort(w)+"-*") if err != nil { updateStatus(deps, w, "failed", prev.LastCommitSHA, sanitizeError(fmt.Sprintf("create temp dir: %v", err), token)) @@ -215,7 +215,7 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu return fmt.Errorf("build image: %w", err) } - env := buildEnv(deps, w.ID) + env := plugin.BuildWorkloadEnv(deps, w.ID, "static source") containerPort := "80" if mode == "deno" { @@ -424,50 +424,6 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu return nil } -// commitStatusReporter pushes deploy outcomes back to the git provider as -// a commit status, gated on the per-workload report_commit_status flag. -// It is strictly best-effort: every call is wrapped so a reporting failure -// is logged at Warn and NEVER propagates to fail or block the deploy. -// -// The provider + identifiers are captured once at deploy start so the hot -// transition points (pending/success/failure) read as one-liners. A nil -// receiver (reporting disabled) makes report a no-op, so callers don't have -// to guard each site. -type commitStatusReporter struct { - provider staticsite.GitProvider - owner string - repo string - sha string - targetURL string - enabled bool -} - -// newCommitStatusReporter builds a reporter from the decoded config. When -// report_commit_status is off (or the SHA is empty) the returned reporter's -// report method is inert. -func newCommitStatusReporter(provider staticsite.GitProvider, cfg Config, sha, targetURL string) *commitStatusReporter { - return &commitStatusReporter{ - provider: provider, - owner: cfg.RepoOwner, - repo: cfg.RepoName, - sha: sha, - targetURL: targetURL, - enabled: cfg.ReportCommitStatus, - } -} - -// report sends one commit status, swallowing (and logging) any error. Safe -// to call on a disabled reporter or with a nil provider/empty SHA. -func (r *commitStatusReporter) report(ctx context.Context, w plugin.Workload, status staticsite.CommitStatus, description string) { - if r == nil || !r.enabled || r.provider == nil || r.sha == "" { - return - } - if err := r.provider.SetCommitStatus(ctx, r.owner, r.repo, r.sha, status, r.targetURL, description); err != nil { - slog.Warn("static site: commit-status report failed (ignored)", - "site", w.Name, "status", string(status), "error", err) - } -} - // statusTargetURL derives the https URL the commit status links back to — // the workload's primary public face, or "" when it has none. func statusTargetURL(domain string) string { diff --git a/internal/workload/plugin/source/static/env.go b/internal/workload/plugin/source/static/env.go deleted file mode 100644 index 74b4da8..0000000 --- a/internal/workload/plugin/source/static/env.go +++ /dev/null @@ -1,42 +0,0 @@ -package static - -import ( - "log/slog" - - "github.com/alexei/tinyforge/internal/crypto" - "github.com/alexei/tinyforge/internal/workload/plugin" -) - -// buildEnv flattens workload_env rows into the KEY=VALUE list Docker -// expects. Mirrors image/image.go:buildEnv but without an embedded -// cfg.Env map — the static source only carries env via workload_env -// today (legacy static_site_secrets has been replaced by the unified -// workload_env table during the workload refactor). -// -// Encrypted rows are decrypted lazily so plaintext never lives in the -// store output. A decrypt failure logs and skips the entry rather than -// failing the whole deploy: bricking a sync because one rotated key -// missed an env entry would be worse than running with the variable -// unset and surfacing the warning. -func buildEnv(deps plugin.Deps, workloadID string) []string { - rows, err := deps.Store.ListWorkloadEnv(workloadID) - if err != nil { - slog.Warn("static source: list workload env", "workload", workloadID, "error", err) - return nil - } - out := make([]string, 0, len(rows)) - for _, e := range rows { - value := e.Value - if e.Encrypted { - decrypted, err := crypto.Decrypt(deps.EncKey, e.Value) - if err != nil { - slog.Warn("static source: decrypt env value", - "workload", workloadID, "key", e.Key, "error", err) - continue - } - value = decrypted - } - out = append(out, e.Key+"="+value) - } - return out -} diff --git a/internal/workload/plugin/source/static/helpers_test.go b/internal/workload/plugin/source/static/helpers_test.go index 33617aa..9a67926 100644 --- a/internal/workload/plugin/source/static/helpers_test.go +++ b/internal/workload/plugin/source/static/helpers_test.go @@ -12,7 +12,7 @@ import ( ) func TestIdShort_TruncatesLongID(t *testing.T) { - got := idShort(plugin.Workload{ID: "abcd1234-5678-1234-abcd-deadbeef0000"}) + got := plugin.IDShort(plugin.Workload{ID: "abcd1234-5678-1234-abcd-deadbeef0000"}) if got != "abcd1234" { t.Fatalf("idShort = %q, want %q", got, "abcd1234") } @@ -20,14 +20,14 @@ func TestIdShort_TruncatesLongID(t *testing.T) { func TestIdShort_ShortIDPassesThrough(t *testing.T) { // IDs shorter than 8 chars must not panic on slicing. - got := idShort(plugin.Workload{ID: "abc"}) + got := plugin.IDShort(plugin.Workload{ID: "abc"}) if got != "abc" { t.Fatalf("idShort = %q, want %q", got, "abc") } } func TestIdShort_ExactlyEightChars(t *testing.T) { - got := idShort(plugin.Workload{ID: "12345678"}) + got := plugin.IDShort(plugin.Workload{ID: "12345678"}) if got != "12345678" { t.Fatalf("idShort = %q, want %q", got, "12345678") } diff --git a/internal/workload/plugin/source/static/naming.go b/internal/workload/plugin/source/static/naming.go index 666af9e..8375d3a 100644 --- a/internal/workload/plugin/source/static/naming.go +++ b/internal/workload/plugin/source/static/naming.go @@ -7,31 +7,18 @@ import ( "github.com/alexei/tinyforge/internal/workload/plugin" ) -// idShort returns the first 8 chars of a workload ID, used as the -// uniqueness suffix on every Docker resource (container, image, -// volume) the static source materializes. Workload names are not -// UNIQUE in the schema today; including the ID short prevents two -// workloads with the same name from clobbering each other's -// container, image, or storage volume. -func idShort(w plugin.Workload) string { - if len(w.ID) < 8 { - return w.ID - } - return w.ID[:8] -} - // containerNameFor is the deterministic container name. Includes // w.Name for visual continuity in `docker ps` plus the ID short for // uniqueness. func containerNameFor(w plugin.Workload) string { - return fmt.Sprintf("dw-site-%s-%s", w.Name, idShort(w)) + return fmt.Sprintf("dw-site-%s-%s", w.Name, plugin.IDShort(w)) } // imageTagFor is the deterministic image tag — same shape as the // container name so the linkage between an image and the workload // that owns it stays obvious from `docker images`. func imageTagFor(w plugin.Workload) string { - return fmt.Sprintf("dw-site-%s-%s:latest", w.Name, idShort(w)) + return fmt.Sprintf("dw-site-%s-%s:latest", w.Name, plugin.IDShort(w)) } // siteVolumeKey is the input to docker.SiteVolumeName / EnsureSiteVolume @@ -41,7 +28,7 @@ func imageTagFor(w plugin.Workload) string { // envelope. Including idShort prevents two workloads sharing a name // from sharing one persistent volume. func siteVolumeKey(w plugin.Workload) string { - return fmt.Sprintf("%s-%s", w.Name, idShort(w)) + return fmt.Sprintf("%s-%s", w.Name, plugin.IDShort(w)) } // sanitizeError clamps an error string so persisting it (in diff --git a/internal/workload/plugin/source/static/state_integration_test.go b/internal/workload/plugin/source/static/state_integration_test.go index e00c788..86eb2b8 100644 --- a/internal/workload/plugin/source/static/state_integration_test.go +++ b/internal/workload/plugin/source/static/state_integration_test.go @@ -300,7 +300,7 @@ func TestBuildEnv_PlainValues(t *testing.T) { } } - got := buildEnv(deps, wid) + got := plugin.BuildWorkloadEnv(deps, wid, "static source") gotSet := map[string]bool{} for _, line := range got { gotSet[line] = true @@ -331,7 +331,7 @@ func TestBuildEnv_DecryptsEncryptedValues(t *testing.T) { t.Fatalf("seed encrypted env: %v", err) } - got := buildEnv(deps, wid) + got := plugin.BuildWorkloadEnv(deps, wid, "static source") if len(got) != 1 { t.Fatalf("buildEnv returned %d, want 1: %v", len(got), got) } @@ -363,7 +363,7 @@ func TestBuildEnv_SkipsRowsThatFailToDecrypt(t *testing.T) { } } - got := buildEnv(deps, wid) + got := plugin.BuildWorkloadEnv(deps, wid, "static source") // Expect AAA_GOOD and CCC_PLAIN; BBB_BAD silently skipped. Check by // set membership so the assertion doesn't depend on ListWorkloadEnv // preserving any particular order. @@ -393,7 +393,7 @@ func TestBuildEnv_SkipsRowsThatFailToDecrypt(t *testing.T) { func TestBuildEnv_EmptyOnMissingWorkload(t *testing.T) { deps, _ := testDeps(t) - got := buildEnv(deps, "wid-no-env") + got := plugin.BuildWorkloadEnv(deps, "wid-no-env", "static source") if len(got) != 0 { t.Errorf("buildEnv returned %d, want 0: %v", len(got), got) } @@ -414,7 +414,7 @@ func TestBuildEnv_StoreFailurePropagatesAsEmpty(t *testing.T) { } deps := plugin.Deps{Store: st} - got := buildEnv(deps, "anything") + got := plugin.BuildWorkloadEnv(deps, "anything", "static source") if len(got) != 0 { t.Errorf("buildEnv returned %d, want 0 on store failure: %v", len(got), got) }