| 1 | import type { Env } from "../env.js"; |
| 2 | import { decryptAndReseal, encryptSecret } from "../lib/crypto.js"; |
| 3 | import { HttpError, now } from "../lib/util.js"; |
| 4 | import type { RepoDO } from "./RepoDO.js"; |
| 5 | import { requiredCheck } from "./required.js"; |
| 6 | import { atLeast, globToRegExp, type Caller } from "./types.js"; |
| 7 | |
| 8 | type DeploymentRow = { |
| 9 | id: number; |
| 10 | sha: string; |
| 11 | ref: string; |
| 12 | task: string; |
| 13 | environment: string; |
| 14 | original_environment: string; |
| 15 | description: string | null; |
| 16 | payload: string; |
| 17 | transient: number; |
| 18 | production: number; |
| 19 | creator_id: string; |
| 20 | creator_login: string; |
| 21 | creator_kind: string; |
| 22 | job_id: string | null; |
| 23 | created_at: string; |
| 24 | updated_at: string; |
| 25 | }; |
| 26 | |
| 27 | type StatusRow = { |
| 28 | id: number; |
| 29 | deployment_id: number; |
| 30 | state: string; |
| 31 | description: string; |
| 32 | environment: string; |
| 33 | environment_url: string; |
| 34 | log_url: string; |
| 35 | creator_id: string; |
| 36 | creator_login: string; |
| 37 | creator_kind: string; |
| 38 | created_at: string; |
| 39 | }; |
| 40 | |
| 41 | type EnvironmentRow = { id: number; name: string; branch_policy: string | null; reviewers: string | null; wait_timer: number; prevent_self_review: number; created_at: string; updated_at: string }; |
| 42 | |
| 43 | type ReviewRow = { id: number; run_id: string; environment: string; state: "approved" | "rejected"; comment: string; user_id: string; user_login: string; created_at: string }; |
| 44 | |
| 45 | export type Person = { id: string; login: string; kind: string }; |
| 46 | |
| 47 | export type Deployment = { |
| 48 | id: number; |
| 49 | sha: string; |
| 50 | ref: string; |
| 51 | task: string; |
| 52 | environment: string; |
| 53 | originalEnvironment: string; |
| 54 | description: string | null; |
| 55 | /** The payload as JSON text (see parsePayload). */ |
| 56 | payload: string; |
| 57 | transient: boolean; |
| 58 | production: boolean; |
| 59 | creator: Person; |
| 60 | createdAt: string; |
| 61 | updatedAt: string; |
| 62 | /** State of the newest status, if any. */ |
| 63 | state: string | null; |
| 64 | }; |
| 65 | |
| 66 | export type DeploymentStatus = { |
| 67 | id: number; |
| 68 | deploymentId: number; |
| 69 | state: string; |
| 70 | description: string; |
| 71 | environment: string; |
| 72 | environmentUrl: string; |
| 73 | logUrl: string; |
| 74 | creator: Person; |
| 75 | createdAt: string; |
| 76 | }; |
| 77 | |
| 78 | /** Which refs may deploy to an environment (GitHub's deployment branch policy). */ |
| 79 | export type BranchPolicy = { protected_branches: boolean; custom_branch_policies: boolean; patterns: Array<{ id: number; name: string; type: "branch" | "tag" }> }; |
| 80 | |
| 81 | /** |
| 82 | * Whether a job on `ref`, started by an actor of `actorKind`, may receive an |
| 83 | * environment's secrets. Pull request refs never do: their code is whatever the |
| 84 | * pull request says. Runs started by agents only do when the environment is |
| 85 | * limited to protected branches, where changes need review. |
| 86 | */ |
| 87 | export function secretsAllowedFor(policy: BranchPolicy | null, ref: string, actorKind: string, rules: { agentsMayPush?: boolean } | null): boolean { |
| 88 | if (ref.startsWith("refs/pull/")) return false; |
| 89 | // "Protected" has to mean agents can't push there themselves: a rule that lets them push |
| 90 | // straight to the branch would hand the environment's secrets to any agent run. |
| 91 | if (actorKind === "agent") return !!policy?.protected_branches && !!rules && !rules.agentsMayPush; |
| 92 | return true; |
| 93 | } |
| 94 | |
| 95 | /** A required reviewer: a person with access to the repository (Diffinery has no teams). */ |
| 96 | export type Reviewer = { type: "User"; id: string; login: string }; |
| 97 | |
| 98 | /** An environment's protection rules beyond its branch policy, as GitHub has them. */ |
| 99 | export type Protection = { reviewers: Reviewer[]; waitTimer: number; preventSelfReview: boolean }; |
| 100 | |
| 101 | export type Environment = { id: number; name: string; policy: BranchPolicy | null; protection: Protection; createdAt: string; updatedAt: string }; |
| 102 | |
| 103 | /** One required reviewer's decision on an environment for a workflow run. */ |
| 104 | export type DeploymentReview = { id: number; runId: string; environment: string; state: "approved" | "rejected"; comment: string; user: { id: string; login: string }; createdAt: string }; |
| 105 | |
| 106 | /** GitHub's limits: up to 6 required reviewers, and a wait timer of up to 30 days. */ |
| 107 | export const MAX_REVIEWERS = 6; |
| 108 | export const MAX_WAIT_TIMER = 43_200; |
| 109 | /** A job waits this long for its environment's reviewers before it fails, as on GitHub. */ |
| 110 | export const APPROVAL_DAYS = 30; |
| 111 | export const MAX_REVIEW_COMMENT = 4_000; |
| 112 | |
| 113 | const STATES = new Set(["error", "failure", "inactive", "in_progress", "queued", "pending", "success"]); |
| 114 | const ENV_NAME = /^[^\x00-\x1f\x7f/\\]{1,255}$/; |
| 115 | const SECRET_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; |
| 116 | |
| 117 | const person = (id: string, login: string, kind: string): Person => ({ id, login, kind }); |
| 118 | |
| 119 | /** A deployment's payload: the JSON it was created with, or the raw string when it isn't JSON. */ |
| 120 | export function parsePayload(text: string): unknown { |
| 121 | try { |
| 122 | return JSON.parse(text); |
| 123 | } catch { |
| 124 | return text; |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | /** |
| 129 | * Deployments, deployment statuses and environments (with their own secrets |
| 130 | * and variables). Workflow jobs with `environment:` record a deployment and |
| 131 | * get that environment's secrets once its branch policy allows the ref. |
| 132 | */ |
| 133 | export class Deployments { |
| 134 | constructor( |
| 135 | private repo: RepoDO, |
| 136 | private env: Env, |
| 137 | ) {} |
| 138 | |
| 139 | private get sql() { |
| 140 | return this.repo.sql; |
| 141 | } |
| 142 | |
| 143 | private toDeployment(r: DeploymentRow): Deployment { |
| 144 | const latest = this.sql.exec<{ state: string }>("SELECT state FROM deployment_statuses WHERE deployment_id = ? ORDER BY id DESC LIMIT 1", r.id).toArray()[0]; |
| 145 | return { |
| 146 | id: r.id, |
| 147 | sha: r.sha, |
| 148 | ref: r.ref, |
| 149 | task: r.task, |
| 150 | environment: r.environment, |
| 151 | originalEnvironment: r.original_environment, |
| 152 | description: r.description, |
| 153 | payload: r.payload, |
| 154 | transient: !!r.transient, |
| 155 | production: !!r.production, |
| 156 | creator: person(r.creator_id, r.creator_login, r.creator_kind), |
| 157 | createdAt: r.created_at, |
| 158 | updatedAt: r.updated_at, |
| 159 | state: latest?.state ?? null, |
| 160 | }; |
| 161 | } |
| 162 | |
| 163 | private toStatus = (s: StatusRow): DeploymentStatus => ({ |
| 164 | id: s.id, |
| 165 | deploymentId: s.deployment_id, |
| 166 | state: s.state, |
| 167 | description: s.description, |
| 168 | environment: s.environment, |
| 169 | environmentUrl: s.environment_url, |
| 170 | logUrl: s.log_url, |
| 171 | creator: person(s.creator_id, s.creator_login, s.creator_kind), |
| 172 | createdAt: s.created_at, |
| 173 | }); |
| 174 | |
| 175 | private row(id: number): DeploymentRow { |
| 176 | const r = this.sql.exec<DeploymentRow>("SELECT * FROM deployments WHERE id = ?", id).toArray()[0]; |
| 177 | if (!r) throw new HttpError(404, "deployment not found"); |
| 178 | return r; |
| 179 | } |
| 180 | |
| 181 | private emit(type: "deployment" | "deployment_status", action: string, payload: Record<string, unknown>, caller: Pick<Caller, "id" | "login" | "kind" | "onBehalfOf">): void { |
| 182 | const m = this.repo.meta; |
| 183 | this.repo.emit({ type, action, repoId: m.repoId, owner: m.owner, repo: m.name, payload, actor: { id: caller.id, login: caller.login, kind: caller.kind, onBehalfOf: caller.onBehalfOf }, at: now() }); |
| 184 | } |
| 185 | |
| 186 | // ---------------------------------------------------------------- deployments |
| 187 | |
| 188 | async create( |
| 189 | input: { ref?: string; task?: string; payload?: unknown; environment?: string; description?: string | null; transient_environment?: boolean; production_environment?: boolean; required_contexts?: string[] }, |
| 190 | caller: Caller, |
| 191 | jobId: string | null = null, |
| 192 | ): Promise<Deployment> { |
| 193 | if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to create deployments"); |
| 194 | const ref = (input.ref ?? "").trim(); |
| 195 | if (!ref) throw new HttpError(422, "ref is required", { errors: [{ resource: "Deployment", code: "missing_field", field: "ref" }] }); |
| 196 | const sha = await this.repo.resolve(ref); |
| 197 | if (!sha) throw new HttpError(422, `no ref found for: ${ref}`, { errors: [{ resource: "Deployment", code: "invalid", field: "ref" }] }); |
| 198 | const rawEnvironment = (input.environment ?? "production").trim(); |
| 199 | if (!ENV_NAME.test(rawEnvironment)) throw new HttpError(422, "invalid environment name"); |
| 200 | // Environment names are case-insensitive, as on GitHub: reuse the existing spelling. |
| 201 | const environment = this.envRow(rawEnvironment)?.name ?? rawEnvironment; |
| 202 | // Like GitHub: without required_contexts every context on the commit must have passed; [] skips the check. |
| 203 | // Deployments a workflow job makes for its `environment:` are not checked (the job's own run is still going). |
| 204 | if (jobId === null && (input.required_contexts === undefined || input.required_contexts.length)) { |
| 205 | const entries = [...this.repo.statuses.asChecks(sha), ...this.repo.collab.checks(sha)]; |
| 206 | const contexts = input.required_contexts ?? [...new Set(entries.filter((e) => e.creatorKind !== "agent").map((e) => e.name))]; |
| 207 | const states = contexts.map((ctx) => ({ context: ctx, state: requiredCheck(ctx, entries).state })); |
| 208 | const failing = states.filter((x) => x.state !== "success"); |
| 209 | if (failing.length) throw new HttpError(409, `Conflict: Commit status checks failed for ${ref}.`, { errors: failing.map((x) => ({ contexts: [x], resource: "Deployment", field: "required_contexts" })) }); |
| 210 | } |
| 211 | this.ensureEnvironment(environment); |
| 212 | const t = now(); |
| 213 | this.sql.exec( |
| 214 | "INSERT INTO deployments (sha, ref, task, environment, original_environment, description, payload, transient, production, creator_id, creator_login, creator_kind, job_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", |
| 215 | sha, |
| 216 | ref, |
| 217 | (input.task || "deploy").slice(0, 100), |
| 218 | environment, |
| 219 | environment, |
| 220 | input.description ? String(input.description).slice(0, 1000) : null, |
| 221 | typeof input.payload === "string" ? input.payload : JSON.stringify(input.payload ?? {}), |
| 222 | input.transient_environment ? 1 : 0, |
| 223 | (input.production_environment ?? environment.toLowerCase() === "production") ? 1 : 0, |
| 224 | caller.id, |
| 225 | caller.login, |
| 226 | caller.kind, |
| 227 | jobId, |
| 228 | t, |
| 229 | t, |
| 230 | ); |
| 231 | const d = this.toDeployment(this.sql.exec<DeploymentRow>("SELECT * FROM deployments WHERE id = last_insert_rowid()").one()); |
| 232 | this.emit("deployment", "created", { deployment: { id: d.id, sha, ref, task: d.task, environment, description: d.description, payload: parsePayload(d.payload), created_at: t } }, caller); |
| 233 | return d; |
| 234 | } |
| 235 | |
| 236 | list(filter: { sha?: string; ref?: string; task?: string; environment?: string; limit: number; offset: number }): { items: Deployment[]; total: number } { |
| 237 | const where: string[] = []; |
| 238 | const args: unknown[] = []; |
| 239 | for (const [col, v] of [["sha", filter.sha], ["ref", filter.ref], ["task", filter.task], ["environment", filter.environment]] as const) { |
| 240 | if (v) { |
| 241 | where.push(col === "environment" ? "environment = ? COLLATE NOCASE" : `${col} = ?`); |
| 242 | args.push(v); |
| 243 | } |
| 244 | } |
| 245 | const w = where.length ? `WHERE ${where.join(" AND ")}` : ""; |
| 246 | const total = this.sql.exec<{ n: number }>(`SELECT count(*) AS n FROM deployments ${w}`, ...args).one().n; |
| 247 | const rows = this.sql.exec<DeploymentRow>(`SELECT * FROM deployments ${w} ORDER BY id DESC LIMIT ? OFFSET ?`, ...args, Math.min(filter.limit, 100), filter.offset).toArray(); |
| 248 | return { items: rows.map((r) => this.toDeployment(r)), total }; |
| 249 | } |
| 250 | |
| 251 | get(id: number): Deployment { |
| 252 | return this.toDeployment(this.row(id)); |
| 253 | } |
| 254 | |
| 255 | remove(id: number, caller: Caller): void { |
| 256 | if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to delete deployments"); |
| 257 | const d = this.get(id); |
| 258 | const others = this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM deployments WHERE environment = ? COLLATE NOCASE AND id != ?", d.environment, id).one().n; |
| 259 | if (d.state === "success" && others > 0) throw new HttpError(422, "We cannot delete an active deployment unless it is the only deployment in a given environment."); |
| 260 | this.sql.exec("DELETE FROM deployment_statuses WHERE deployment_id = ?", id); |
| 261 | this.sql.exec("DELETE FROM deployments WHERE id = ?", id); |
| 262 | } |
| 263 | |
| 264 | createStatus( |
| 265 | id: number, |
| 266 | input: { state?: string; target_url?: string; log_url?: string; description?: string; environment?: string; environment_url?: string; auto_inactive?: boolean }, |
| 267 | caller: Pick<Caller, "id" | "login" | "kind" | "onBehalfOf" | "role">, |
| 268 | ): DeploymentStatus { |
| 269 | if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to create deployment statuses"); |
| 270 | const d = this.row(id); |
| 271 | const state = String(input.state ?? ""); |
| 272 | if (!STATES.has(state)) throw new HttpError(422, "state must be one of error, failure, inactive, in_progress, queued, pending, success", { errors: [{ resource: "DeploymentStatus", code: "invalid", field: "state" }] }); |
| 273 | const urls = [input.log_url ?? input.target_url ?? "", input.environment_url ?? ""]; |
| 274 | for (const u of urls) if (u && !/^https?:\/\//i.test(u)) throw new HttpError(422, "URLs must be http(s)"); |
| 275 | let environment = input.environment?.trim() || d.environment; |
| 276 | if (environment.toLowerCase() === d.environment.toLowerCase()) environment = d.environment; |
| 277 | else { |
| 278 | if (!ENV_NAME.test(environment)) throw new HttpError(422, "invalid environment name"); |
| 279 | environment = this.ensureEnvironment(environment).name; |
| 280 | this.sql.exec("UPDATE deployments SET environment = ? WHERE id = ?", environment, id); |
| 281 | } |
| 282 | const t = now(); |
| 283 | this.sql.exec( |
| 284 | "INSERT INTO deployment_statuses (deployment_id, state, description, environment, environment_url, log_url, creator_id, creator_login, creator_kind, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", |
| 285 | id, |
| 286 | state, |
| 287 | (input.description ?? "").slice(0, 140), |
| 288 | environment, |
| 289 | urls[1], |
| 290 | urls[0], |
| 291 | caller.id, |
| 292 | caller.login, |
| 293 | caller.kind, |
| 294 | t, |
| 295 | ); |
| 296 | const statusId = this.sql.exec<{ id: number }>("SELECT last_insert_rowid() AS id").one().id; |
| 297 | this.sql.exec("UPDATE deployments SET updated_at = ? WHERE id = ?", t, id); |
| 298 | if (state === "success" && input.auto_inactive !== false) { |
| 299 | // Older non-transient, non-production deployments to the same environment become inactive (newer ones stay as they are). |
| 300 | const older = this.sql.exec<{ id: number }>("SELECT id FROM deployments WHERE environment = ? COLLATE NOCASE AND id < ? AND transient = 0 AND production = 0", environment, id).toArray(); |
| 301 | for (const o of older) { |
| 302 | const last = this.sql.exec<{ state: string }>("SELECT state FROM deployment_statuses WHERE deployment_id = ? ORDER BY id DESC LIMIT 1", o.id).toArray()[0]; |
| 303 | if (last?.state === "success") { |
| 304 | this.sql.exec( |
| 305 | "INSERT INTO deployment_statuses (deployment_id, state, description, environment, creator_id, creator_login, creator_kind, created_at) VALUES (?, 'inactive', '', ?, ?, ?, ?, ?)", |
| 306 | o.id, |
| 307 | environment, |
| 308 | caller.id, |
| 309 | caller.login, |
| 310 | caller.kind, |
| 311 | t, |
| 312 | ); |
| 313 | } |
| 314 | } |
| 315 | } |
| 316 | const status = this.status(id, statusId); |
| 317 | this.emit("deployment_status", "created", { deployment_status: { id: statusId, state, description: status.description, environment, environment_url: status.environmentUrl, log_url: status.logUrl, created_at: t }, deployment: { id, sha: d.sha, ref: d.ref, task: d.task, environment } }, caller); |
| 318 | return status; |
| 319 | } |
| 320 | |
| 321 | statuses(id: number): DeploymentStatus[] { |
| 322 | this.row(id); |
| 323 | return this.sql.exec<StatusRow>("SELECT * FROM deployment_statuses WHERE deployment_id = ? ORDER BY id DESC", id).toArray().map(this.toStatus); |
| 324 | } |
| 325 | |
| 326 | status(id: number, statusId: number): DeploymentStatus { |
| 327 | const s = this.sql.exec<StatusRow>("SELECT * FROM deployment_statuses WHERE deployment_id = ? AND id = ?", id, statusId).toArray()[0]; |
| 328 | if (!s) throw new HttpError(404, "deployment status not found"); |
| 329 | return this.toStatus(s); |
| 330 | } |
| 331 | |
| 332 | // ---------------------------------------------------------------- environments |
| 333 | |
| 334 | /** Environments guard deployment secrets, so only people with admin access change them, never agents. */ |
| 335 | private requireHumanAdmin(caller: Caller): void { |
| 336 | if (!atLeast(caller.role, "admin") || caller.kind === "agent") throw new HttpError(403, "only repository admins (people, not agents) can manage environments"); |
| 337 | } |
| 338 | |
| 339 | /** Remember who last moved a tag: tags created by agents never satisfy environment tag patterns. */ |
| 340 | noteRefUpdate(ref: string, newOid: string, caller: Pick<Caller, "id" | "kind">): void { |
| 341 | if (!ref.startsWith("refs/tags/")) return; |
| 342 | if (/^0+$/.test(newOid)) this.sql.exec("DELETE FROM ref_actors WHERE ref = ?", ref); |
| 343 | else this.sql.exec("INSERT OR REPLACE INTO ref_actors (ref, actor_id, actor_kind, updated_at) VALUES (?, ?, ?, ?)", ref, caller.id, caller.kind, now()); |
| 344 | } |
| 345 | |
| 346 | private tagByAgent(tag: string): boolean { |
| 347 | return this.sql.exec<{ actor_kind: string }>("SELECT actor_kind FROM ref_actors WHERE ref = ?", `refs/tags/${tag}`).toArray()[0]?.actor_kind === "agent"; |
| 348 | } |
| 349 | |
| 350 | private envRow(name: string): EnvironmentRow | undefined { |
| 351 | return this.sql.exec<EnvironmentRow>("SELECT * FROM environments WHERE name = ?", name).toArray()[0]; |
| 352 | } |
| 353 | |
| 354 | private toEnvironment = (r: EnvironmentRow): Environment => ({ |
| 355 | id: r.id, |
| 356 | name: r.name, |
| 357 | policy: r.branch_policy ? (JSON.parse(r.branch_policy) as BranchPolicy) : null, |
| 358 | protection: { reviewers: r.reviewers ? (JSON.parse(r.reviewers) as Reviewer[]) : [], waitTimer: r.wait_timer ?? 0, preventSelfReview: !!r.prevent_self_review }, |
| 359 | createdAt: r.created_at, |
| 360 | updatedAt: r.updated_at, |
| 361 | }); |
| 362 | |
| 363 | private ensureEnvironment(name: string): EnvironmentRow { |
| 364 | const existing = this.envRow(name); |
| 365 | if (existing) return existing; |
| 366 | const t = now(); |
| 367 | this.sql.exec("INSERT INTO environments (name, created_at, updated_at) VALUES (?, ?, ?)", name, t, t); |
| 368 | return this.envRow(name)!; |
| 369 | } |
| 370 | |
| 371 | environments(): Environment[] { |
| 372 | return this.sql.exec<EnvironmentRow>("SELECT * FROM environments ORDER BY name").toArray().map(this.toEnvironment); |
| 373 | } |
| 374 | |
| 375 | environment(name: string): Environment { |
| 376 | const r = this.envRow(name); |
| 377 | if (!r) throw new HttpError(404, "environment not found"); |
| 378 | return this.toEnvironment(r); |
| 379 | } |
| 380 | |
| 381 | /** |
| 382 | * Create or update an environment. `reviewers` arrive resolved to people (http/environments.ts |
| 383 | * checks each is a person with access); a field left out keeps its value. |
| 384 | */ |
| 385 | upsertEnvironment( |
| 386 | name: string, |
| 387 | input: { wait_timer?: unknown; reviewers?: Reviewer[] | null; prevent_self_review?: unknown; deployment_branch_policy?: { protected_branches?: boolean; custom_branch_policies?: boolean } | null }, |
| 388 | caller: Caller, |
| 389 | ): Environment { |
| 390 | this.requireHumanAdmin(caller); |
| 391 | if (!ENV_NAME.test(name)) throw new HttpError(422, "invalid environment name"); |
| 392 | if (input.wait_timer !== undefined && input.wait_timer !== null && (typeof input.wait_timer !== "number" || !Number.isInteger(input.wait_timer) || input.wait_timer < 0 || input.wait_timer > MAX_WAIT_TIMER)) { |
| 393 | throw new HttpError(422, `wait_timer must be a whole number of minutes from 0 to ${MAX_WAIT_TIMER}`); |
| 394 | } |
| 395 | if (input.prevent_self_review !== undefined && input.prevent_self_review !== null && typeof input.prevent_self_review !== "boolean") throw new HttpError(422, "prevent_self_review must be true or false"); |
| 396 | if (input.reviewers) { |
| 397 | if (input.reviewers.length > MAX_REVIEWERS) throw new HttpError(422, `an environment can have at most ${MAX_REVIEWERS} required reviewers`); |
| 398 | if (input.reviewers.some((r) => r.type !== "User" || typeof r.id !== "string" || typeof r.login !== "string")) throw new HttpError(422, "reviewers must be people"); |
| 399 | } |
| 400 | const row = this.ensureEnvironment(name); |
| 401 | if (input.wait_timer !== undefined && input.wait_timer !== null) this.sql.exec("UPDATE environments SET wait_timer = ?, updated_at = ? WHERE id = ?", input.wait_timer, now(), row.id); |
| 402 | if (input.prevent_self_review !== undefined && input.prevent_self_review !== null) this.sql.exec("UPDATE environments SET prevent_self_review = ?, updated_at = ? WHERE id = ?", input.prevent_self_review ? 1 : 0, now(), row.id); |
| 403 | if (input.reviewers !== undefined) { |
| 404 | const unique = [...new Map((input.reviewers ?? []).map((r) => [r.id, { type: "User" as const, id: r.id, login: r.login }])).values()]; |
| 405 | this.sql.exec("UPDATE environments SET reviewers = ?, updated_at = ? WHERE id = ?", unique.length ? JSON.stringify(unique) : null, now(), row.id); |
| 406 | } |
| 407 | if (input.deployment_branch_policy !== undefined) { |
| 408 | const p = input.deployment_branch_policy; |
| 409 | if (p && !!p.protected_branches === !!p.custom_branch_policies) throw new HttpError(422, "set exactly one of protected_branches and custom_branch_policies"); |
| 410 | const old = row.branch_policy ? (JSON.parse(row.branch_policy) as BranchPolicy) : null; |
| 411 | const policy: BranchPolicy | null = p ? { protected_branches: !!p.protected_branches, custom_branch_policies: !!p.custom_branch_policies, patterns: p.custom_branch_policies ? (old?.patterns ?? []) : [] } : null; |
| 412 | this.sql.exec("UPDATE environments SET branch_policy = ?, updated_at = ? WHERE id = ?", policy ? JSON.stringify(policy) : null, now(), row.id); |
| 413 | } |
| 414 | return this.environment(name); |
| 415 | } |
| 416 | |
| 417 | /** The environment's reviewers and wait timer, or null when it has neither (its jobs then start at once). */ |
| 418 | protection(name: string): Protection | null { |
| 419 | const r = this.envRow(name); |
| 420 | if (!r) return null; |
| 421 | const p = this.toEnvironment(r).protection; |
| 422 | return p.reviewers.length || p.waitTimer > 0 ? p : null; |
| 423 | } |
| 424 | |
| 425 | /** Record a reviewer's decision for a run's environment (the caller checked who may decide). */ |
| 426 | addReview(runId: string, environment: string, state: "approved" | "rejected", comment: string, user: { id: string; login: string }): DeploymentReview { |
| 427 | const t = now(); |
| 428 | this.sql.exec("INSERT INTO deployment_reviews (run_id, environment, state, comment, user_id, user_login, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", runId, environment, state, comment.slice(0, MAX_REVIEW_COMMENT), user.id, user.login, t); |
| 429 | return this.toReview(this.sql.exec<ReviewRow>("SELECT * FROM deployment_reviews WHERE id = last_insert_rowid()").one()); |
| 430 | } |
| 431 | |
| 432 | /** The decision on a run's environment so far: the first one counts, as on GitHub. */ |
| 433 | decision(runId: string, environment: string): "approved" | "rejected" | null { |
| 434 | return this.sql.exec<{ state: "approved" | "rejected" }>("SELECT state FROM deployment_reviews WHERE run_id = ? AND environment = ? COLLATE NOCASE ORDER BY id LIMIT 1", runId, environment).toArray()[0]?.state ?? null; |
| 435 | } |
| 436 | |
| 437 | /** The deployments workflow jobs recorded, newest first. */ |
| 438 | jobDeployments(jobIds: string[]): Deployment[] { |
| 439 | if (!jobIds.length) return []; |
| 440 | return this.sql.exec<DeploymentRow>(`SELECT * FROM deployments WHERE job_id IN (${jobIds.map(() => "?").join(",")}) ORDER BY id DESC`, ...jobIds).toArray().map((r) => this.toDeployment(r)); |
| 441 | } |
| 442 | |
| 443 | reviews(runId: string): DeploymentReview[] { |
| 444 | return this.sql.exec<ReviewRow>("SELECT * FROM deployment_reviews WHERE run_id = ? ORDER BY id", runId).toArray().map((r) => this.toReview(r)); |
| 445 | } |
| 446 | |
| 447 | private toReview = (r: ReviewRow): DeploymentReview => ({ id: r.id, runId: r.run_id, environment: r.environment, state: r.state, comment: r.comment, user: { id: r.user_id, login: r.user_login }, createdAt: r.created_at }); |
| 448 | |
| 449 | deleteEnvironment(name: string, caller: Caller): void { |
| 450 | this.requireHumanAdmin(caller); |
| 451 | this.environment(name); |
| 452 | this.sql.exec("DELETE FROM environments WHERE name = ?", name); |
| 453 | this.sql.exec("DELETE FROM environment_secrets WHERE environment = ?", name); |
| 454 | this.sql.exec("DELETE FROM environment_variables WHERE environment = ?", name); |
| 455 | } |
| 456 | |
| 457 | addBranchPattern(name: string, pattern: { name?: string; type?: string }, caller: Caller): { id: number; name: string; type: "branch" | "tag" } { |
| 458 | this.requireHumanAdmin(caller); |
| 459 | const env = this.environment(name); |
| 460 | if (!env.policy?.custom_branch_policies) throw new HttpError(422, "enable custom_branch_policies on the environment first"); |
| 461 | const p = { id: Math.max(0, ...env.policy.patterns.map((x) => x.id)) + 1, name: String(pattern.name ?? "").trim(), type: pattern.type === "tag" ? ("tag" as const) : ("branch" as const) }; |
| 462 | if (!p.name) throw new HttpError(422, "name is required"); |
| 463 | env.policy.patterns.push(p); |
| 464 | this.sql.exec("UPDATE environments SET branch_policy = ?, updated_at = ? WHERE id = ?", JSON.stringify(env.policy), now(), env.id); |
| 465 | return p; |
| 466 | } |
| 467 | |
| 468 | removeBranchPattern(name: string, id: number, caller: Caller): void { |
| 469 | this.requireHumanAdmin(caller); |
| 470 | const env = this.environment(name); |
| 471 | if (!env.policy) throw new HttpError(404, "branch policy not found"); |
| 472 | env.policy.patterns = env.policy.patterns.filter((x) => x.id !== id); |
| 473 | this.sql.exec("UPDATE environments SET branch_policy = ?, updated_at = ? WHERE id = ?", JSON.stringify(env.policy), now(), env.id); |
| 474 | } |
| 475 | |
| 476 | /** Whether a run on `ref` may use the environment (and see its secrets). */ |
| 477 | refAllowed(name: string, ref: string): boolean { |
| 478 | const r = this.envRow(name); |
| 479 | const policy = r?.branch_policy ? (JSON.parse(r.branch_policy) as BranchPolicy) : null; |
| 480 | if (!policy) return true; |
| 481 | const branch = ref.startsWith("refs/heads/") ? ref.slice(11) : null; |
| 482 | const tag = ref.startsWith("refs/tags/") ? ref.slice(10) : null; |
| 483 | if (policy.protected_branches) return !!branch && !!this.repo.protectionFor(branch); |
| 484 | return policy.patterns.some((p) => (p.type === "tag" ? tag !== null && !this.tagByAgent(tag) && globToRegExp(p.name).test(tag) : branch !== null && globToRegExp(p.name).test(branch))); |
| 485 | } |
| 486 | |
| 487 | /** Whether a job may receive the environment's secrets (it already passed refAllowed); see secretsAllowedFor. */ |
| 488 | secretsAllowed(name: string, ref: string, actorKind: string): boolean { |
| 489 | const r = this.envRow(name); |
| 490 | const rules = ref.startsWith("refs/heads/") ? this.repo.protectionFor(ref.slice(11)) : null; |
| 491 | return secretsAllowedFor(r?.branch_policy ? (JSON.parse(r.branch_policy) as BranchPolicy) : null, ref, actorKind, rules); |
| 492 | } |
| 493 | |
| 494 | // ---------------------------------------------------------------- environment secrets and variables |
| 495 | |
| 496 | private scope(name: string): string { |
| 497 | return `repo:${this.repo.meta.repoId}:env:${name.toLowerCase()}`; |
| 498 | } |
| 499 | |
| 500 | async setSecret(envName: string, name: string, value: string, caller: Caller): Promise<void> { |
| 501 | if (!atLeast(caller.role, "admin") || caller.kind === "agent") throw new HttpError(403, "only repository admins (people, not agents) can manage secrets"); |
| 502 | if (!SECRET_NAME.test(name) || /^GITHUB_/i.test(name)) throw new HttpError(422, "invalid secret name"); |
| 503 | const env = this.environment(envName); |
| 504 | // An environment holding secrets defaults to protected branches only, like a fresh GitHub environment should be. |
| 505 | if (!env.policy) this.sql.exec("UPDATE environments SET branch_policy = ?, updated_at = ? WHERE id = ?", JSON.stringify({ protected_branches: true, custom_branch_policies: false, patterns: [] } satisfies BranchPolicy), now(), env.id); |
| 506 | this.sql.exec("INSERT OR REPLACE INTO environment_secrets (environment, name, ciphertext, updated_at) VALUES (?, ?, ?, ?)", env.name, name.toUpperCase(), await encryptSecret(this.env, this.scope(env.name), value), now()); |
| 507 | } |
| 508 | |
| 509 | deleteSecret(envName: string, name: string, caller: Caller): void { |
| 510 | if (!atLeast(caller.role, "admin") || caller.kind === "agent") throw new HttpError(403, "only repository admins (people, not agents) can manage secrets"); |
| 511 | this.sql.exec("DELETE FROM environment_secrets WHERE environment = ? AND name = ?", envName, name.toUpperCase()); |
| 512 | } |
| 513 | |
| 514 | secretNames(envName: string): Array<{ name: string; updatedAt: string }> { |
| 515 | this.environment(envName); |
| 516 | return this.sql.exec<{ name: string; updated_at: string }>("SELECT name, updated_at FROM environment_secrets WHERE environment = ? ORDER BY name", envName).toArray().map((r) => ({ name: r.name, updatedAt: r.updated_at })); |
| 517 | } |
| 518 | |
| 519 | setVariable(envName: string, name: string, value: string | null, caller: Caller, mustExist = false): void { |
| 520 | if (!atLeast(caller.role, "admin")) throw new HttpError(403, "admin access is required to manage variables"); |
| 521 | const env = this.environment(envName); |
| 522 | const key = name.toUpperCase(); |
| 523 | if (value === null) { |
| 524 | this.sql.exec("DELETE FROM environment_variables WHERE environment = ? AND name = ?", env.name, key); |
| 525 | return; |
| 526 | } |
| 527 | if (!SECRET_NAME.test(name) || /^GITHUB_/i.test(name)) throw new HttpError(422, "invalid variable name"); |
| 528 | const existing = this.sql.exec("SELECT 1 FROM environment_variables WHERE environment = ? AND name = ?", env.name, key).toArray().length > 0; |
| 529 | if (mustExist && !existing) throw new HttpError(404, "variable not found"); |
| 530 | const t = now(); |
| 531 | if (existing) this.sql.exec("UPDATE environment_variables SET value = ?, updated_at = ? WHERE environment = ? AND name = ?", value, t, env.name, key); |
| 532 | else this.sql.exec("INSERT INTO environment_variables (environment, name, value, created_at, updated_at) VALUES (?, ?, ?, ?, ?)", env.name, key, value, t, t); |
| 533 | } |
| 534 | |
| 535 | variables(envName: string): Array<{ name: string; value: string; createdAt: string; updatedAt: string }> { |
| 536 | this.environment(envName); |
| 537 | return this.sql |
| 538 | .exec<{ name: string; value: string; created_at: string; updated_at: string }>("SELECT * FROM environment_variables WHERE environment = ? ORDER BY name", envName) |
| 539 | .toArray() |
| 540 | .map((r) => ({ name: r.name, value: r.value, createdAt: r.created_at, updatedAt: r.updated_at })); |
| 541 | } |
| 542 | |
| 543 | // ---------------------------------------------------------------- workflow jobs with `environment:` |
| 544 | |
| 545 | /** |
| 546 | * Called when a job starts waiting on its environment's protection rules: records its |
| 547 | * deployment with a `waiting` status, as GitHub shows a deployment waiting for review. |
| 548 | */ |
| 549 | jobWaiting(job: { id: string; ref: string; sha: string; logUrl: string; actor: Person }, name: string): void { |
| 550 | if (this.sql.exec("SELECT 1 FROM deployments WHERE job_id = ?", job.id).toArray().length) return; |
| 551 | const env = this.ensureEnvironment(name).name; |
| 552 | const system: Caller = { ...job.actor, kind: job.actor.kind as Caller["kind"], role: "write" }; |
| 553 | const id = this.insertJobDeployment(job, env); |
| 554 | const t = now(); |
| 555 | // Not a state the REST API accepts from clients; GitHub reports it the same way for deployments waiting for review. |
| 556 | this.sql.exec( |
| 557 | "INSERT INTO deployment_statuses (deployment_id, state, description, environment, log_url, creator_id, creator_login, creator_kind, created_at) VALUES (?, 'waiting', ?, ?, ?, ?, ?, ?, ?)", |
| 558 | id, |
| 559 | "Waiting for the environment's protection rules", |
| 560 | env, |
| 561 | job.logUrl, |
| 562 | system.id, |
| 563 | system.login, |
| 564 | system.kind, |
| 565 | t, |
| 566 | ); |
| 567 | } |
| 568 | |
| 569 | private insertJobDeployment(job: { id: string; ref: string; sha: string; actor: Person }, name: string): number { |
| 570 | const t = now(); |
| 571 | const ref = job.ref.replace(/^refs\/(heads|tags)\//, ""); |
| 572 | this.sql.exec( |
| 573 | "INSERT INTO deployments (sha, ref, task, environment, original_environment, payload, production, creator_id, creator_login, creator_kind, job_id, created_at, updated_at) VALUES (?, ?, 'deploy', ?, ?, '{}', ?, ?, ?, ?, ?, ?, ?)", |
| 574 | job.sha, |
| 575 | ref, |
| 576 | name, |
| 577 | name, |
| 578 | name.toLowerCase() === "production" ? 1 : 0, |
| 579 | job.actor.id, |
| 580 | job.actor.login, |
| 581 | job.actor.kind, |
| 582 | job.id, |
| 583 | t, |
| 584 | t, |
| 585 | ); |
| 586 | const id = this.sql.exec<{ id: number }>("SELECT last_insert_rowid() AS id").one().id; |
| 587 | const system: Caller = { ...job.actor, kind: job.actor.kind as Caller["kind"], role: "write" }; |
| 588 | this.emit("deployment", "created", { deployment: { id, sha: job.sha, ref, task: "deploy", environment: name, description: null, payload: {}, created_at: t } }, system); |
| 589 | return id; |
| 590 | } |
| 591 | |
| 592 | /** |
| 593 | * Called when a job with `environment:` starts: checks the environment's |
| 594 | * branch policy, records a deployment with an in_progress status, and |
| 595 | * returns the environment's secrets and variables for the job. |
| 596 | */ |
| 597 | async jobStarted(job: { id: string; ref: string; sha: string; logUrl: string; actor: Person }, environment: { name: string; url?: string }): Promise<{ secrets: Record<string, string>; vars: Record<string, string> }> { |
| 598 | const raw = environment.name.trim(); |
| 599 | if (!ENV_NAME.test(raw)) throw new HttpError(422, `invalid environment name '${raw}'`); |
| 600 | const name = this.ensureEnvironment(raw).name; |
| 601 | if (!this.refAllowed(name, job.ref)) throw new HttpError(403, `${job.ref.replace(/^refs\/(heads|tags)\//, "")} is not allowed to deploy to ${name} due to environment protection rules.`); |
| 602 | const system: Caller = { ...job.actor, kind: job.actor.kind as Caller["kind"], role: "write" }; |
| 603 | const existing = this.sql.exec<{ id: number }>("SELECT id FROM deployments WHERE job_id = ?", job.id).toArray()[0]; |
| 604 | const environmentUrl = environment.url && /^https?:\/\//i.test(environment.url) ? environment.url : undefined; |
| 605 | if (!existing) this.createStatus(this.insertJobDeployment(job, name), { state: "in_progress", log_url: job.logUrl, environment_url: environmentUrl }, system); |
| 606 | else if (this.sql.exec<{ state: string }>("SELECT state FROM deployment_statuses WHERE deployment_id = ? ORDER BY id DESC LIMIT 1", existing.id).toArray()[0]?.state === "waiting") { |
| 607 | // Its protection rules passed: the deployment it recorded while waiting goes on. |
| 608 | this.createStatus(existing.id, { state: "in_progress", log_url: job.logUrl, environment_url: environmentUrl }, system); |
| 609 | } |
| 610 | const secrets: Record<string, string> = {}; |
| 611 | const rows = this.secretsAllowed(name, job.ref, job.actor.kind) ? this.sql.exec<{ name: string; ciphertext: string }>("SELECT name, ciphertext FROM environment_secrets WHERE environment = ?", name).toArray() : []; |
| 612 | for (const r of rows) { |
| 613 | secrets[r.name] = await decryptAndReseal(this.env, this.scope(name), r.ciphertext, (sealed) => |
| 614 | this.sql.exec("UPDATE environment_secrets SET ciphertext = ? WHERE environment = ? AND name = ? AND ciphertext = ?", sealed, name, r.name, r.ciphertext), |
| 615 | ); |
| 616 | } |
| 617 | const vars = Object.fromEntries(this.sql.exec<{ name: string; value: string }>("SELECT name, value FROM environment_variables WHERE environment = ?", name).toArray().map((r) => [r.name, r.value])); |
| 618 | return { secrets, vars }; |
| 619 | } |
| 620 | |
| 621 | /** Called when a job finishes: the deployment it recorded gets a final status. */ |
| 622 | jobCompleted(jobId: string, conclusion: string | null): void { |
| 623 | const d = this.sql.exec<DeploymentRow>("SELECT * FROM deployments WHERE job_id = ?", jobId).toArray()[0]; |
| 624 | if (!d) return; |
| 625 | const last = this.sql.exec<StatusRow>("SELECT * FROM deployment_statuses WHERE deployment_id = ? ORDER BY id DESC LIMIT 1", d.id).toArray()[0]; |
| 626 | const state = conclusion === "success" ? "success" : conclusion === "cancelled" || conclusion === "skipped" ? "inactive" : "failure"; |
| 627 | this.createStatus(d.id, { state, log_url: last?.log_url || undefined, environment_url: last?.environment_url || undefined }, { id: d.creator_id, login: d.creator_login, kind: d.creator_kind as Caller["kind"], role: "write" }); |
| 628 | } |
| 629 | } |