Environments: the token names the environment once approved; a Deployments page b644e1757711
Pinned at oxygenflow a3b6ad83. A job's environment is kept on the job
(migration 0071). Its identity tokens carry an environment claim only when
the job was approved; otherwise the claim is left out and the log says why.
/{owner}/{name}/deployments shows what each environment runs (state, run,
job, who approved, when) and the history; GET /api/v1/repos/{o}/{n}/deployments
lists them; the Flow page links to it. Tests: the token's claim with and
without, the page and the API after an approved deploy.
Refs andymccall/oxygenflow#59
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-16 04:39 UTC
- Signature
- ✔ signed by Claude Code
- Hash
b644e1757711b8dc1e800847526486c53bad3094fdcc2c5c8fc8beb347282570- Depends on
- 1edac4b68c6d 380996db84e5 417184c73f4e 42fa95540468 4f6925337848 554ccacfc123 745d225de344 d1727c573f30 d23464c0ac60
- Depended on by
- b1860d6090ec e97500b70f9a 05f1a85a2842 53a0fb2e4ac6 5d90e0e045e2 47e374020b59
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 28 atoms, 2 tree operations, 11 files
Browse the tree at this change
added crates/hub/migrations/0071_flow_environments.sql +5 −0
| 1 | + | -- Environments (oxygenflow#59): the environment a job deploys to, from |
|
| 2 | + | -- its pipeline, so a repository's Deployments page can say what each one |
|
| 3 | + | -- runs. |
|
| 4 | + | ALTER TABLE flow_jobs ADD COLUMN environment TEXT NOT NULL DEFAULT ''; |
|
| 5 | + | CREATE INDEX flow_jobs_environment ON flow_jobs(environment) WHERE environment != ''; |
added templates/deployments.html +21 −0
| 1 | + | {% extends "base.html" %} |
|
| 2 | + | {% import "repo_tabs.html" as m %} |
|
| 3 | + | {% block title %}Deployments · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 4 | + | {% block tabs %}{% call m::tabs(repo, "flow") %}{% endblock %} |
|
| 5 | + | {% block content %} |
|
| 6 | + | <div class="head"><h1>Deployments <small>what each environment runs</small></h1></div> |
|
| 7 | + | {% if current.is_empty() %} |
|
| 8 | + | <p class="empty">Nothing deployed yet. A job deploys to an environment with <code>environment "prod"</code> in <a href="{{ repo.url }}/file/{{ repo.default_bookmark }}/.flow/pipeline.kdl">.flow/pipeline.kdl</a>; its identity tokens name the environment once the job is approved.</p> |
|
| 9 | + | {% else %} |
|
| 10 | + | <section class="panel"> |
|
| 11 | + | <table class="plain-table"> |
|
| 12 | + | <thead><tr><th>Environment</th><th>State</th><th>Run</th><th>Job</th><th>Approved by</th><th>When</th></tr></thead> |
|
| 13 | + | <tbody>{% for d in current %}<tr><td><strong>{{ d.environment }}</strong></td><td><a href="{{ repo.url }}/tree/{{ d.state }}"><code>{{ d.state|short }}</code></a>{% if !d.bookmark.is_empty() %} <span class="lbl lbl-bm">{{ d.bookmark }}</span>{% endif %}</td><td><a href="{{ repo.url }}/flow/{{ d.run_number }}">#{{ d.run_number }}</a></td><td>{{ d.job }}</td><td>{% if d.approved_by.is_empty() %}<span class="mute">not gated</span>{% else %}{{ d.approved_by }}{% endif %}</td><td><time title="{{ d.finished_ms|when_i }}">{{ d.finished_ms|ago_i }}</time></td></tr>{% endfor %}</tbody> |
|
| 14 | + | </table> |
|
| 15 | + | </section> |
|
| 16 | + | <section class="panel"> |
|
| 17 | + | <h2>History</h2> |
|
| 18 | + | <ul class="plain small">{% for d in history %}<li><strong>{{ d.environment }}</strong> ← <a href="{{ repo.url }}/flow/{{ d.run_number }}">run #{{ d.run_number }}</a> {{ d.job }} at <code>{{ d.state|short }}</code>{% if !d.approved_by.is_empty() %}, approved by {{ d.approved_by }}{% endif %}, started by {{ d.actor }} · <time title="{{ d.finished_ms|when_i }}">{{ d.finished_ms|ago_i }}</time></li>{% endfor %}</ul> |
|
| 19 | + | </section> |
|
| 20 | + | {% endif %} |
|
| 21 | + | {% endblock %} |
modified crates/hub/src/api/mod.rs +21 −0
| … | 141 unchanged lines |
||
| 142 | 142 | .route("/repos/{owner}/{name}/flow/runs/{number}/rerun", post(rerun_flow_run)) |
|
| 143 | 143 | .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/rerun", post(rerun_flow_job)) |
|
| 144 | 144 | .route("/repos/{owner}/{name}/flow/runs/{number}/cancel", post(cancel_flow_run)) |
|
| 145 | + | .route("/repos/{owner}/{name}/deployments", get(list_deployments)) |
|
| 145 | 146 | .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/approve", post(approve_flow_job)) |
|
| 146 | 147 | .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/reject", post(reject_flow_job)) |
|
| 147 | 148 | .route("/repos/{owner}/{name}/checks/required", get(required_checks).put(set_required_checks)) |
|
| … | 2008 unchanged lines |
||
| 2156 | 2157 | Ok(Json(serde_json::json!({ "number": number, "status": "cancelled" }))) |
|
| 2157 | 2158 | } |
|
| 2158 | 2159 | ||
| 2160 | + | /// `GET /repos/{o}/{n}/deployments`: deployments, newest first |
|
| 2161 | + | /// (oxygenflow#59). |
|
| 2162 | + | async fn list_deployments( |
|
| 2163 | + | State(app): State<Shared>, |
|
| 2164 | + | headers: HeaderMap, |
|
| 2165 | + | Path((owner, name)): Path<(String, String)>, |
|
| 2166 | + | ) -> Result<Json<Vec<serde_json::Value>>, ApiError> { |
|
| 2167 | + | let c = caller(&app, &headers).await?; |
|
| 2168 | + | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
|
| 2169 | + | let list = db::flow::deployments(&app.db, row.id, 100).await?; |
|
| 2170 | + | Ok(Json( |
|
| 2171 | + | list.into_iter() |
|
| 2172 | + | .map(|d| { |
|
| 2173 | + | serde_json::json!({ "environment": d.environment, "job": d.job, "run": d.run_number, "state": d.state, |
|
| 2174 | + | "bookmark": d.bookmark, "actor": d.actor, "approved_by": d.approved_by, "finished_ms": d.finished_ms }) |
|
| 2175 | + | }) |
|
| 2176 | + | .collect(), |
|
| 2177 | + | )) |
|
| 2178 | + | } |
|
| 2179 | + | ||
| 2159 | 2180 | /// `POST /repos/{o}/{n}/flow/runs/{number}/jobs/{job}/approve` and |
|
| 2160 | 2181 | /// `.../reject` (oxygenflow#57): the job's approval rules decide who may. |
|
| 2161 | 2182 | async fn approve_flow_job( |
|
| … | 416 unchanged lines |
modified crates/hub/src/db/flow.rs +36 −2
| … | 101 unchanged lines |
||
| 102 | 102 | /// Who approved or rejected it, and when. |
|
| 103 | 103 | pub approved_by: String, |
|
| 104 | 104 | pub approved_ms: Option<i64>, |
|
| 105 | + | /// The environment it deploys to, or empty (oxygenflow#59). |
|
| 106 | + | pub environment: String, |
|
| 105 | 107 | } |
|
| 106 | 108 | ||
| 107 | 109 | impl Job { |
|
| … | 99 unchanged lines |
||
| 207 | 209 | .await?; |
|
| 208 | 210 | for j in jobs { |
|
| 209 | 211 | sqlx::query( |
|
| 210 | - | "INSERT INTO flow_jobs (run_id, name, depth, depends, spec, sandbox, runtime, status, need_disk_mib, approval) \ |
|
| 211 | - | VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?)", |
|
| 212 | + | "INSERT INTO flow_jobs (run_id, name, depth, depends, spec, sandbox, runtime, status, need_disk_mib, approval, environment) \ |
|
| 213 | + | VALUES (?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?)", |
|
| 212 | 214 | ) |
|
| 213 | 215 | .bind(id) |
|
| 214 | 216 | .bind(&j.name) |
|
| … | 4 unchanged lines |
||
| 219 | 221 | .bind(&j.runtime) |
|
| 220 | 222 | .bind(j.need_disk_mib) |
|
| 221 | 223 | .bind(if needs_approval(&j.spec) { "waiting" } else { "" }) |
|
| 224 | + | .bind(environment_of(&j.spec)) |
|
| 222 | 225 | .execute(&mut *tx) |
|
| 223 | 226 | .await?; |
|
| 224 | 227 | } |
|
| … | 1 unchanged lines |
||
| 226 | 229 | Ok(id) |
|
| 227 | 230 | } |
|
| 228 | 231 | ||
| 232 | + | /// The environment a compiled job deploys to, or empty (oxygenflow#59). |
|
| 233 | + | fn environment_of(spec: &str) -> String { |
|
| 234 | + | serde_json::from_str::<serde_json::Value>(spec).ok().and_then(|v| v["environment"].as_str().map(String::from)).unwrap_or_default() |
|
| 235 | + | } |
|
| 236 | + | ||
| 229 | 237 | /// Whether a compiled job asks for approval (oxygenflow#57). |
|
| 230 | 238 | fn needs_approval(spec: &str) -> bool { |
|
| 231 | 239 | serde_json::from_str::<serde_json::Value>(spec).is_ok_and(|v| !v["approval"].is_null()) |
|
| … | 868 unchanged lines |
||
| 1100 | 1108 | "SELECT j.* FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id \ |
|
| 1101 | 1109 | WHERE j.status = 'queued' AND j.approval = 'waiting' AND r.status IN ('queued', 'running')", |
|
| 1102 | 1110 | ) |
|
| 1111 | + | .fetch_all(pool) |
|
| 1112 | + | .await |
|
| 1113 | + | } |
|
| 1114 | + | ||
| 1115 | + | /// One deployment: a job for an environment that passed. |
|
| 1116 | + | #[derive(Clone, Debug, FromRow)] |
|
| 1117 | + | pub struct Deployment { |
|
| 1118 | + | pub environment: String, |
|
| 1119 | + | pub job: String, |
|
| 1120 | + | pub run_number: i64, |
|
| 1121 | + | pub state: String, |
|
| 1122 | + | pub bookmark: String, |
|
| 1123 | + | pub actor: String, |
|
| 1124 | + | pub approved_by: String, |
|
| 1125 | + | pub finished_ms: i64, |
|
| 1126 | + | } |
|
| 1127 | + | ||
| 1128 | + | /// A repository's deployments, newest first (oxygenflow#59). |
|
| 1129 | + | pub async fn deployments(pool: &Pool, repo_id: i64, limit: i64) -> sqlx::Result<Vec<Deployment>> { |
|
| 1130 | + | sqlx::query_as( |
|
| 1131 | + | "SELECT j.environment, j.name AS job, r.number AS run_number, r.state, r.bookmark, r.actor, j.approved_by, \ |
|
| 1132 | + | COALESCE(j.finished_ms, 0) AS finished_ms FROM flow_jobs j JOIN flow_runs r ON r.id = j.run_id \ |
|
| 1133 | + | WHERE r.repo_id = ? AND j.environment != '' AND j.status = 'passed' ORDER BY j.finished_ms DESC LIMIT ?", |
|
| 1134 | + | ) |
|
| 1135 | + | .bind(repo_id) |
|
| 1136 | + | .bind(limit) |
|
| 1103 | 1137 | .fetch_all(pool) |
|
| 1104 | 1138 | .await |
|
| 1105 | 1139 | } |
modified crates/hub/src/flow.rs +9 −0
| … | 824 unchanged lines |
||
| 825 | 825 | let names: Vec<String> = claim.job.secrets.iter().map(|s| s.name.clone()).collect(); |
|
| 826 | 826 | let found = crate::secrets::for_job(&app, &row, &names).await?; |
|
| 827 | 827 | // The identities it asks for (#103): a token each, as a file. |
|
| 828 | + | // An environment goes in its tokens only once the job was approved |
|
| 829 | + | // (oxygenflow#59): the approval is the environment's protection. |
|
| 830 | + | let environment = claim.job.environment.clone().filter(|_| job.approval == "approved"); |
|
| 831 | + | if let (Some(e), None, false) = (&claim.job.environment, &environment, claim.job.identities.is_empty()) { |
|
| 832 | + | left_out.push(format!( |
|
| 833 | + | "environment {e}: not in the job's identity tokens, because the job was not approved (give it an approval block)" |
|
| 834 | + | )); |
|
| 835 | + | } |
|
| 828 | 836 | if !claim.job.identities.is_empty() { |
|
| 829 | 837 | let issuer = app.oidc().await.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("oidc: {e}")))?; |
|
| 830 | 838 | for i in &claim.job.identities { |
|
| … | 9 unchanged lines |
||
| 840 | 848 | state: &run.state, |
|
| 841 | 849 | runner: &runner, |
|
| 842 | 850 | actor: &run.actor, |
|
| 851 | + | environment: environment.as_deref(), |
|
| 843 | 852 | now_ms: db::now_ms(), |
|
| 844 | 853 | ttl_s: timeout_s, |
|
| 845 | 854 | }); |
|
| … | 1464 unchanged lines |
modified crates/hub/src/oidc.rs +12 −2
| … | 46 unchanged lines |
||
| 47 | 47 | pub state: &'a str, |
|
| 48 | 48 | pub runner: &'a str, |
|
| 49 | 49 | pub actor: &'a str, |
|
| 50 | + | /// The environment the job deploys to, only once it was approved |
|
| 51 | + | /// (oxygenflow#59). |
|
| 52 | + | pub environment: Option<&'a str>, |
|
| 50 | 53 | pub now_ms: i64, |
|
| 51 | 54 | pub ttl_s: u64, |
|
| 52 | 55 | } |
|
| … | 43 unchanged lines |
||
| 96 | 99 | "subject_types_supported": ["public"], |
|
| 97 | 100 | "id_token_signing_alg_values_supported": ["RS256"], |
|
| 98 | 101 | "claims_supported": ["iss", "sub", "aud", "exp", "iat", "nbf", "jti", "repository", "repository_owner", |
|
| 99 | - | "repository_visibility", "bookmark", "run_number", "job", "state", "runner", "actor"], |
|
| 102 | + | "repository_visibility", "bookmark", "run_number", "job", "state", "runner", "actor", "environment"], |
|
| 100 | 103 | "scopes_supported": ["openid"], |
|
| 101 | 104 | }) |
|
| 102 | 105 | } |
|
| … | 9 unchanged lines |
||
| 112 | 115 | let iat = c.now_ms / 1000; |
|
| 113 | 116 | let exp = iat + c.ttl_s.clamp(60, MAX_TTL_S) as i64; |
|
| 114 | 117 | let header = serde_json::json!({ "alg": "RS256", "typ": "JWT", "kid": self.kid }); |
|
| 115 | - | let payload = serde_json::json!({ |
|
| 118 | + | let mut payload = serde_json::json!({ |
|
| 116 | 119 | "iss": c.issuer, |
|
| 117 | 120 | "sub": format!("repo:{}/{}:bookmark:{}", c.owner, c.name, c.bookmark), |
|
| 118 | 121 | "aud": c.audience, |
|
| … | 11 unchanged lines |
||
| 130 | 133 | "runner": c.runner, |
|
| 131 | 134 | "actor": c.actor, |
|
| 132 | 135 | }); |
|
| 136 | + | if let Some(e) = c.environment { |
|
| 137 | + | payload["environment"] = serde_json::Value::from(e); |
|
| 138 | + | } |
|
| 133 | 139 | let signing_input = format!("{}.{}", b64url(header.to_string().as_bytes()), b64url(payload.to_string().as_bytes())); |
|
| 134 | 140 | let sig = self.signing.sign(signing_input.as_bytes()); |
|
| 135 | 141 | format!("{signing_input}.{}", b64url(&sig.to_bytes())) |
|
| … | 146 unchanged lines |
||
| 282 | 288 | state: "ab".repeat(32).leak(), |
|
| 283 | 289 | runner: "earth-1", |
|
| 284 | 290 | actor: "andy", |
|
| 291 | + | environment: Some("prod"), |
|
| 285 | 292 | now_ms: 1_800_000_000_000, |
|
| 286 | 293 | ttl_s: 900, |
|
| 287 | 294 | }; |
|
| … | 2 unchanged lines |
||
| 290 | 297 | let got = again.verify(&token, "sts.amazonaws.com", 1_800_000_500_000).expect("verifies"); |
|
| 291 | 298 | assert_eq!(got["sub"], "repo:andy/demo:bookmark:main"); |
|
| 292 | 299 | assert_eq!(got["run_number"], 7); |
|
| 300 | + | assert_eq!(got["environment"], "prod"); |
|
| 301 | + | let plain = issuer.mint(&JobClaims { environment: None, ..claims }); |
|
| 302 | + | assert!(again.verify(&plain, "sts.amazonaws.com", 1_800_000_500_000).unwrap().get("environment").is_none(), "only when approved"); |
|
| 293 | 303 | assert_eq!(got["exp"].as_i64().unwrap() - got["iat"].as_i64().unwrap(), 900); |
|
| 294 | 304 | assert!(again.verify(&token, "other", 1_800_000_500_000).is_none(), "another audience"); |
|
| 295 | 305 | assert!(again.verify(&token, "sts.amazonaws.com", 1_800_001_000_000).is_none(), "expired"); |
|
| … | 11 unchanged lines |
modified crates/hub/src/web/flow.rs +31 −0
| … | 57 unchanged lines |
||
| 58 | 58 | ||
| 59 | 59 | const PER_PAGE: i64 = 30; |
|
| 60 | 60 | ||
| 61 | + | #[derive(Template)] |
|
| 62 | + | #[template(path = "deployments.html")] |
|
| 63 | + | pub struct DeploymentsPage { |
|
| 64 | + | shell: Shell, |
|
| 65 | + | repo: RepoShell, |
|
| 66 | + | /// Each environment's newest deployment, by name. |
|
| 67 | + | current: Vec<fdb::Deployment>, |
|
| 68 | + | /// Every recent deployment, newest first. |
|
| 69 | + | history: Vec<fdb::Deployment>, |
|
| 70 | + | } |
|
| 71 | + | ||
| 72 | + | /// `GET /{owner}/{name}/deployments`: what each environment runs |
|
| 73 | + | /// (oxygenflow#59). |
|
| 74 | + | pub async fn deployments( |
|
| 75 | + | State(app): State<Shared>, |
|
| 76 | + | viewer: Viewer, |
|
| 77 | + | Path((owner, name)): Path<(String, String)>, |
|
| 78 | + | ) -> Result<Page<DeploymentsPage>, Error> { |
|
| 79 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/deployments"), &viewer); |
|
| 80 | + | let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?; |
|
| 81 | + | let history = fdb::deployments(&app.db, row.id, 50).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 82 | + | let mut current: Vec<fdb::Deployment> = Vec::new(); |
|
| 83 | + | for d in &history { |
|
| 84 | + | if !current.iter().any(|c| c.environment == d.environment) { |
|
| 85 | + | current.push(d.clone()); |
|
| 86 | + | } |
|
| 87 | + | } |
|
| 88 | + | current.sort_by(|a, b| a.environment.cmp(&b.environment)); |
|
| 89 | + | Ok(Page(DeploymentsPage { shell, repo: RepoShell::new(&app, &row, "flow").with_role(role), current, history })) |
|
| 90 | + | } |
|
| 91 | + | ||
| 61 | 92 | pub async fn runs( |
|
| 62 | 93 | State(app): State<Shared>, |
|
| 63 | 94 | viewer: Viewer, |
|
| … | 796 unchanged lines |
modified crates/hub/src/web/mod.rs +1 −0
| … | 248 unchanged lines |
||
| 249 | 249 | .route("/{owner}/{name}/coverage", get(coverage::overview)) |
|
| 250 | 250 | .route("/{owner}/{name}/coverage/badge.svg", get(coverage::badge)) |
|
| 251 | 251 | .route("/{owner}/{name}/coverage/{id}/file/{*path}", get(coverage::file)) |
|
| 252 | + | .route("/{owner}/{name}/deployments", get(flow::deployments)) |
|
| 252 | 253 | .route("/{owner}/{name}/flow/{id}", get(flow::run)) |
|
| 253 | 254 | .route("/{owner}/{name}/flow/{id}/live", get(flow::live)) |
|
| 254 | 255 | .route("/{owner}/{name}/flow/start", post(flow::start)) |
|
| … | 512 unchanged lines |
modified crates/hub/tests/flow.rs +11 −1
| … | 4267 unchanged lines |
||
| 4268 | 4268 | work.path(), |
|
| 4269 | 4269 | ".flow/pipeline.kdl", |
|
| 4270 | 4270 | &format!( |
|
| 4271 | - | "schema-version 1\nsandbox \"alpine\" {{\n rootfs \"cas://blob/{ROOTFS}\"\n}}\njob \"plan\" {{\n sandbox \"alpine\"\n exec \"plan\"\n}}\njob \"apply\" {{\n sandbox \"alpine\"\n depends-on \"plan\"\n approval {{\n by \"maintain\"\n others-only #true\n }}\n exec \"apply\"\n}}\n" |
|
| 4271 | + | "schema-version 1\nsandbox \"alpine\" {{\n rootfs \"cas://blob/{ROOTFS}\"\n}}\njob \"plan\" {{\n sandbox \"alpine\"\n exec \"plan\"\n}}\njob \"apply\" {{\n sandbox \"alpine\"\n depends-on \"plan\"\n approval {{\n by \"maintain\"\n others-only #true\n }}\n environment \"prod\"\n identity \"https://sts.example\" {{\n mount-path \"/run/secrets/cloud\"\n }}\n exec \"apply\"\n}}\n" |
|
| 4272 | 4272 | ), |
|
| 4273 | 4273 | ); |
|
| 4274 | 4274 | let mut n = 0; |
|
| … | 44 unchanged lines |
||
| 4319 | 4319 | assert_eq!(oxygenhub::flow::decide(&h.app, &row, &run, "apply", &carol, true).await.unwrap(), "apply: approved by carol"); |
|
| 4320 | 4320 | let j = claim(&h.app).await.expect("approved, it runs"); |
|
| 4321 | 4321 | assert_eq!(j["job"]["name"], "apply"); |
|
| 4322 | + | // Approved, its identity token names the environment (oxygenflow#59). |
|
| 4323 | + | let token = |
|
| 4324 | + | j["secrets"].as_array().unwrap().iter().find(|s| s["path"] == "/run/secrets/cloud").unwrap()["value"].as_str().unwrap().to_string(); |
|
| 4325 | + | let claims = h.app.oidc().await.unwrap().verify(&token, "https://sts.example", db::now_ms()).expect("a valid token"); |
|
| 4326 | + | assert_eq!(claims["environment"], "prod"); |
|
| 4322 | 4327 | let (id, ph) = (j["job_id"].as_i64().unwrap(), j["pipeline_hash"].as_str().unwrap().to_string()); |
|
| 4323 | 4328 | finish(&h.app, id, serde_json::json!({ "exit_code": 0, "duration_ms": 10, "receipt": receipt("apply", &ph, 0) })).await; |
|
| 4324 | 4329 | let audit = db::accounts::audit_rows(&h.app.db, 50).await.unwrap(); |
|
| 4325 | 4330 | assert!(audit.iter().any(|r| r.action == "flow.approved" && r.actor == "carol" && r.target.ends_with("#1 apply")), "audited"); |
|
| 4326 | 4331 | let (_, page) = call(&h.app, "GET", &format!("/andy/gated/flow/{}", run.number), None, None).await; |
|
| 4327 | 4332 | assert!(page.contains("approved by carol"), "the run says who"); |
|
| 4333 | + | let (_, page) = call(&h.app, "GET", "/andy/gated/deployments", None, None).await; |
|
| 4334 | + | assert!(page.contains("<strong>prod</strong>") && page.contains(">#1</a>") && page.contains("<td>carol</td>"), "the Deployments page"); |
|
| 4335 | + | let (_, list) = call(&h.app, "GET", "/api/v1/repos/andy/gated/deployments", Some("site"), None).await; |
|
| 4336 | + | let list: serde_json::Value = serde_json::from_str(&list).unwrap(); |
|
| 4337 | + | assert_eq!((list[0]["environment"].as_str(), list[0]["approved_by"].as_str()), (Some("prod"), Some("carol"))); |
|
| 4328 | 4338 | ||
| 4329 | 4339 | // Run 2: rejected through the API's rules: the job ends cancelled, the run ends. |
|
| 4330 | 4340 | push(); |
|
| … | 16 unchanged lines |
replaced deps/oxygenflow +0 −0
No line changes.
modified templates/flow_runs.html +1 −1
| … | 3 unchanged lines |
||
| 4 | 4 | {% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %} |
|
| 5 | 5 | {% block tabs %}{% call m::tabs(repo, "flow") %}{% endblock %} |
|
| 6 | 6 | {% block content %} |
|
| 7 | - | <div class="head"><h1>Flow <small>pipeline runs</small></h1> |
|
| 7 | + | <div class="head"><h1>Flow <small>pipeline runs · <a href="{{ repo.url }}/deployments">deployments</a></small></h1> |
|
| 8 | 8 | {% if can_run %}<form class="inline" method="post" action="{{ repo.url }}/flow/start"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><label for="run-bookmark" class="small mute">Run the pipeline of</label> <select id="run-bookmark" name="bookmark">{% for b in bookmarks %}<option value="{{ b }}">{{ b }}</option>{% endfor %}</select> <button type="submit" class="primary-btn">Run pipeline</button></form>{% endif %} |
|
| 9 | 9 | </div> |
|
| 10 | 10 | {% if let Some(w) = why_not %}<p class="small mute">{{ w }}</p>{% endif %} |
|
| … | 42 unchanged lines |