| 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 DeploymentStatus, type Environment } from "../repo/deployments.js"; |
| 6 | import type { Caller } from "../repo/types.js"; |
| 7 | import { baseOf, ghUser, isoSeconds, nodeId, pageOf, paginate, repoApi, repoHtml, setLinks } from "./gh.js"; |
| 8 | import { intParam, jsonBody, loadTarget, repoParams } from "./ghctx.js"; |
| 9 | |
| 10 | /** Deployments, deployment statuses and environments, with environment secrets and variables (GitHub REST). */ |
| 11 | export const deployments = new Hono<{ Bindings: Env; Variables: AppVars }>(); |
| 12 | |
| 13 | const R = "/repos/:owner/:repo"; |
| 14 | const load = (c: AppContext, min: "read" | "write" | "admin" = "read") => loadTarget(c, min); |
| 15 | const api = (c: AppContext) => repoApi(baseOf(c), repoParams(c).owner, repoParams(c).repo); |
| 16 | const envName = (c: AppContext) => (c.req.param("env") ?? ""); |
| 17 | |
| 18 | function deploymentJson(c: AppContext, d: Deployment): Record<string, unknown> { |
| 19 | const url = `${api(c)}/deployments/${d.id}`; |
| 20 | return { |
| 21 | url, |
| 22 | id: d.id, |
| 23 | node_id: nodeId("Deployment", `${repoParams(c).owner}/${repoParams(c).repo}/${d.id}`), |
| 24 | sha: d.sha, |
| 25 | ref: d.ref, |
| 26 | task: d.task, |
| 27 | payload: parsePayload(d.payload), |
| 28 | original_environment: d.originalEnvironment, |
| 29 | environment: d.environment, |
| 30 | description: d.description, |
| 31 | creator: ghUser(baseOf(c), d.creator), |
| 32 | created_at: isoSeconds(d.createdAt), |
| 33 | updated_at: isoSeconds(d.updatedAt), |
| 34 | statuses_url: `${url}/statuses`, |
| 35 | repository_url: api(c), |
| 36 | transient_environment: d.transient, |
| 37 | production_environment: d.production, |
| 38 | performed_via_github_app: null, |
| 39 | }; |
| 40 | } |
| 41 | |
| 42 | function statusJson(c: AppContext, s: DeploymentStatus) { |
| 43 | const dep = `${api(c)}/deployments/${s.deploymentId}`; |
| 44 | return { |
| 45 | url: `${dep}/statuses/${s.id}`, |
| 46 | id: s.id, |
| 47 | node_id: nodeId("DeploymentStatus", `${repoParams(c).owner}/${repoParams(c).repo}/${s.id}`), |
| 48 | state: s.state, |
| 49 | creator: ghUser(baseOf(c), s.creator), |
| 50 | description: s.description, |
| 51 | environment: s.environment, |
| 52 | target_url: s.logUrl, |
| 53 | log_url: s.logUrl, |
| 54 | environment_url: s.environmentUrl, |
| 55 | created_at: isoSeconds(s.createdAt), |
| 56 | updated_at: isoSeconds(s.createdAt), |
| 57 | deployment_url: dep, |
| 58 | repository_url: api(c), |
| 59 | performed_via_github_app: null, |
| 60 | }; |
| 61 | } |
| 62 | |
| 63 | function environmentJson(c: AppContext, e: Environment) { |
| 64 | const name = encodeURIComponent(e.name); |
| 65 | return { |
| 66 | id: e.id, |
| 67 | node_id: nodeId("Environment", `${repoParams(c).owner}/${repoParams(c).repo}/${e.id}`), |
| 68 | name: e.name, |
| 69 | url: `${api(c)}/environments/${name}`, |
| 70 | html_url: `${repoHtml(baseOf(c), repoParams(c).owner, repoParams(c).repo)}/deployments/activity_log?environments_filter=${name}`, |
| 71 | created_at: isoSeconds(e.createdAt), |
| 72 | updated_at: isoSeconds(e.updatedAt), |
| 73 | can_admins_bypass: true, |
| 74 | protection_rules: e.policy ? [{ id: e.id, node_id: nodeId("BranchPolicy", e.id), type: "branch_policy" }] : [], |
| 75 | deployment_branch_policy: e.policy ? { protected_branches: e.policy.protected_branches, custom_branch_policies: e.policy.custom_branch_policies } : null, |
| 76 | }; |
| 77 | } |
| 78 | |
| 79 | /** Environment secrets and settings are sensitive: changes go to the audit log. */ |
| 80 | function audit(c: AppContext, caller: Caller, action: string, detail: Record<string, unknown>) { |
| 81 | 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 })); |
| 82 | } |
| 83 | |
| 84 | // ---------------------------------------------------------------- deployments |
| 85 | |
| 86 | deployments.get(`${R}/deployments`, async (c) => { |
| 87 | const { stub } = await load(c); |
| 88 | const p = pageOf(c); |
| 89 | const q = (k: string) => c.req.query(k) || undefined; |
| 90 | const res = await stub.deployments({ sha: q("sha"), ref: q("ref"), task: q("task"), environment: q("environment"), limit: p.perPage, offset: p.offset }); |
| 91 | setLinks(c, p, res.total); |
| 92 | return c.json(res.items.map((d) => deploymentJson(c, d))); |
| 93 | }); |
| 94 | deployments.post(`${R}/deployments`, async (c) => { |
| 95 | const { stub, caller } = await load(c, "write"); |
| 96 | 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); |
| 97 | 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"))) { |
| 98 | throw new HttpError(422, "required_contexts must be an array of strings", { errors: [{ resource: "Deployment", code: "invalid", field: "required_contexts" }] }); |
| 99 | } |
| 100 | const required = Array.isArray(b.required_contexts) ? (b.required_contexts as string[]) : undefined; |
| 101 | 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); |
| 102 | return c.json(deploymentJson(c, d), 201); |
| 103 | }); |
| 104 | deployments.get(`${R}/deployments/:id`, async (c) => { |
| 105 | const { stub } = await load(c); |
| 106 | return c.json(deploymentJson(c, await stub.deployment(intParam(c, "id")))); |
| 107 | }); |
| 108 | deployments.delete(`${R}/deployments/:id`, async (c) => { |
| 109 | const { stub, caller } = await load(c, "write"); |
| 110 | await stub.deleteDeployment(intParam(c, "id"), caller); |
| 111 | return c.body(null, 204); |
| 112 | }); |
| 113 | deployments.get(`${R}/deployments/:id/statuses`, async (c) => { |
| 114 | const { stub } = await load(c); |
| 115 | return c.json(paginate(c, (await stub.deploymentStatuses(intParam(c, "id"))).map((s) => statusJson(c, s)))); |
| 116 | }); |
| 117 | deployments.post(`${R}/deployments/:id/statuses`, async (c) => { |
| 118 | const { stub, caller } = await load(c, "write"); |
| 119 | const b = await jsonBody<{ state?: string; target_url?: string; log_url?: string; description?: string; environment?: string; environment_url?: string; auto_inactive?: boolean }>(c); |
| 120 | return c.json(statusJson(c, await stub.createDeploymentStatus(intParam(c, "id"), b, caller)), 201); |
| 121 | }); |
| 122 | deployments.get(`${R}/deployments/:id/statuses/:status`, async (c) => { |
| 123 | const { stub } = await load(c); |
| 124 | return c.json(statusJson(c, await stub.deploymentStatus(intParam(c, "id"), intParam(c, "status")))); |
| 125 | }); |
| 126 | |
| 127 | // ---------------------------------------------------------------- environments |
| 128 | |
| 129 | deployments.get(`${R}/environments`, async (c) => { |
| 130 | const { stub } = await load(c); |
| 131 | const all = await stub.environments(); |
| 132 | return c.json({ total_count: all.length, environments: paginate(c, all).map((e) => environmentJson(c, e)) }); |
| 133 | }); |
| 134 | deployments.get(`${R}/environments/:env`, async (c) => { |
| 135 | const { stub } = await load(c); |
| 136 | return c.json(environmentJson(c, await stub.environment(envName(c)))); |
| 137 | }); |
| 138 | deployments.put(`${R}/environments/:env`, async (c) => { |
| 139 | const { stub, caller } = await load(c, "admin"); |
| 140 | const b = await jsonBody<{ wait_timer?: number; reviewers?: unknown[] | null; deployment_branch_policy?: { protected_branches?: boolean; custom_branch_policies?: boolean } | null; prevent_self_review?: boolean }>(c, true); |
| 141 | const e = await stub.upsertEnvironment(envName(c), b, caller); |
| 142 | audit(c, caller, "environment.update", { environment: e.name, deployment_branch_policy: b.deployment_branch_policy ?? null }); |
| 143 | return c.json(environmentJson(c, e)); |
| 144 | }); |
| 145 | deployments.delete(`${R}/environments/:env`, async (c) => { |
| 146 | const { stub, caller } = await load(c, "admin"); |
| 147 | await stub.deleteEnvironment(envName(c), caller); |
| 148 | audit(c, caller, "environment.delete", { environment: envName(c) }); |
| 149 | return c.body(null, 204); |
| 150 | }); |
| 151 | deployments.get(`${R}/environments/:env/deployment-branch-policies`, async (c) => { |
| 152 | const { stub } = await load(c); |
| 153 | const e = await stub.environment(envName(c)); |
| 154 | const list = e.policy?.patterns ?? []; |
| 155 | return c.json({ total_count: list.length, branch_policies: paginate(c, list).map((p) => ({ ...p, node_id: nodeId("BranchPolicy", `${e.id}/${p.id}`) })) }); |
| 156 | }); |
| 157 | deployments.post(`${R}/environments/:env/deployment-branch-policies`, async (c) => { |
| 158 | const { stub, caller } = await load(c, "admin"); |
| 159 | const b = await jsonBody<{ name?: string; type?: string }>(c); |
| 160 | const p = await stub.addEnvironmentBranchPattern(envName(c), b, caller); |
| 161 | audit(c, caller, "environment.branch_policy.create", { environment: envName(c), pattern: p.name, type: p.type }); |
| 162 | return c.json({ ...p, node_id: nodeId("BranchPolicy", p.id) }); |
| 163 | }); |
| 164 | deployments.delete(`${R}/environments/:env/deployment-branch-policies/:id`, async (c) => { |
| 165 | const { stub, caller } = await load(c, "admin"); |
| 166 | await stub.removeEnvironmentBranchPattern(envName(c), intParam(c, "id"), caller); |
| 167 | audit(c, caller, "environment.branch_policy.delete", { environment: envName(c), id: intParam(c, "id") }); |
| 168 | return c.body(null, 204); |
| 169 | }); |
| 170 | |
| 171 | // ---------------------------------------------------------------- environment secrets (names only, never values) and variables |
| 172 | |
| 173 | deployments.get(`${R}/environments/:env/secrets`, async (c) => { |
| 174 | const { stub } = await load(c, "admin"); |
| 175 | const list = await stub.environmentSecretNames(envName(c)); |
| 176 | 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) })) }); |
| 177 | }); |
| 178 | deployments.get(`${R}/environments/:env/secrets/:name`, async (c) => { |
| 179 | const { stub } = await load(c, "admin"); |
| 180 | const s = (await stub.environmentSecretNames(envName(c))).find((x) => x.name === c.req.param("name").toUpperCase()); |
| 181 | if (!s) throw new HttpError(404, "Not Found"); |
| 182 | return c.json({ name: s.name, created_at: isoSeconds(s.updatedAt), updated_at: isoSeconds(s.updatedAt) }); |
| 183 | }); |
| 184 | deployments.put(`${R}/environments/:env/secrets/:name`, async (c) => { |
| 185 | const { stub, caller } = await load(c, "admin"); |
| 186 | if (caller.kind === "agent") throw new HttpError(403, "agents cannot manage secrets"); |
| 187 | const b = await jsonBody<{ value?: string; encrypted_value?: string }>(c); |
| 188 | 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"); |
| 189 | if (typeof b.value !== "string") throw new HttpError(422, "value is required (send it over HTTPS; it is encrypted at rest)"); |
| 190 | const existed = (await stub.environmentSecretNames(envName(c))).some((x) => x.name === c.req.param("name").toUpperCase()); |
| 191 | await stub.setEnvironmentSecret(envName(c), c.req.param("name"), b.value, caller); |
| 192 | audit(c, caller, "environment.secret.set", { environment: envName(c), name: c.req.param("name").toUpperCase() }); |
| 193 | return c.body(null, existed ? 204 : 201); |
| 194 | }); |
| 195 | deployments.delete(`${R}/environments/:env/secrets/:name`, async (c) => { |
| 196 | const { stub, caller } = await load(c, "admin"); |
| 197 | if (caller.kind === "agent") throw new HttpError(403, "agents cannot manage secrets"); |
| 198 | await stub.deleteEnvironmentSecret(envName(c), c.req.param("name"), caller); |
| 199 | audit(c, caller, "environment.secret.delete", { environment: envName(c), name: c.req.param("name").toUpperCase() }); |
| 200 | return c.body(null, 204); |
| 201 | }); |
| 202 | |
| 203 | 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) }); |
| 204 | |
| 205 | deployments.get(`${R}/environments/:env/variables`, async (c) => { |
| 206 | const { stub } = await load(c, "write"); |
| 207 | const list = await stub.environmentVariables(envName(c)); |
| 208 | return c.json({ total_count: list.length, variables: paginate(c, list, 10).map(variableJson) }); |
| 209 | }); |
| 210 | deployments.get(`${R}/environments/:env/variables/:name`, async (c) => { |
| 211 | const { stub } = await load(c, "write"); |
| 212 | const v = (await stub.environmentVariables(envName(c))).find((x) => x.name === c.req.param("name").toUpperCase()); |
| 213 | if (!v) throw new HttpError(404, "Not Found"); |
| 214 | return c.json(variableJson(v)); |
| 215 | }); |
| 216 | deployments.post(`${R}/environments/:env/variables`, async (c) => { |
| 217 | const { stub, caller } = await load(c, "admin"); |
| 218 | const b = await jsonBody<{ name?: string; value?: string }>(c); |
| 219 | if (typeof b.name !== "string" || typeof b.value !== "string") throw new HttpError(422, "name and value are required"); |
| 220 | if ((await stub.environmentVariables(envName(c))).some((x) => x.name === b.name!.toUpperCase())) throw new HttpError(409, "Already exists - Variable already exists"); |
| 221 | await stub.setEnvironmentVariable(envName(c), b.name, b.value, caller); |
| 222 | return c.json({}, 201); |
| 223 | }); |
| 224 | deployments.patch(`${R}/environments/:env/variables/:name`, async (c) => { |
| 225 | const { stub, caller } = await load(c, "admin"); |
| 226 | const b = await jsonBody<{ name?: string; value?: string }>(c); |
| 227 | const name = c.req.param("name"); |
| 228 | const current = (await stub.environmentVariables(envName(c))).find((x) => x.name === name.toUpperCase()); |
| 229 | if (!current) throw new HttpError(404, "Not Found"); |
| 230 | const next = typeof b.name === "string" && b.name ? b.name : name; |
| 231 | await stub.setEnvironmentVariable(envName(c), next, typeof b.value === "string" ? b.value : current.value, caller); |
| 232 | if (next.toUpperCase() !== name.toUpperCase()) await stub.setEnvironmentVariable(envName(c), name, null, caller); |
| 233 | return c.body(null, 204); |
| 234 | }); |
| 235 | deployments.delete(`${R}/environments/:env/variables/:name`, async (c) => { |
| 236 | const { stub, caller } = await load(c, "admin"); |
| 237 | await stub.setEnvironmentVariable(envName(c), c.req.param("name"), null, caller); |
| 238 | return c.body(null, 204); |
| 239 | }); |