| 1 | import { Hono } from "hono"; |
| 2 | import type { Env } from "../env.js"; |
| 3 | import type { AppContext, AppVars } from "../auth/principal.js"; |
| 4 | import { HttpError } from "../lib/util.js"; |
| 5 | import { parsePayload, type Deployment, type DeploymentReview, type DeploymentStatus, type Environment, type Reviewer } from "../repo/deployments.js"; |
| 6 | import type { PendingDeployment } from "../repo/actions.js"; |
| 7 | import type { Caller } from "../repo/types.js"; |
| 8 | import { baseOf, ghUser, isoSeconds, nodeId, pageOf, paginate, repoApi, repoHtml, setLinks } from "./gh.js"; |
| 9 | import { intParam, jsonBody, loadTarget, repoParams } from "./ghctx.js"; |
| 10 | import { refuseAgent, WHILE_IMPORTING } from "./common.js"; |
| 11 | import { resolveReviewers, reviewDeployments, viewerFor } from "./environment-reviews.js"; |
| 12 | |
| 13 | /** Deployments, deployment statuses and environments, with environment secrets and variables (GitHub REST). */ |
| 14 | export const deployments = new Hono<{ Bindings: Env; Variables: AppVars }>(); |
| 15 | |
| 16 | const R = "/repos/:owner/:repo"; |
| 17 | const load = (c: AppContext, min: "read" | "write" | "admin" = "read", opts: { whileImporting?: boolean } = {}) => loadTarget(c, min, opts); |
| 18 | const api = (c: AppContext) => repoApi(baseOf(c), repoParams(c).owner, repoParams(c).repo); |
| 19 | const envName = (c: AppContext) => (c.req.param("env") ?? ""); |
| 20 | |
| 21 | function deploymentJson(c: AppContext, d: Deployment): Record<string, unknown> { |
| 22 | const url = `${api(c)}/deployments/${d.id}`; |
| 23 | return { |
| 24 | url, |
| 25 | id: d.id, |
| 26 | node_id: nodeId("Deployment", `${repoParams(c).owner}/${repoParams(c).repo}/${d.id}`), |
| 27 | sha: d.sha, |
| 28 | ref: d.ref, |
| 29 | task: d.task, |
| 30 | payload: parsePayload(d.payload), |
| 31 | original_environment: d.originalEnvironment, |
| 32 | environment: d.environment, |
| 33 | description: d.description, |
| 34 | creator: ghUser(baseOf(c), d.creator), |
| 35 | created_at: isoSeconds(d.createdAt), |
| 36 | updated_at: isoSeconds(d.updatedAt), |
| 37 | statuses_url: `${url}/statuses`, |
| 38 | repository_url: api(c), |
| 39 | transient_environment: d.transient, |
| 40 | production_environment: d.production, |
| 41 | performed_via_github_app: null, |
| 42 | }; |
| 43 | } |
| 44 | |
| 45 | function statusJson(c: AppContext, s: DeploymentStatus) { |
| 46 | const dep = `${api(c)}/deployments/${s.deploymentId}`; |
| 47 | return { |
| 48 | url: `${dep}/statuses/${s.id}`, |
| 49 | id: s.id, |
| 50 | node_id: nodeId("DeploymentStatus", `${repoParams(c).owner}/${repoParams(c).repo}/${s.id}`), |
| 51 | state: s.state, |
| 52 | creator: ghUser(baseOf(c), s.creator), |
| 53 | description: s.description, |
| 54 | environment: s.environment, |
| 55 | target_url: s.logUrl, |
| 56 | log_url: s.logUrl, |
| 57 | environment_url: s.environmentUrl, |
| 58 | created_at: isoSeconds(s.createdAt), |
| 59 | updated_at: isoSeconds(s.createdAt), |
| 60 | deployment_url: dep, |
| 61 | repository_url: api(c), |
| 62 | performed_via_github_app: null, |
| 63 | }; |
| 64 | } |
| 65 | |
| 66 | const reviewerJson = (c: AppContext, r: Reviewer) => ({ type: "User", reviewer: ghUser(baseOf(c), { id: r.id, login: r.login, kind: "user" }) }); |
| 67 | |
| 68 | /** An environment as the REST API shows it inside other objects (pending deployments, approvals). */ |
| 69 | function environmentRef(c: AppContext, e: { id: number; name: string }) { |
| 70 | return { |
| 71 | id: e.id, |
| 72 | node_id: nodeId("Environment", `${repoParams(c).owner}/${repoParams(c).repo}/${e.id}`), |
| 73 | name: e.name, |
| 74 | url: `${api(c)}/environments/${encodeURIComponent(e.name)}`, |
| 75 | html_url: `${repoHtml(baseOf(c), repoParams(c).owner, repoParams(c).repo)}/settings/environments/${encodeURIComponent(e.name)}`, |
| 76 | }; |
| 77 | } |
| 78 | |
| 79 | function environmentJson(c: AppContext, e: Environment) { |
| 80 | const p = e.protection; |
| 81 | const rules: Array<Record<string, unknown>> = []; |
| 82 | if (p.waitTimer > 0) rules.push({ id: e.id * 10 + 1, node_id: nodeId("WaitTimer", e.id), type: "wait_timer", wait_timer: p.waitTimer }); |
| 83 | if (p.reviewers.length) rules.push({ id: e.id * 10 + 2, node_id: nodeId("RequiredReviewers", e.id), type: "required_reviewers", prevent_self_review: p.preventSelfReview, reviewers: p.reviewers.map((r) => reviewerJson(c, r)) }); |
| 84 | if (e.policy) rules.push({ id: e.id * 10 + 3, node_id: nodeId("BranchPolicy", e.id), type: "branch_policy" }); |
| 85 | return { |
| 86 | ...environmentRef(c, e), |
| 87 | created_at: isoSeconds(e.createdAt), |
| 88 | updated_at: isoSeconds(e.updatedAt), |
| 89 | // Nobody skips an environment's rules, admins included. |
| 90 | can_admins_bypass: false, |
| 91 | protection_rules: rules, |
| 92 | deployment_branch_policy: e.policy ? { protected_branches: e.policy.protected_branches, custom_branch_policies: e.policy.custom_branch_policies } : null, |
| 93 | }; |
| 94 | } |
| 95 | |
| 96 | function pendingJson(c: AppContext, p: PendingDeployment) { |
| 97 | return { |
| 98 | environment: environmentRef(c, p.environment), |
| 99 | wait_timer: p.waitTimer, |
| 100 | wait_timer_started_at: isoSeconds(p.waitTimerStartedAt), |
| 101 | current_user_can_approve: p.currentUserCanApprove, |
| 102 | reviewers: p.reviewers.map((r) => reviewerJson(c, r)), |
| 103 | }; |
| 104 | } |
| 105 | |
| 106 | function approvalJson(c: AppContext, r: DeploymentReview, envs: Environment[]) { |
| 107 | const e = envs.find((x) => x.name.toLowerCase() === r.environment.toLowerCase()); |
| 108 | return { |
| 109 | url: `${api(c)}/actions/runs/${r.runId}/approvals`, |
| 110 | environments: [environmentRef(c, e ?? { id: 0, name: r.environment })], |
| 111 | state: r.state, |
| 112 | user: ghUser(baseOf(c), { id: r.user.id, login: r.user.login, kind: "user" }), |
| 113 | comment: r.comment, |
| 114 | }; |
| 115 | } |
| 116 | |
| 117 | /** Environment secrets and settings are sensitive: changes go to the audit log. */ |
| 118 | function audit(c: AppContext, caller: Caller, action: string, detail: Record<string, unknown>) { |
| 119 | c.executionCtx.waitUntil(c.get("dir").audit({ actorId: caller.id, actorLogin: caller.login, onBehalfOf: caller.onBehalfOf, action, target: `${repoParams(c).owner}/${repoParams(c).repo}`, detail })); |
| 120 | } |
| 121 | |
| 122 | // ---------------------------------------------------------------- deployments |
| 123 | |
| 124 | deployments.get(`${R}/deployments`, async (c) => { |
| 125 | const { stub } = await load(c); |
| 126 | const p = pageOf(c); |
| 127 | const q = (k: string) => c.req.query(k) || undefined; |
| 128 | const res = await stub.deployments({ sha: q("sha"), ref: q("ref"), task: q("task"), environment: q("environment"), limit: p.perPage, offset: p.offset }); |
| 129 | setLinks(c, p, res.total); |
| 130 | return c.json(res.items.map((d) => deploymentJson(c, d))); |
| 131 | }); |
| 132 | deployments.post(`${R}/deployments`, async (c) => { |
| 133 | const { stub, caller } = await load(c, "write"); |
| 134 | const b = await jsonBody<{ ref?: string; task?: string; auto_merge?: boolean; required_contexts?: unknown; payload?: unknown; environment?: string; description?: string | null; transient_environment?: boolean; production_environment?: boolean }>(c); |
| 135 | if (b.required_contexts !== undefined && b.required_contexts !== null && (!Array.isArray(b.required_contexts) || b.required_contexts.length > 100 || b.required_contexts.some((x) => typeof x !== "string"))) { |
| 136 | throw new HttpError(422, "required_contexts must be an array of strings", { errors: [{ resource: "Deployment", code: "invalid", field: "required_contexts" }] }); |
| 137 | } |
| 138 | const required = Array.isArray(b.required_contexts) ? (b.required_contexts as string[]) : undefined; |
| 139 | const d = await stub.createDeployment({ ref: b.ref, task: b.task, payload: b.payload, environment: b.environment, description: b.description, transient_environment: b.transient_environment, production_environment: b.production_environment, required_contexts: required }, caller); |
| 140 | return c.json(deploymentJson(c, d), 201); |
| 141 | }); |
| 142 | deployments.get(`${R}/deployments/:id`, async (c) => { |
| 143 | const { stub } = await load(c); |
| 144 | return c.json(deploymentJson(c, await stub.deployment(intParam(c, "id")))); |
| 145 | }); |
| 146 | deployments.delete(`${R}/deployments/:id`, async (c) => { |
| 147 | const { stub, caller } = await load(c, "write"); |
| 148 | await stub.deleteDeployment(intParam(c, "id"), caller); |
| 149 | return c.body(null, 204); |
| 150 | }); |
| 151 | deployments.get(`${R}/deployments/:id/statuses`, async (c) => { |
| 152 | const { stub } = await load(c); |
| 153 | return c.json(paginate(c, (await stub.deploymentStatuses(intParam(c, "id"))).map((s) => statusJson(c, s)))); |
| 154 | }); |
| 155 | deployments.post(`${R}/deployments/:id/statuses`, async (c) => { |
| 156 | const { stub, caller } = await load(c, "write"); |
| 157 | const b = await jsonBody<{ state?: string; target_url?: string; log_url?: string; description?: string; environment?: string; environment_url?: string; auto_inactive?: boolean }>(c); |
| 158 | return c.json(statusJson(c, await stub.createDeploymentStatus(intParam(c, "id"), b, caller)), 201); |
| 159 | }); |
| 160 | deployments.get(`${R}/deployments/:id/statuses/:status`, async (c) => { |
| 161 | const { stub } = await load(c); |
| 162 | return c.json(statusJson(c, await stub.deploymentStatus(intParam(c, "id"), intParam(c, "status")))); |
| 163 | }); |
| 164 | |
| 165 | // ---------------------------------------------------------------- environments |
| 166 | |
| 167 | deployments.get(`${R}/environments`, async (c) => { |
| 168 | const { stub } = await load(c); |
| 169 | const all = await stub.environments(); |
| 170 | return c.json({ total_count: all.length, environments: paginate(c, all).map((e) => environmentJson(c, e)) }); |
| 171 | }); |
| 172 | deployments.get(`${R}/environments/:env`, async (c) => { |
| 173 | const { stub } = await load(c); |
| 174 | return c.json(environmentJson(c, await stub.environment(envName(c)))); |
| 175 | }); |
| 176 | deployments.put(`${R}/environments/:env`, async (c) => { |
| 177 | const { stub, caller, repo } = await load(c, "admin", WHILE_IMPORTING); |
| 178 | const b = await jsonBody<{ wait_timer?: unknown; reviewers?: unknown; deployment_branch_policy?: { protected_branches?: boolean; custom_branch_policies?: boolean } | null; prevent_self_review?: unknown }>(c, true); |
| 179 | if (caller.kind === "agent") throw new HttpError(403, "only repository admins (people, not agents) can manage environments"); |
| 180 | const reviewers = await resolveReviewers(c, repo, b.reviewers); |
| 181 | const e = await stub.upsertEnvironment(envName(c), { ...b, reviewers }, caller); |
| 182 | audit(c, caller, "environment.update", { |
| 183 | environment: e.name, |
| 184 | deployment_branch_policy: b.deployment_branch_policy ?? null, |
| 185 | ...(reviewers !== undefined ? { reviewers: (reviewers ?? []).map((r) => r.login) } : {}), |
| 186 | ...(b.wait_timer !== undefined ? { wait_timer: b.wait_timer } : {}), |
| 187 | ...(b.prevent_self_review !== undefined ? { prevent_self_review: b.prevent_self_review } : {}), |
| 188 | }); |
| 189 | return c.json(environmentJson(c, e)); |
| 190 | }); |
| 191 | deployments.delete(`${R}/environments/:env`, async (c) => { |
| 192 | const { stub, caller } = await load(c, "admin", WHILE_IMPORTING); |
| 193 | await stub.deleteEnvironment(envName(c), caller); |
| 194 | audit(c, caller, "environment.delete", { environment: envName(c) }); |
| 195 | return c.body(null, 204); |
| 196 | }); |
| 197 | deployments.get(`${R}/environments/:env/deployment-branch-policies`, async (c) => { |
| 198 | const { stub } = await load(c); |
| 199 | const e = await stub.environment(envName(c)); |
| 200 | const list = e.policy?.patterns ?? []; |
| 201 | return c.json({ total_count: list.length, branch_policies: paginate(c, list).map((p) => ({ ...p, node_id: nodeId("BranchPolicy", `${e.id}/${p.id}`) })) }); |
| 202 | }); |
| 203 | deployments.post(`${R}/environments/:env/deployment-branch-policies`, async (c) => { |
| 204 | const { stub, caller } = await load(c, "admin", WHILE_IMPORTING); |
| 205 | const b = await jsonBody<{ name?: string; type?: string }>(c); |
| 206 | const p = await stub.addEnvironmentBranchPattern(envName(c), b, caller); |
| 207 | audit(c, caller, "environment.branch_policy.create", { environment: envName(c), pattern: p.name, type: p.type }); |
| 208 | return c.json({ ...p, node_id: nodeId("BranchPolicy", p.id) }); |
| 209 | }); |
| 210 | deployments.delete(`${R}/environments/:env/deployment-branch-policies/:id`, async (c) => { |
| 211 | const { stub, caller } = await load(c, "admin", WHILE_IMPORTING); |
| 212 | await stub.removeEnvironmentBranchPattern(envName(c), intParam(c, "id"), caller); |
| 213 | audit(c, caller, "environment.branch_policy.delete", { environment: envName(c), id: intParam(c, "id") }); |
| 214 | return c.body(null, 204); |
| 215 | }); |
| 216 | |
| 217 | // ---------------------------------------------------------------- environment secrets (names only, never values) and variables |
| 218 | |
| 219 | deployments.get(`${R}/environments/:env/secrets`, async (c) => { |
| 220 | const { stub } = await load(c, "admin"); |
| 221 | const list = await stub.environmentSecretNames(envName(c)); |
| 222 | return c.json({ total_count: list.length, secrets: paginate(c, list).map((s) => ({ name: s.name, created_at: isoSeconds(s.updatedAt), updated_at: isoSeconds(s.updatedAt) })) }); |
| 223 | }); |
| 224 | deployments.get(`${R}/environments/:env/secrets/:name`, async (c) => { |
| 225 | const { stub } = await load(c, "admin"); |
| 226 | const s = (await stub.environmentSecretNames(envName(c))).find((x) => x.name === c.req.param("name").toUpperCase()); |
| 227 | if (!s) throw new HttpError(404, "Not Found"); |
| 228 | return c.json({ name: s.name, created_at: isoSeconds(s.updatedAt), updated_at: isoSeconds(s.updatedAt) }); |
| 229 | }); |
| 230 | deployments.put(`${R}/environments/:env/secrets/:name`, async (c) => { |
| 231 | const { stub, caller } = await load(c, "admin", WHILE_IMPORTING); |
| 232 | if (caller.kind === "agent") throw new HttpError(403, "agents cannot manage secrets"); |
| 233 | const b = await jsonBody<{ value?: string; encrypted_value?: string }>(c); |
| 234 | if (typeof b.value !== "string" && typeof b.encrypted_value === "string") throw new HttpError(422, "encrypted_value (sealed box) is not supported yet: send the secret as `value` over HTTPS; it is encrypted at rest"); |
| 235 | if (typeof b.value !== "string") throw new HttpError(422, "value is required (send it over HTTPS; it is encrypted at rest)"); |
| 236 | const existed = (await stub.environmentSecretNames(envName(c))).some((x) => x.name === c.req.param("name").toUpperCase()); |
| 237 | await stub.setEnvironmentSecret(envName(c), c.req.param("name"), b.value, caller); |
| 238 | audit(c, caller, "environment.secret.set", { environment: envName(c), name: c.req.param("name").toUpperCase() }); |
| 239 | return c.body(null, existed ? 204 : 201); |
| 240 | }); |
| 241 | deployments.delete(`${R}/environments/:env/secrets/:name`, async (c) => { |
| 242 | const { stub, caller } = await load(c, "admin", WHILE_IMPORTING); |
| 243 | if (caller.kind === "agent") throw new HttpError(403, "agents cannot manage secrets"); |
| 244 | await stub.deleteEnvironmentSecret(envName(c), c.req.param("name"), caller); |
| 245 | audit(c, caller, "environment.secret.delete", { environment: envName(c), name: c.req.param("name").toUpperCase() }); |
| 246 | return c.body(null, 204); |
| 247 | }); |
| 248 | |
| 249 | const variableJson = (v: { name: string; value: string; createdAt: string; updatedAt: string }) => ({ name: v.name, value: v.value, created_at: isoSeconds(v.createdAt), updated_at: isoSeconds(v.updatedAt) }); |
| 250 | |
| 251 | deployments.get(`${R}/environments/:env/variables`, async (c) => { |
| 252 | const { stub } = await load(c, "write"); |
| 253 | const list = await stub.environmentVariables(envName(c)); |
| 254 | return c.json({ total_count: list.length, variables: paginate(c, list, 10).map(variableJson) }); |
| 255 | }); |
| 256 | deployments.get(`${R}/environments/:env/variables/:name`, async (c) => { |
| 257 | const { stub } = await load(c, "write"); |
| 258 | const v = (await stub.environmentVariables(envName(c))).find((x) => x.name === c.req.param("name").toUpperCase()); |
| 259 | if (!v) throw new HttpError(404, "Not Found"); |
| 260 | return c.json(variableJson(v)); |
| 261 | }); |
| 262 | deployments.post(`${R}/environments/:env/variables`, async (c) => { |
| 263 | const { stub, caller } = await load(c, "admin", WHILE_IMPORTING); |
| 264 | refuseAgent(caller, "variables"); |
| 265 | const b = await jsonBody<{ name?: string; value?: string }>(c); |
| 266 | if (typeof b.name !== "string" || typeof b.value !== "string") throw new HttpError(422, "name and value are required"); |
| 267 | if ((await stub.environmentVariables(envName(c))).some((x) => x.name === b.name!.toUpperCase())) throw new HttpError(409, "Already exists - Variable already exists"); |
| 268 | await stub.setEnvironmentVariable(envName(c), b.name, b.value, caller); |
| 269 | audit(c, caller, "environment.variable.set", { environment: envName(c), name: b.name.toUpperCase() }); |
| 270 | return c.json({}, 201); |
| 271 | }); |
| 272 | deployments.patch(`${R}/environments/:env/variables/:name`, async (c) => { |
| 273 | const { stub, caller } = await load(c, "admin", WHILE_IMPORTING); |
| 274 | refuseAgent(caller, "variables"); |
| 275 | const b = await jsonBody<{ name?: string; value?: string }>(c); |
| 276 | const name = c.req.param("name"); |
| 277 | const all = await stub.environmentVariables(envName(c)); |
| 278 | const current = all.find((x) => x.name === name.toUpperCase()); |
| 279 | if (!current) throw new HttpError(404, "Not Found"); |
| 280 | const next = typeof b.name === "string" && b.name ? b.name : name; |
| 281 | if (next.toUpperCase() !== name.toUpperCase() && all.some((x) => x.name === next.toUpperCase())) throw new HttpError(409, "Already exists - Variable already exists"); |
| 282 | await stub.setEnvironmentVariable(envName(c), next, typeof b.value === "string" ? b.value : current.value, caller); |
| 283 | if (next.toUpperCase() !== name.toUpperCase()) await stub.setEnvironmentVariable(envName(c), name, null, caller); |
| 284 | audit(c, caller, "environment.variable.set", { environment: envName(c), name: next.toUpperCase(), ...(next.toUpperCase() !== name.toUpperCase() ? { renamed_from: name.toUpperCase() } : {}) }); |
| 285 | return c.body(null, 204); |
| 286 | }); |
| 287 | deployments.delete(`${R}/environments/:env/variables/:name`, async (c) => { |
| 288 | const { stub, caller } = await load(c, "admin", WHILE_IMPORTING); |
| 289 | refuseAgent(caller, "variables"); |
| 290 | await stub.setEnvironmentVariable(envName(c), c.req.param("name"), null, caller); |
| 291 | audit(c, caller, "environment.variable.delete", { environment: envName(c), name: c.req.param("name").toUpperCase() }); |
| 292 | return c.body(null, 204); |
| 293 | }); |
| 294 | |
| 295 | // ---------------------------------------------------------------- deployments waiting on reviewers (pending deployments) |
| 296 | |
| 297 | deployments.get(`${R}/actions/runs/:id/pending_deployments`, async (c) => { |
| 298 | const { stub, repo } = await load(c); |
| 299 | const viewer = await viewerFor(c, repo); |
| 300 | return c.json((await stub.pendingDeployments(c.req.param("id"), viewer)).map((x) => pendingJson(c, x))); |
| 301 | }); |
| 302 | deployments.post(`${R}/actions/runs/:id/pending_deployments`, async (c) => { |
| 303 | const l = await load(c); |
| 304 | const b = await jsonBody<{ environment_ids?: unknown; state?: unknown; comment?: unknown }>(c); |
| 305 | const out = await reviewDeployments(c, l, c.req.param("id"), b); |
| 306 | return c.json(out.deployments.map((d) => deploymentJson(c, d))); |
| 307 | }); |
| 308 | deployments.get(`${R}/actions/runs/:id/approvals`, async (c) => { |
| 309 | const { stub } = await load(c); |
| 310 | const [reviews, envs] = await Promise.all([stub.deploymentReviews(c.req.param("id")), stub.environments()]); |
| 311 | return c.json(reviews.map((r) => approvalJson(c, r, envs))); |
| 312 | }); |