tenseleyflow/shithub / 66867ba

Browse files

api/actions_lifecycle_rest: enable/disable + run delete + artifacts + job logs

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
66867ba845ae231aed0bbaeb6113c90479e98e3b
Parents
0264232
Tree
58a5572

1 changed file

StatusFile+-
A internal/web/handlers/api/actions_lifecycle_rest.go 346 0
internal/web/handlers/api/actions_lifecycle_rest.goadded
@@ -0,0 +1,346 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package api
4
+
5
+import (
6
+	"context"
7
+	"io"
8
+	"net/http"
9
+	"strconv"
10
+	"time"
11
+
12
+	"github.com/go-chi/chi/v5"
13
+	"github.com/jackc/pgx/v5/pgtype"
14
+
15
+	"github.com/tenseleyFlow/shithub/internal/actions/dispatch"
16
+	actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc"
17
+	"github.com/tenseleyFlow/shithub/internal/auth/pat"
18
+	"github.com/tenseleyFlow/shithub/internal/auth/policy"
19
+	"github.com/tenseleyFlow/shithub/internal/web/middleware"
20
+)
21
+
22
+// mountActionsLifecycleREST registers the §13 part 2 lifecycle
23
+// surface: workflow enable/disable, run delete, artifact list +
24
+// download + delete, and job-log download.
25
+//
26
+// Cancel + rerun live on the existing mountActionsLifecycle (S41g)
27
+// routes and are not duplicated here.
28
+//
29
+// Scopes: `repo:read` on the GETs, `repo:write` on the mutations.
30
+func (h *Handlers) mountActionsLifecycleREST(r chi.Router) {
31
+	r.Group(func(r chi.Router) {
32
+		r.Use(middleware.RequireScope(pat.ScopeRepoRead))
33
+		r.Get("/api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts", h.actionsRunArtifactsList)
34
+		r.Get("/api/v1/repos/{owner}/{repo}/actions/artifacts/{artifact_id}", h.actionsArtifactGet)
35
+		r.Get("/api/v1/repos/{owner}/{repo}/actions/artifacts/{artifact_id}/zip", h.actionsArtifactDownload)
36
+		r.Get("/api/v1/repos/{owner}/{repo}/actions/jobs/{job_id}/logs", h.actionsJobLogs)
37
+	})
38
+	r.Group(func(r chi.Router) {
39
+		r.Use(middleware.RequireScope(pat.ScopeRepoWrite))
40
+		r.Put("/api/v1/repos/{owner}/{repo}/actions/workflows/{workflow}/enable", h.actionsWorkflowEnable)
41
+		r.Put("/api/v1/repos/{owner}/{repo}/actions/workflows/{workflow}/disable", h.actionsWorkflowDisable)
42
+		r.Delete("/api/v1/repos/{owner}/{repo}/actions/runs/{run_id}", h.actionsRunDelete)
43
+		r.Delete("/api/v1/repos/{owner}/{repo}/actions/artifacts/{artifact_id}", h.actionsArtifactDelete)
44
+	})
45
+}
46
+
47
+// ─── workflow enable / disable ──────────────────────────────────────
48
+
49
+func (h *Handlers) actionsWorkflowEnable(w http.ResponseWriter, r *http.Request) {
50
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoWrite)
51
+	if !ok {
52
+		return
53
+	}
54
+	file, err := dispatch.NormalizeFilePath(chi.URLParam(r, "workflow"))
55
+	if err != nil {
56
+		writeAPIError(w, http.StatusBadRequest, "invalid workflow file path")
57
+		return
58
+	}
59
+	if _, err := actionsdb.New().EnableWorkflow(r.Context(), h.d.Pool, actionsdb.EnableWorkflowParams{
60
+		RepoID: repo.ID, WorkflowFile: file,
61
+	}); err != nil {
62
+		h.d.Logger.ErrorContext(r.Context(), "api: workflow enable", "error", err)
63
+		writeAPIError(w, http.StatusInternalServerError, "enable failed")
64
+		return
65
+	}
66
+	w.WriteHeader(http.StatusNoContent)
67
+}
68
+
69
+func (h *Handlers) actionsWorkflowDisable(w http.ResponseWriter, r *http.Request) {
70
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoWrite)
71
+	if !ok {
72
+		return
73
+	}
74
+	file, err := dispatch.NormalizeFilePath(chi.URLParam(r, "workflow"))
75
+	if err != nil {
76
+		writeAPIError(w, http.StatusBadRequest, "invalid workflow file path")
77
+		return
78
+	}
79
+	auth := middleware.PATAuthFromContext(r.Context())
80
+	if err := actionsdb.New().DisableWorkflow(r.Context(), h.d.Pool, actionsdb.DisableWorkflowParams{
81
+		RepoID:           repo.ID,
82
+		WorkflowFile:     file,
83
+		DisabledByUserID: pgtype.Int8{Int64: auth.UserID, Valid: auth.UserID != 0},
84
+	}); err != nil {
85
+		h.d.Logger.ErrorContext(r.Context(), "api: workflow disable", "error", err)
86
+		writeAPIError(w, http.StatusInternalServerError, "disable failed")
87
+		return
88
+	}
89
+	w.WriteHeader(http.StatusNoContent)
90
+}
91
+
92
+// ─── run delete ─────────────────────────────────────────────────────
93
+
94
+func (h *Handlers) actionsRunDelete(w http.ResponseWriter, r *http.Request) {
95
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoWrite)
96
+	if !ok {
97
+		return
98
+	}
99
+	runID, err := strconv.ParseInt(chi.URLParam(r, "run_id"), 10, 64)
100
+	if err != nil {
101
+		writeAPIError(w, http.StatusNotFound, "run not found")
102
+		return
103
+	}
104
+	q := actionsdb.New()
105
+	run, err := q.GetWorkflowRunByID(r.Context(), h.d.Pool, runID)
106
+	if err != nil || run.RepoID != repo.ID {
107
+		writeAPIError(w, http.StatusNotFound, "run not found")
108
+		return
109
+	}
110
+	objectKeys, err := q.ListArtifactObjectKeysForRun(r.Context(), h.d.Pool, runID)
111
+	if err != nil {
112
+		h.d.Logger.ErrorContext(r.Context(), "api: list run artifact keys", "error", err)
113
+		writeAPIError(w, http.StatusInternalServerError, "delete failed")
114
+		return
115
+	}
116
+	if _, err := q.DeleteWorkflowRunByID(r.Context(), h.d.Pool, runID); err != nil {
117
+		h.d.Logger.ErrorContext(r.Context(), "api: delete run", "error", err)
118
+		writeAPIError(w, http.StatusInternalServerError, "delete failed")
119
+		return
120
+	}
121
+	if h.d.ObjectStore != nil && len(objectKeys) > 0 {
122
+		go h.purgeArtifactObjects(objectKeys)
123
+	}
124
+	w.WriteHeader(http.StatusNoContent)
125
+}
126
+
127
+// purgeArtifactObjects is a best-effort S3 cleanup detached from the
128
+// request lifecycle. Failures are logged but never surfaced — the
129
+// authoritative DB row is gone, and the cleanup sweeper retries
130
+// orphan-object deletion on its own schedule.
131
+func (h *Handlers) purgeArtifactObjects(keys []string) {
132
+	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
133
+	defer cancel()
134
+	for _, k := range keys {
135
+		if err := h.d.ObjectStore.Delete(ctx, k); err != nil {
136
+			h.d.Logger.Warn("api: purge artifact object", "key", k, "error", err)
137
+		}
138
+	}
139
+}
140
+
141
+// ─── run artifacts ──────────────────────────────────────────────────
142
+
143
+type artifactResponse struct {
144
+	ID         int64  `json:"id"`
145
+	Name       string `json:"name"`
146
+	SizeBytes  int64  `json:"size_bytes"`
147
+	ArchiveURL string `json:"archive_url"`
148
+	ExpiresAt  string `json:"expires_at,omitempty"`
149
+	CreatedAt  string `json:"created_at"`
150
+}
151
+
152
+func (h *Handlers) actionsRunArtifactsList(w http.ResponseWriter, r *http.Request) {
153
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoRead)
154
+	if !ok {
155
+		return
156
+	}
157
+	runID, err := strconv.ParseInt(chi.URLParam(r, "run_id"), 10, 64)
158
+	if err != nil {
159
+		writeAPIError(w, http.StatusNotFound, "run not found")
160
+		return
161
+	}
162
+	q := actionsdb.New()
163
+	run, err := q.GetWorkflowRunByID(r.Context(), h.d.Pool, runID)
164
+	if err != nil || run.RepoID != repo.ID {
165
+		writeAPIError(w, http.StatusNotFound, "run not found")
166
+		return
167
+	}
168
+	rows, err := q.ListArtifactsForRun(r.Context(), h.d.Pool, runID)
169
+	if err != nil {
170
+		h.d.Logger.ErrorContext(r.Context(), "api: list artifacts", "error", err)
171
+		writeAPIError(w, http.StatusInternalServerError, "list failed")
172
+		return
173
+	}
174
+	out := make([]artifactResponse, 0, len(rows))
175
+	for _, a := range rows {
176
+		out = append(out, artifactResponse{
177
+			ID:         a.ID,
178
+			Name:       a.Name,
179
+			SizeBytes:  a.ByteCount,
180
+			ArchiveURL: artifactArchiveURL(r, chi.URLParam(r, "owner"), repo.Name,a.ID),
181
+			ExpiresAt:  pgTimestampString(a.ExpiresAt),
182
+			CreatedAt:  a.CreatedAt.Time.UTC().Format(time.RFC3339),
183
+		})
184
+	}
185
+	writeJSON(w, http.StatusOK, out)
186
+}
187
+
188
+func (h *Handlers) actionsArtifactGet(w http.ResponseWriter, r *http.Request) {
189
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoRead)
190
+	if !ok {
191
+		return
192
+	}
193
+	artifact, ok := h.lookupArtifact(w, r, repo.ID)
194
+	if !ok {
195
+		return
196
+	}
197
+	writeJSON(w, http.StatusOK, artifactResponse{
198
+		ID:         artifact.ID,
199
+		Name:       artifact.Name,
200
+		SizeBytes:  artifact.ByteCount,
201
+		ArchiveURL: artifactArchiveURL(r, chi.URLParam(r, "owner"), repo.Name,artifact.ID),
202
+		ExpiresAt:  pgTimestampString(artifact.ExpiresAt),
203
+		CreatedAt:  artifact.CreatedAt.Time.UTC().Format(time.RFC3339),
204
+	})
205
+}
206
+
207
+func (h *Handlers) actionsArtifactDownload(w http.ResponseWriter, r *http.Request) {
208
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoRead)
209
+	if !ok {
210
+		return
211
+	}
212
+	artifact, ok := h.lookupArtifact(w, r, repo.ID)
213
+	if !ok {
214
+		return
215
+	}
216
+	if h.d.ObjectStore == nil {
217
+		writeAPIError(w, http.StatusServiceUnavailable, "object store not configured")
218
+		return
219
+	}
220
+	rc, _, err := h.d.ObjectStore.Get(r.Context(), artifact.ObjectKey)
221
+	if err != nil {
222
+		h.d.Logger.ErrorContext(r.Context(), "api: artifact get", "error", err, "key", artifact.ObjectKey)
223
+		writeAPIError(w, http.StatusNotFound, "artifact blob not found")
224
+		return
225
+	}
226
+	defer rc.Close()
227
+	w.Header().Set("Content-Type", "application/zip")
228
+	w.Header().Set("Content-Disposition", `attachment; filename="`+artifact.Name+`.zip"`)
229
+	w.Header().Set("Cache-Control", "no-store")
230
+	if _, err := io.Copy(w, rc); err != nil {
231
+		h.d.Logger.WarnContext(r.Context(), "api: artifact stream", "error", err)
232
+	}
233
+}
234
+
235
+func (h *Handlers) actionsArtifactDelete(w http.ResponseWriter, r *http.Request) {
236
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoWrite)
237
+	if !ok {
238
+		return
239
+	}
240
+	artifact, ok := h.lookupArtifact(w, r, repo.ID)
241
+	if !ok {
242
+		return
243
+	}
244
+	if _, err := actionsdb.New().DeleteWorkflowArtifactByID(r.Context(), h.d.Pool, artifact.ID); err != nil {
245
+		h.d.Logger.ErrorContext(r.Context(), "api: delete artifact", "error", err)
246
+		writeAPIError(w, http.StatusInternalServerError, "delete failed")
247
+		return
248
+	}
249
+	if h.d.ObjectStore != nil {
250
+		go h.purgeArtifactObjects([]string{artifact.ObjectKey})
251
+	}
252
+	w.WriteHeader(http.StatusNoContent)
253
+}
254
+
255
+// lookupArtifact resolves the {artifact_id} URL param against the
256
+// repo so a caller can't drive `/repos/foo/bar/actions/artifacts/<id>`
257
+// against an artifact in an unrelated repo.
258
+func (h *Handlers) lookupArtifact(w http.ResponseWriter, r *http.Request, repoID int64) (actionsdb.WorkflowArtifact, bool) {
259
+	id, err := strconv.ParseInt(chi.URLParam(r, "artifact_id"), 10, 64)
260
+	if err != nil {
261
+		writeAPIError(w, http.StatusNotFound, "artifact not found")
262
+		return actionsdb.WorkflowArtifact{}, false
263
+	}
264
+	q := actionsdb.New()
265
+	artifact, err := q.GetArtifactByID(r.Context(), h.d.Pool, id)
266
+	if err != nil {
267
+		writeAPIError(w, http.StatusNotFound, "artifact not found")
268
+		return actionsdb.WorkflowArtifact{}, false
269
+	}
270
+	run, err := q.GetWorkflowRunByID(r.Context(), h.d.Pool, artifact.RunID)
271
+	if err != nil || run.RepoID != repoID {
272
+		writeAPIError(w, http.StatusNotFound, "artifact not found")
273
+		return actionsdb.WorkflowArtifact{}, false
274
+	}
275
+	return artifact, true
276
+}
277
+
278
+// ─── job logs ───────────────────────────────────────────────────────
279
+
280
+func (h *Handlers) actionsJobLogs(w http.ResponseWriter, r *http.Request) {
281
+	repo, ok := h.resolveAPIRepo(w, r, policy.ActionRepoRead)
282
+	if !ok {
283
+		return
284
+	}
285
+	jobID, err := strconv.ParseInt(chi.URLParam(r, "job_id"), 10, 64)
286
+	if err != nil {
287
+		writeAPIError(w, http.StatusNotFound, "job not found")
288
+		return
289
+	}
290
+	q := actionsdb.New()
291
+	job, err := q.GetWorkflowJobByID(r.Context(), h.d.Pool, jobID)
292
+	if err != nil {
293
+		writeAPIError(w, http.StatusNotFound, "job not found")
294
+		return
295
+	}
296
+	run, err := q.GetWorkflowRunByID(r.Context(), h.d.Pool, job.RunID)
297
+	if err != nil || run.RepoID != repo.ID {
298
+		writeAPIError(w, http.StatusNotFound, "job not found")
299
+		return
300
+	}
301
+	steps, err := q.ListStepsForJob(r.Context(), h.d.Pool, jobID)
302
+	if err != nil {
303
+		h.d.Logger.ErrorContext(r.Context(), "api: list steps", "error", err)
304
+		writeAPIError(w, http.StatusInternalServerError, "logs unavailable")
305
+		return
306
+	}
307
+	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
308
+	w.Header().Set("Cache-Control", "no-store")
309
+	for _, step := range steps {
310
+		// One small header per step keeps the concatenated transcript
311
+		// scannable; gh's job-logs API does the same.
312
+		if _, werr := io.WriteString(w, "##[group] step "+strconv.FormatInt(int64(step.StepIndex), 10)+": "+step.StepName+"\n"); werr != nil {
313
+			return
314
+		}
315
+		chunks, err := q.ListAllStepLogChunksForStep(r.Context(), h.d.Pool, step.ID)
316
+		if err != nil {
317
+			h.d.Logger.WarnContext(r.Context(), "api: list log chunks", "error", err, "step_id", step.ID)
318
+			continue
319
+		}
320
+		for _, c := range chunks {
321
+			if _, werr := w.Write(c.Chunk); werr != nil {
322
+				return
323
+			}
324
+		}
325
+		if _, werr := io.WriteString(w, "##[endgroup]\n"); werr != nil {
326
+			return
327
+		}
328
+	}
329
+}
330
+
331
+// ─── helpers ────────────────────────────────────────────────────────
332
+
333
+func artifactArchiveURL(r *http.Request, owner, repo string, artifactID int64) string {
334
+	scheme := "http"
335
+	if r.TLS != nil {
336
+		scheme = "https"
337
+	}
338
+	return scheme + "://" + r.Host + "/api/v1/repos/" + owner + "/" + repo + "/actions/artifacts/" + strconv.FormatInt(artifactID, 10) + "/zip"
339
+}
340
+
341
+func pgTimestampString(t pgtype.Timestamptz) string {
342
+	if !t.Valid {
343
+		return ""
344
+	}
345
+	return t.Time.UTC().Format(time.RFC3339)
346
+}