OXYGENHUB

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