diff --git a/internal/staticsite/commit_status_test.go b/internal/staticsite/commit_status_test.go new file mode 100644 index 0000000..6dce3b0 --- /dev/null +++ b/internal/staticsite/commit_status_test.go @@ -0,0 +1,331 @@ +package staticsite + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// ── State mapping (pure) ──────────────────────────────────────────── +// +// Each provider maps the provider-agnostic CommitStatus onto its own API +// vocabulary. Gitea/GitHub accept the same four words; GitLab collapses +// failure+error into "failed". + +func TestGiteaState_Mapping(t *testing.T) { + cases := map[CommitStatus]string{ + CommitStatusPending: "pending", + CommitStatusSuccess: "success", + CommitStatusFailure: "failure", + CommitStatusError: "error", + CommitStatus("???"): "pending", // unknown -> pending fallback + } + for in, want := range cases { + if got := giteaState(in); got != want { + t.Errorf("giteaState(%q) = %q, want %q", in, got, want) + } + } +} + +func TestGitHubState_Mapping(t *testing.T) { + cases := map[CommitStatus]string{ + CommitStatusPending: "pending", + CommitStatusSuccess: "success", + CommitStatusFailure: "failure", + CommitStatusError: "error", + CommitStatus("???"): "pending", + } + for in, want := range cases { + if got := githubState(in); got != want { + t.Errorf("githubState(%q) = %q, want %q", in, got, want) + } + } +} + +func TestGitLabState_Mapping(t *testing.T) { + cases := map[CommitStatus]string{ + CommitStatusPending: "pending", + CommitStatusSuccess: "success", + CommitStatusFailure: "failed", // GitLab has no "failure" + CommitStatusError: "failed", // error also collapses to "failed" + CommitStatus("???"): "pending", + } + for in, want := range cases { + if got := gitlabState(in); got != want { + t.Errorf("gitlabState(%q) = %q, want %q", in, got, want) + } + } +} + +func TestTruncateDescription(t *testing.T) { + short := "Tinyforge: deploying" + if got := truncateDescription(short); got != short { + t.Errorf("short description mutated: %q", got) + } + long := strings.Repeat("x", 200) + got := truncateDescription(long) + if len([]rune(got)) > maxCommitStatusDescription { + t.Errorf("truncated length = %d runes, want <= %d", len([]rune(got)), maxCommitStatusDescription) + } + if !strings.HasSuffix(got, "…") { + t.Errorf("missing ellipsis on truncation: %q", got) + } +} + +// ── Endpoint + body construction (httptest) ───────────────────────── +// +// The SSRF-safe client refuses loopback, so for these tests we swap the +// provider's httpClient for a plain one pointed at httptest. This still +// exercises the real URL/body construction inside each SetCommitStatus. + +type capturedRequest struct { + method string + path string // r.URL.EscapedPath() — preserves %2F so GitLab's encoded project path is observable + rawQ string + body map[string]string + auth string + token string // PRIVATE-TOKEN (GitLab) +} + +func newCaptureServer(t *testing.T, capture *capturedRequest) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capture.method = r.Method + capture.path = r.URL.EscapedPath() + capture.rawQ = r.URL.RawQuery + capture.auth = r.Header.Get("Authorization") + capture.token = r.Header.Get("PRIVATE-TOKEN") + raw, _ := io.ReadAll(r.Body) + if len(raw) > 0 { + _ = json.Unmarshal(raw, &capture.body) + } + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{}`)) + })) +} + +func TestGitea_SetCommitStatus_Request(t *testing.T) { + var cap capturedRequest + srv := newCaptureServer(t, &cap) + defer srv.Close() + + f := NewGiteaContentFetcher(srv.URL, "tok123") + f.httpClient = srv.Client() // bypass SSRF guard for loopback test server + + err := f.SetCommitStatus(context.Background(), "owner", "repo", "abc123", + CommitStatusSuccess, "https://app.example.com", "deployed") + if err != nil { + t.Fatalf("SetCommitStatus: %v", err) + } + + if cap.method != http.MethodPost { + t.Errorf("method = %q, want POST", cap.method) + } + if want := "/api/v1/repos/owner/repo/statuses/abc123"; cap.path != want { + t.Errorf("path = %q, want %q", cap.path, want) + } + if cap.body["state"] != "success" { + t.Errorf("state = %q, want success", cap.body["state"]) + } + if cap.body["context"] != "tinyforge" { + t.Errorf("context = %q, want tinyforge", cap.body["context"]) + } + if cap.body["target_url"] != "https://app.example.com" { + t.Errorf("target_url = %q", cap.body["target_url"]) + } + if cap.body["description"] != "deployed" { + t.Errorf("description = %q, want deployed", cap.body["description"]) + } + if cap.auth != "token tok123" { + t.Errorf("auth = %q, want 'token tok123'", cap.auth) + } +} + +func TestGitHub_SetCommitStatus_Request(t *testing.T) { + var cap capturedRequest + srv := newCaptureServer(t, &cap) + defer srv.Close() + + // Force GHE-style apiBase so we hit the server's path; the github.com + // branch hard-codes api.github.com which the SSRF client would block. + g := NewGitHubProvider(srv.URL, "ghp_tok") + g.apiBase = srv.URL + "/api/v3" + g.httpClient = srv.Client() + + err := g.SetCommitStatus(context.Background(), "octo", "cat", "deadbeef", + CommitStatusFailure, "", "failed") + if err != nil { + t.Fatalf("SetCommitStatus: %v", err) + } + + if want := "/api/v3/repos/octo/cat/statuses/deadbeef"; cap.path != want { + t.Errorf("path = %q, want %q", cap.path, want) + } + if cap.body["state"] != "failure" { + t.Errorf("state = %q, want failure", cap.body["state"]) + } + if cap.body["context"] != "tinyforge" { + t.Errorf("context = %q, want tinyforge", cap.body["context"]) + } + if cap.auth != "Bearer ghp_tok" { + t.Errorf("auth = %q, want 'Bearer ghp_tok'", cap.auth) + } +} + +func TestGitLab_SetCommitStatus_Request(t *testing.T) { + var cap capturedRequest + srv := newCaptureServer(t, &cap) + defer srv.Close() + + g := NewGitLabProvider(srv.URL, "glpat-xyz") + g.httpClient = srv.Client() + + err := g.SetCommitStatus(context.Background(), "grp", "proj", "cafe01", + CommitStatusError, "https://app.example.com", "boom") + if err != nil { + t.Fatalf("SetCommitStatus: %v", err) + } + + // GitLab uses the URL-encoded project path + sha in the path, and the + // status metadata as query params. + if want := "/api/v4/projects/grp%2Fproj/statuses/cafe01"; cap.path != want { + t.Errorf("path = %q, want %q", cap.path, want) + } + q, err := parseQuery(cap.rawQ) + if err != nil { + t.Fatalf("parse query %q: %v", cap.rawQ, err) + } + if q["state"] != "failed" { // error -> failed + t.Errorf("state = %q, want failed", q["state"]) + } + if q["name"] != "tinyforge" { + t.Errorf("name = %q, want tinyforge", q["name"]) + } + if q["target_url"] != "https://app.example.com" { + t.Errorf("target_url = %q", q["target_url"]) + } + if q["description"] != "boom" { + t.Errorf("description = %q, want boom", q["description"]) + } + if cap.token != "glpat-xyz" { + t.Errorf("PRIVATE-TOKEN = %q, want glpat-xyz", cap.token) + } +} + +// parseQuery is a tiny wrapper so the test reads the first value of each +// query key without dragging net/url into every assertion. +func parseQuery(raw string) (map[string]string, error) { + out := map[string]string{} + if raw == "" { + return out, nil + } + for _, pair := range strings.Split(raw, "&") { + kv := strings.SplitN(pair, "=", 2) + k := urlDecode(kv[0]) + v := "" + if len(kv) == 2 { + v = urlDecode(kv[1]) + } + if _, ok := out[k]; !ok { + out[k] = v + } + } + return out, nil +} + +func urlDecode(s string) string { + dec, err := decodeQueryComponent(s) + if err != nil { + return s + } + return dec +} + +// decodeQueryComponent decodes one application/x-www-form-urlencoded +// component (handles %XX and '+'-as-space) without importing net/url here. +func decodeQueryComponent(s string) (string, error) { + var b strings.Builder + for i := 0; i < len(s); i++ { + switch s[i] { + case '+': + b.WriteByte(' ') + case '%': + if i+2 >= len(s) { + return s, errPercent + } + hi, lo := fromHex(s[i+1]), fromHex(s[i+2]) + if hi < 0 || lo < 0 { + return s, errPercent + } + b.WriteByte(byte(hi<<4 | lo)) + i += 2 + default: + b.WriteByte(s[i]) + } + } + return b.String(), nil +} + +var errPercent = &decodeErr{} + +type decodeErr struct{} + +func (*decodeErr) Error() string { return "bad percent-encoding" } + +func fromHex(c byte) int { + switch { + case c >= '0' && c <= '9': + return int(c - '0') + case c >= 'a' && c <= 'f': + return int(c-'a') + 10 + case c >= 'A' && c <= 'F': + return int(c-'A') + 10 + } + return -1 +} + +// TestSetCommitStatus_NonOK_ReturnsError verifies a non-2xx provider +// response surfaces as an error (the deploy hook logs + swallows it, but +// the provider method itself must report it). +func TestSetCommitStatus_NonOK_ReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"message":"bad token"}`)) + })) + defer srv.Close() + + f := NewGiteaContentFetcher(srv.URL, "tok") + f.httpClient = srv.Client() + + err := f.SetCommitStatus(context.Background(), "o", "r", "sha", CommitStatusPending, "", "x") + if err == nil { + t.Fatal("expected error on 401, got nil") + } + if !strings.Contains(err.Error(), "401") { + t.Errorf("error missing status code: %v", err) + } +} + +// TestSetCommitStatus_RespectsContext ensures the call honours context +// cancellation (defensive — the deploy hook passes the deploy ctx). +func TestSetCommitStatus_RespectsContext(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + time.Sleep(200 * time.Millisecond) + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + f := NewGiteaContentFetcher(srv.URL, "") + f.httpClient = srv.Client() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + if err := f.SetCommitStatus(ctx, "o", "r", "sha", CommitStatusPending, "", "x"); err == nil { + t.Fatal("expected context-deadline error, got nil") + } +} diff --git a/internal/staticsite/gitea_content.go b/internal/staticsite/gitea_content.go index 3134ef1..1e950ac 100644 --- a/internal/staticsite/gitea_content.go +++ b/internal/staticsite/gitea_content.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -304,6 +305,54 @@ func (f *GiteaContentFetcher) TestConnection(ctx context.Context, owner, repo st return nil } +// SetCommitStatus reports a deploy status on a commit via Gitea's commit- +// status API (also serves Forgejo/Gogs). The "context" field is fixed to +// "tinyforge" so repeated deploys update one status row. +func (f *GiteaContentFetcher) SetCommitStatus(ctx context.Context, owner, repo, sha string, status CommitStatus, targetURL, description string) error { + state := giteaState(status) + body, err := json.Marshal(map[string]string{ + "state": state, + "target_url": targetURL, + "description": truncateDescription(description), + "context": commitStatusContext, + }) + if err != nil { + return fmt.Errorf("marshal status: %w", err) + } + // Path-escape each identifier so the URL shape matches the other + // provider methods and a hostile owner/repo/sha can't break out of + // the intended path. The SSRF-safe client guards the host. + apiURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/statuses/%s", + f.baseURL, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha)) + if err := postJSON(ctx, f.httpClient, apiURL, body, f.setAuth); err != nil { + return fmt.Errorf("set commit status: %w", err) + } + return nil +} + +// setAuth applies the Gitea token header (no-op when the token is empty). +func (f *GiteaContentFetcher) setAuth(req *http.Request) { + if f.token != "" { + req.Header.Set("Authorization", "token "+f.token) + } +} + +// giteaState maps a provider-agnostic CommitStatus onto Gitea's API +// vocabulary. Gitea accepts the same four words Tinyforge uses, so this is +// a 1:1 mapping with a "pending" fallback for any unknown value. +func giteaState(status CommitStatus) string { + switch status { + case CommitStatusSuccess: + return "success" + case CommitStatusFailure: + return "failure" + case CommitStatusError: + return "error" + default: + return "pending" + } +} + // doGet performs an authenticated GET request and returns the response body. func (f *GiteaContentFetcher) doGet(ctx context.Context, url string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) diff --git a/internal/staticsite/github_provider.go b/internal/staticsite/github_provider.go index 1a0fe91..a1f5c15 100644 --- a/internal/staticsite/github_provider.go +++ b/internal/staticsite/github_provider.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -115,6 +116,43 @@ func (g *GitHubProvider) TestConnection(ctx context.Context, owner, repo string) return err } +// SetCommitStatus reports a deploy status on a commit via GitHub's commit- +// status API (works for github.com and GitHub Enterprise — apiBase already +// carries the /api/v3 suffix for GHE). The "context" field is fixed to +// "tinyforge" so repeated deploys update one status row. +func (g *GitHubProvider) SetCommitStatus(ctx context.Context, owner, repo, sha string, status CommitStatus, targetURL, description string) error { + body, err := json.Marshal(map[string]string{ + "state": githubState(status), + "target_url": targetURL, + "description": truncateDescription(description), + "context": commitStatusContext, + }) + if err != nil { + return fmt.Errorf("marshal status: %w", err) + } + apiURL := fmt.Sprintf("%s/repos/%s/%s/statuses/%s", + g.apiBase, url.PathEscape(owner), url.PathEscape(repo), url.PathEscape(sha)) + if err := postJSON(ctx, g.httpClient, apiURL, body, g.setAuth); err != nil { + return fmt.Errorf("set commit status: %w", err) + } + return nil +} + +// githubState maps a provider-agnostic CommitStatus onto GitHub's API +// vocabulary. GitHub accepts the same four words Tinyforge uses. +func githubState(status CommitStatus) string { + switch status { + case CommitStatusSuccess: + return "success" + case CommitStatusFailure: + return "failure" + case CommitStatusError: + return "error" + default: + return "pending" + } +} + func (g *GitHubProvider) ListBranches(ctx context.Context, owner, repo string) ([]string, error) { var allBranches []string page := 1 diff --git a/internal/staticsite/gitlab_provider.go b/internal/staticsite/gitlab_provider.go index d35fe59..dde46d3 100644 --- a/internal/staticsite/gitlab_provider.go +++ b/internal/staticsite/gitlab_provider.go @@ -95,6 +95,45 @@ func (g *GitLabProvider) TestConnection(ctx context.Context, owner, repo string) return err } +// SetCommitStatus reports a deploy status on a commit via GitLab's commit- +// status API. GitLab's state vocabulary differs (pending/running/success/ +// failed/canceled), so failure AND error both map to "failed". The status +// metadata (name/target_url/description) is passed as query parameters, +// which is how GitLab's POST .../statuses/{sha} endpoint accepts them. +func (g *GitLabProvider) SetCommitStatus(ctx context.Context, owner, repo, sha string, status CommitStatus, targetURL, description string) error { + q := url.Values{} + q.Set("state", gitlabState(status)) + q.Set("name", commitStatusContext) + if targetURL != "" { + q.Set("target_url", targetURL) + } + if description != "" { + q.Set("description", truncateDescription(description)) + } + apiURL := fmt.Sprintf("%s/projects/%s/statuses/%s?%s", + g.apiBase, projectPath(owner, repo), url.PathEscape(sha), q.Encode()) + // No JSON body — all fields ride as query params. Reuse postJSON for + // the SSRF-safe POST + 2xx handling; an empty body is valid here. + if err := postJSON(ctx, g.httpClient, apiURL, nil, g.setAuth); err != nil { + return fmt.Errorf("set commit status: %w", err) + } + return nil +} + +// gitlabState maps a provider-agnostic CommitStatus onto GitLab's API +// vocabulary. GitLab has no "failure"/"error" split — both map to +// "failed". +func gitlabState(status CommitStatus) string { + switch status { + case CommitStatusSuccess: + return "success" + case CommitStatusFailure, CommitStatusError: + return "failed" + default: + return "pending" + } +} + func (g *GitLabProvider) ListBranches(ctx context.Context, owner, repo string) ([]string, error) { var allBranches []string page := 1 diff --git a/internal/staticsite/provider.go b/internal/staticsite/provider.go index 10a0ad6..4b19ed9 100644 --- a/internal/staticsite/provider.go +++ b/internal/staticsite/provider.go @@ -1,6 +1,7 @@ package staticsite import ( + "bytes" "context" "fmt" "io" @@ -21,6 +22,40 @@ type RepoInfo struct { HTMLURL string `json:"html_url"` } +// CommitStatus is the deploy outcome reported back to the git provider as +// a commit status. The values are provider-agnostic; each implementation +// maps them onto its own API vocabulary (Gitea/GitHub use the same four +// words, GitLab collapses failure/error into "failed"). +type CommitStatus string + +const ( + CommitStatusPending CommitStatus = "pending" + CommitStatusSuccess CommitStatus = "success" + CommitStatusFailure CommitStatus = "failure" + CommitStatusError CommitStatus = "error" +) + +// commitStatusContext is the status "context"/"name" key reported to every +// provider so repeated deploys update the same status row rather than +// piling up new ones. +const commitStatusContext = "tinyforge" + +// maxCommitStatusDescription caps the human-readable description so a +// provider can't reject the request for an over-long field. +const maxCommitStatusDescription = 140 + +// truncateDescription clamps a status description to the provider-safe +// length, appending an ellipsis when it had to cut. +func truncateDescription(s string) string { + if len(s) <= maxCommitStatusDescription { + return s + } + // Reserve room for the ellipsis rune; cut on a byte boundary that + // stays under the cap. Descriptions are short ASCII strings in + // practice, so a simple byte cut is fine here. + return s[:maxCommitStatusDescription-1] + "…" +} + // GitProvider abstracts Git hosting API operations. // Implementations exist for Gitea/Forgejo/Gogs, GitHub, and GitLab. type GitProvider interface { @@ -45,6 +80,12 @@ type GitProvider interface { // DownloadFolder downloads all files from a folder path to a local directory. DownloadFolder(ctx context.Context, owner, repo, branch, folderPath, destDir string) error + + // SetCommitStatus reports a deploy status on a commit. Best-effort; + // callers ignore errors beyond logging. targetURL and description are + // optional (pass "" to omit); description is truncated to a provider- + // safe length by the implementation. + SetCommitStatus(ctx context.Context, owner, repo, sha string, status CommitStatus, targetURL, description string) error } // ProviderType identifies a Git hosting provider. @@ -135,6 +176,36 @@ func httpGet(ctx context.Context, client *http.Client, url string) (int, error) return resp.StatusCode, nil } +// postJSON is a shared helper for POSTing a JSON body to a provider API +// endpoint with the caller's auth applied. It accepts any 2xx as success +// (status APIs return 201 Created on Gitea/GitHub, 200/201 on GitLab) and +// returns a status-code-only error on non-2xx — it must NOT echo the +// response body: the deploy hook logs this error best-effort, and a +// hostile/misconfigured provider could reflect the request's auth token +// back in its body. The body bytes must already be marshalled by the caller. +func postJSON(ctx context.Context, client *http.Client, url string, body []byte, authHeader func(r *http.Request)) error { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create request: %w", err) + } + if authHeader != nil { + authHeader(req) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("execute request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("unexpected status %d", resp.StatusCode) + } + return nil +} + // downloadFileHTTP is a shared helper for downloading a file from a URL. func downloadFileHTTP(ctx context.Context, client *http.Client, url, localPath string, authHeader func(r *http.Request)) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) diff --git a/internal/workload/plugin/source/dockerfile/commit_status_test.go b/internal/workload/plugin/source/dockerfile/commit_status_test.go new file mode 100644 index 0000000..7eb915f --- /dev/null +++ b/internal/workload/plugin/source/dockerfile/commit_status_test.go @@ -0,0 +1,125 @@ +package dockerfile + +import ( + "context" + "errors" + "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") +} + +func TestStatusTargetURL(t *testing.T) { + if got := statusTargetURL(""); got != "" { + t.Errorf("empty domain -> %q, want \"\"", got) + } + if got := statusTargetURL("x.example.com"); got != "https://x.example.com" { + t.Errorf("got %q", got) + } +} diff --git a/internal/workload/plugin/source/dockerfile/deploy.go b/internal/workload/plugin/source/dockerfile/deploy.go index 958470f..7680b8c 100644 --- a/internal/workload/plugin/source/dockerfile/deploy.go +++ b/internal/workload/plugin/source/dockerfile/deploy.go @@ -43,7 +43,7 @@ const healthCheckDelay = 3 * time.Second // Each step writes its own status update so the dashboard's runtime- // state panel can show a useful intermediate state when the deploy // stalls on the slow step (almost always the build). -func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) error { +func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) (retErr error) { cfg, err := plugin.SourceConfigOf[Config](w) if err != nil { return fmt.Errorf("dockerfile source: decode config: %w", err) @@ -90,6 +90,25 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu domain := primaryDomain(deps, w) + // Commit-status reporter (best-effort; gated on cfg.ReportCommitStatus). + // The deferred terminal report fires Success/Failure based on the + // deploy's outcome, but ONLY once an actual build/deploy began + // (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)) + deployStarted := false + defer func() { + if !deployStarted { + return + } + if retErr != nil { + reporter.report(ctx, w, staticsite.CommitStatusFailure, "Tinyforge: build failed") + } else { + reporter.report(ctx, w, staticsite.CommitStatusSuccess, "Tinyforge: deployed") + } + }() + prevContainerID := "" prevProxyRouteID := "" if prevContainer != nil { @@ -129,8 +148,13 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu } } + // From here on a deploy is genuinely underway, so the deferred terminal + // status report should fire. Push a "pending" commit status (best- + // effort) and arm the deferred Success/Failure report. updateStatus(deps, w, "syncing", prev.LastCommitSHA, "") publishEvent(deps, w, "syncing") + deployStarted = true + reporter.report(ctx, w, staticsite.CommitStatusPending, "Tinyforge: deploying") // Clone the repo into a temp dir. We always download the entire // repo tree (folderPath = ""); a ContextPath subset is applied @@ -371,6 +395,55 @@ 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 { + if domain == "" { + return "" + } + return "https://" + domain +} + // updateStatus writes the runtime-state status/error/commit and (on // terminal states) fires the side effects the static plugin's helper // does: failures land in the event log, and a "deployed" or "failed" diff --git a/internal/workload/plugin/source/dockerfile/dockerfile.go b/internal/workload/plugin/source/dockerfile/dockerfile.go index 6a9377a..a91cad4 100644 --- a/internal/workload/plugin/source/dockerfile/dockerfile.go +++ b/internal/workload/plugin/source/dockerfile/dockerfile.go @@ -59,6 +59,11 @@ type Config struct { 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"` } type source struct{} diff --git a/internal/workload/plugin/source/static/commit_status_test.go b/internal/workload/plugin/source/static/commit_status_test.go new file mode 100644 index 0000000..1ffe6ec --- /dev/null +++ b/internal/workload/plugin/source/static/commit_status_test.go @@ -0,0 +1,140 @@ +package static + +import ( + "context" + "errors" + "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") +} + +func TestStatusTargetURL(t *testing.T) { + if got := statusTargetURL(""); got != "" { + t.Errorf("empty domain -> %q, want \"\"", got) + } + if got := statusTargetURL("x.example.com"); got != "https://x.example.com" { + t.Errorf("got %q", got) + } +} diff --git a/internal/workload/plugin/source/static/deploy.go b/internal/workload/plugin/source/static/deploy.go index 4b47077..895a16d 100644 --- a/internal/workload/plugin/source/static/deploy.go +++ b/internal/workload/plugin/source/static/deploy.go @@ -40,7 +40,7 @@ const healthCheckDelay = 3 * time.Second // log-line format ("Static site \"%s\": %s") and event payload shapes // are preserved so log scrapers and SSE clients keep working through // the cutover. -func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) error { +func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) (retErr error) { cfg, err := plugin.SourceConfigOf[Config](w) if err != nil { return fmt.Errorf("static source: decode config: %w", err) @@ -88,6 +88,26 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu // proxy registration sees the same FQDN it did before. domain := primaryDomain(deps, w) + // Commit-status reporter (best-effort; gated on cfg.ReportCommitStatus). + // Built here once latestSHA + domain are known. A deferred terminal + // report fires Success/Failure based on the deploy's outcome, but ONLY + // once an actual deploy began (deployStarted) — the unchanged-SHA + // 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)) + deployStarted := false + defer func() { + if !deployStarted { + return + } + if retErr != nil { + reporter.report(ctx, w, staticsite.CommitStatusFailure, "Tinyforge: deploy failed") + } else { + reporter.report(ctx, w, staticsite.CommitStatusSuccess, "Tinyforge: deployed") + } + }() + // Skip redeploy when nothing changed AND we have a live container + // (if applicable) live proxy route. Manual deploys always force. prevContainerID := "" @@ -116,9 +136,13 @@ func deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plu } } - // Mark syncing. + // Mark syncing. From here on a deploy is genuinely underway, so the + // deferred terminal status report should fire. Push a "pending" commit + // status (best-effort) and arm the deferred Success/Failure report. updateStatus(deps, w, "syncing", prev.LastCommitSHA, "") publishEvent(deps, w, "syncing") + deployStarted = true + reporter.report(ctx, w, staticsite.CommitStatusPending, "Tinyforge: deploying") // Build context — temp dir cleaned up on every exit path. buildDir, err := os.MkdirTemp("", "dw-site-"+idShort(w)+"-*") @@ -402,6 +426,59 @@ 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 { + if domain == "" { + return "" + } + return "https://" + domain +} + // updateStatus writes the runtime state's status/error/commit fields // and fires the side effects the legacy Manager.updateStatus did: // failures land in the event log, and terminal transitions trigger an diff --git a/internal/workload/plugin/source/static/static.go b/internal/workload/plugin/source/static/static.go index 63e153e..ef814da 100644 --- a/internal/workload/plugin/source/static/static.go +++ b/internal/workload/plugin/source/static/static.go @@ -37,6 +37,10 @@ type Config struct { RenderMarkdown bool `json:"render_markdown"` StorageEnabled bool `json:"storage_enabled"` StorageLimitMB int `json:"storage_limit_mb"` + // ReportCommitStatus, when true, pushes the deploy outcome back to the + // git provider as a commit status (pending/success/failure) on the + // deployed SHA. Best-effort — a reporting failure never fails a deploy. + ReportCommitStatus bool `json:"report_commit_status"` } type source struct{} diff --git a/web/src/lib/components/workload/DockerfileSourceForm.svelte b/web/src/lib/components/workload/DockerfileSourceForm.svelte index 3872a62..8d8f2c0 100644 --- a/web/src/lib/components/workload/DockerfileSourceForm.svelte +++ b/web/src/lib/components/workload/DockerfileSourceForm.svelte @@ -14,6 +14,7 @@