Variables for pipelines at the site, organisation and repository 2a83f5303458
Each level holds NAME=value pairs under Variables on its settings page, showing what it inherits and what it overrides; a claim carries the repository's effective set as env, which Flow adds to the job's environment under FLOW_* and the pipeline's own (oxygenflow pin moved). Not for secrets, which Flow mounts. The three levels now share crate::levels. closes #102
- Author
- Andy McCall @andymccall <[email protected]>
- When
- · 2026-10-04 16:02 UTC
- Signature
- ✔ signed by Andy McCall
- Hash
2a83f5303458253ca676b38c479a96a8469bc39723ac7b74e0da95b8fcfcb673- Depends on
- 19bb31ff1f5c 3556d08e17f9 380996db84e5 42fa95540468 5e6d6484c28e 64caed18236a 749d9a5fcd9f 7974adb0be66 7d2eb590051f 8fb35df7d66e 9008bf6c2b8c 93be9ce4653e 9a065bc0ce33 a3c8288b0141 a6c241e8eba0 b5668108487f b64e2537d702 b6b7e4cf6b24 c889911bec12 cc19697f8871 d23464c0ac60 e71846aa07b9
- Depended on by
- 737de0fac722 c7067c7bda56 e57765dee61a 965b950f9b96 c8684d44d549 5d90e0e045e2 3dd6582de060 aa0bfbd11f02 a46d15e72101 67040b158ae2 36e5a9c15a84 8ddebaa7e856 1f78655a0466 155cab142b63 e8e7158bbc73 efd29a0033e8 83940d53664f 89e89836cae7 4ac01a732552
- In bookmarks
- api-unknown-fields cancel-landed-runs clear-waiting close-proposal-140 diffusion-pin-bookmark draft-ready image-check job-waits main oh-comment-body-file proposal-list-fast run-one-job wiki-says-so
- In tags
- v0.4.1 v0.4.2 v0.5.0 v0.5.1 v0.5.2 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
- 53 atoms, 5 tree operations, 21 files
Browse the tree at this change
added crates/hub/migrations/0037_flow_variables.sql +13 −0
| 1 | + | -- Variables for OxygenFlow jobs (hub spec §13.2, #102), per level: the |
|
| 2 | + | -- site (scope_id 0), an organisation or a repository. A job gets its |
|
| 3 | + | -- repository's effective set, inner levels overriding outer. Not secrets: |
|
| 4 | + | -- those are mounted by Flow (OxygenFlow spec §9), never put in the |
|
| 5 | + | -- environment. |
|
| 6 | + | CREATE TABLE flow_variables ( |
|
| 7 | + | scope TEXT NOT NULL CHECK (scope IN ('site', 'org', 'repo')), |
|
| 8 | + | scope_id INTEGER NOT NULL DEFAULT 0, |
|
| 9 | + | name TEXT NOT NULL, |
|
| 10 | + | value TEXT NOT NULL, |
|
| 11 | + | updated_ms INTEGER NOT NULL, |
|
| 12 | + | PRIMARY KEY (scope, scope_id, name) |
|
| 13 | + | ); |
added crates/hub/src/levels.rs +70 −0
| 1 | + | //! The three levels settings live at, site, organisation and repository, |
|
| 2 | + | //! and how a repository sees the levels above it. Shared by AI assistants |
|
| 3 | + | //! (§13.4) and Flow variables (§13.2). |
|
| 4 | + | ||
| 5 | + | use crate::db::Pool; |
|
| 6 | + | ||
| 7 | + | /// Which level a setting belongs to. |
|
| 8 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| 9 | + | pub enum Scope { |
|
| 10 | + | Site, |
|
| 11 | + | Org(i64), |
|
| 12 | + | Repo(i64), |
|
| 13 | + | } |
|
| 14 | + | ||
| 15 | + | impl Scope { |
|
| 16 | + | pub fn kind(self) -> &'static str { |
|
| 17 | + | match self { |
|
| 18 | + | Scope::Site => "site", |
|
| 19 | + | Scope::Org(_) => "org", |
|
| 20 | + | Scope::Repo(_) => "repo", |
|
| 21 | + | } |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | pub fn id(self) -> i64 { |
|
| 25 | + | match self { |
|
| 26 | + | Scope::Site => 0, |
|
| 27 | + | Scope::Org(id) | Scope::Repo(id) => id, |
|
| 28 | + | } |
|
| 29 | + | } |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | /// A level as a settings page addresses it. |
|
| 33 | + | pub struct At { |
|
| 34 | + | pub scope: Scope, |
|
| 35 | + | /// The owner name, so a repository finds its organisation. |
|
| 36 | + | pub owner: String, |
|
| 37 | + | /// What the audit log names. |
|
| 38 | + | pub label: String, |
|
| 39 | + | } |
|
| 40 | + | ||
| 41 | + | impl At { |
|
| 42 | + | pub fn site() -> At { |
|
| 43 | + | At { scope: Scope::Site, owner: String::new(), label: "site".into() } |
|
| 44 | + | } |
|
| 45 | + | ||
| 46 | + | pub fn org(org: &crate::db::accounts::Organisation) -> At { |
|
| 47 | + | At { scope: Scope::Org(org.id), owner: org.name.clone(), label: org.name.clone() } |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | pub fn repo(row: &crate::db::repos::RepoRow) -> At { |
|
| 51 | + | At { scope: Scope::Repo(row.id), owner: row.owner.clone(), label: format!("{}/{}", row.owner, row.name) } |
|
| 52 | + | } |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | /// The levels above a scope, outermost first: the site, then the |
|
| 56 | + | /// organisation when the owner is one. |
|
| 57 | + | pub async fn above(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<(Scope, String)>> { |
|
| 58 | + | let mut out = vec![]; |
|
| 59 | + | match scope { |
|
| 60 | + | Scope::Site => {} |
|
| 61 | + | Scope::Org(_) => out.push((Scope::Site, "the site".to_string())), |
|
| 62 | + | Scope::Repo(_) => { |
|
| 63 | + | out.push((Scope::Site, "the site".to_string())); |
|
| 64 | + | if let Some(org) = crate::db::accounts::org_by_name(pool, owner).await? { |
|
| 65 | + | out.push((Scope::Org(org.id), format!("the {} organisation", org.name))); |
|
| 66 | + | } |
|
| 67 | + | } |
|
| 68 | + | } |
|
| 69 | + | Ok(out) |
|
| 70 | + | } |
added crates/hub/src/variables.rs +130 −0
| 1 | + | //! Variables for OxygenFlow jobs (hub spec §13.2, oxygenhub#102): plain |
|
| 2 | + | //! `NAME=value` pairs kept per site, organisation and repository. A job |
|
| 3 | + | //! gets its repository's effective set, inner levels overriding outer, as |
|
| 4 | + | //! the claim's `env`; Flow sets them under `FLOW_*` and the pipeline's |
|
| 5 | + | //! own `env`. They are not secrets and are shown in full on the settings |
|
| 6 | + | //! pages; secrets are mounted by Flow (OxygenFlow spec §9). |
|
| 7 | + | ||
| 8 | + | use crate::db::{now_ms, Pool}; |
|
| 9 | + | use crate::levels::{self, Scope}; |
|
| 10 | + | use sqlx::FromRow; |
|
| 11 | + | use std::collections::BTreeMap; |
|
| 12 | + | ||
| 13 | + | /// How many a level may hold. |
|
| 14 | + | pub const MAX_PER_LEVEL: usize = 100; |
|
| 15 | + | /// The longest value. |
|
| 16 | + | pub const MAX_VALUE: usize = 4096; |
|
| 17 | + | ||
| 18 | + | #[derive(Clone, Debug, FromRow)] |
|
| 19 | + | pub struct Variable { |
|
| 20 | + | pub scope: String, |
|
| 21 | + | pub scope_id: i64, |
|
| 22 | + | pub name: String, |
|
| 23 | + | pub value: String, |
|
| 24 | + | pub updated_ms: i64, |
|
| 25 | + | } |
|
| 26 | + | ||
| 27 | + | /// A variable's name: a letter or `_`, then letters, digits and `_`, up |
|
| 28 | + | /// to 64; never `FLOW_*`, which Flow sets itself. |
|
| 29 | + | pub fn name_ok(name: &str) -> bool { |
|
| 30 | + | let mut chars = name.chars(); |
|
| 31 | + | let first = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_'); |
|
| 32 | + | first && name.len() <= 64 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_') && !name.starts_with("FLOW_") |
|
| 33 | + | } |
|
| 34 | + | ||
| 35 | + | /// A value: up to 4096 characters, no control characters but newline and tab. |
|
| 36 | + | pub fn value_ok(value: &str) -> bool { |
|
| 37 | + | value.len() <= MAX_VALUE && !value.chars().any(|c| c.is_control() && c != '\n' && c != '\t') |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | pub async fn list(pool: &Pool, scope: Scope) -> sqlx::Result<Vec<Variable>> { |
|
| 41 | + | sqlx::query_as("SELECT * FROM flow_variables WHERE scope = ? AND scope_id = ? ORDER BY name") |
|
| 42 | + | .bind(scope.kind()) |
|
| 43 | + | .bind(scope.id()) |
|
| 44 | + | .fetch_all(pool) |
|
| 45 | + | .await |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | /// Set a variable at a level, new or changed. |
|
| 49 | + | pub async fn set(pool: &Pool, scope: Scope, name: &str, value: &str) -> sqlx::Result<()> { |
|
| 50 | + | sqlx::query( |
|
| 51 | + | "INSERT INTO flow_variables (scope, scope_id, name, value, updated_ms) VALUES (?, ?, ?, ?, ?) \ |
|
| 52 | + | ON CONFLICT (scope, scope_id, name) DO UPDATE SET value = excluded.value, updated_ms = excluded.updated_ms", |
|
| 53 | + | ) |
|
| 54 | + | .bind(scope.kind()) |
|
| 55 | + | .bind(scope.id()) |
|
| 56 | + | .bind(name) |
|
| 57 | + | .bind(value) |
|
| 58 | + | .bind(now_ms()) |
|
| 59 | + | .execute(pool) |
|
| 60 | + | .await?; |
|
| 61 | + | Ok(()) |
|
| 62 | + | } |
|
| 63 | + | ||
| 64 | + | /// Remove a variable from a level; false when it was not there. |
|
| 65 | + | pub async fn delete(pool: &Pool, scope: Scope, name: &str) -> sqlx::Result<bool> { |
|
| 66 | + | let r = sqlx::query("DELETE FROM flow_variables WHERE scope = ? AND scope_id = ? AND name = ?") |
|
| 67 | + | .bind(scope.kind()) |
|
| 68 | + | .bind(scope.id()) |
|
| 69 | + | .bind(name) |
|
| 70 | + | .execute(pool) |
|
| 71 | + | .await?; |
|
| 72 | + | Ok(r.rows_affected() == 1) |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | /// Forget a level's variables, when the organisation or repository goes. |
|
| 76 | + | pub async fn delete_scope(pool: &Pool, scope: Scope) -> sqlx::Result<()> { |
|
| 77 | + | sqlx::query("DELETE FROM flow_variables WHERE scope = ? AND scope_id = ?").bind(scope.kind()).bind(scope.id()).execute(pool).await?; |
|
| 78 | + | Ok(()) |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | /// A variable as a level sees it: where it comes from, and whether the |
|
| 82 | + | /// level's own value hides it. |
|
| 83 | + | #[derive(Clone, Debug)] |
|
| 84 | + | pub struct Seen { |
|
| 85 | + | pub variable: Variable, |
|
| 86 | + | /// `the site`, `the acme organisation`, or empty for the level's own. |
|
| 87 | + | pub from: String, |
|
| 88 | + | /// An inner level sets the same name, so this value is not the one used. |
|
| 89 | + | pub overridden: bool, |
|
| 90 | + | } |
|
| 91 | + | ||
| 92 | + | /// Every variable a level sees, outermost first, inherited ones marked |
|
| 93 | + | /// overridden where an inner level sets the same name. |
|
| 94 | + | pub async fn seen(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<Seen>> { |
|
| 95 | + | let mut lvls = levels::above(pool, scope, owner).await?; |
|
| 96 | + | lvls.push((scope, String::new())); |
|
| 97 | + | let mut out: Vec<Seen> = vec![]; |
|
| 98 | + | for (level, from) in lvls { |
|
| 99 | + | for v in list(pool, level).await? { |
|
| 100 | + | for o in out.iter_mut().filter(|o| o.variable.name == v.name) { |
|
| 101 | + | o.overridden = true; |
|
| 102 | + | } |
|
| 103 | + | out.push(Seen { variable: v, from: from.clone(), overridden: false }); |
|
| 104 | + | } |
|
| 105 | + | } |
|
| 106 | + | Ok(out) |
|
| 107 | + | } |
|
| 108 | + | ||
| 109 | + | /// The variables a repository's jobs get. |
|
| 110 | + | pub async fn env_for_repo(pool: &Pool, repo: &crate::db::repos::RepoRow) -> sqlx::Result<BTreeMap<String, String>> { |
|
| 111 | + | Ok(seen(pool, Scope::Repo(repo.id), &repo.owner) |
|
| 112 | + | .await? |
|
| 113 | + | .into_iter() |
|
| 114 | + | .filter(|s| !s.overridden) |
|
| 115 | + | .map(|s| (s.variable.name, s.variable.value)) |
|
| 116 | + | .collect()) |
|
| 117 | + | } |
|
| 118 | + | ||
| 119 | + | #[cfg(test)] |
|
| 120 | + | mod tests { |
|
| 121 | + | use super::*; |
|
| 122 | + | ||
| 123 | + | #[test] |
|
| 124 | + | fn names_and_values_are_checked() { |
|
| 125 | + | assert!(name_ok("CC") && name_ok("_x") && name_ok("RUST_LOG")); |
|
| 126 | + | assert!(!name_ok("") && !name_ok("1A") && !name_ok("A-B") && !name_ok("FLOW_REPO") && !name_ok(&"A".repeat(65))); |
|
| 127 | + | assert!(value_ok("clang") && value_ok("two\nlines\twith tab") && value_ok("")); |
|
| 128 | + | assert!(!value_ok("a\u{0}b") && !value_ok(&"x".repeat(MAX_VALUE + 1))); |
|
| 129 | + | } |
|
| 130 | + | } |
added crates/hub/src/web/variables.rs +69 −0
| 1 | + | //! The Flow variables panel (hub spec §13.2, oxygenhub#102) shared by |
|
| 2 | + | //! the administration, organisation and repository settings pages: the |
|
| 3 | + | //! level's own variables and the ones it inherits. |
|
| 4 | + | ||
| 5 | + | use crate::auth::Viewer; |
|
| 6 | + | use crate::db::accounts; |
|
| 7 | + | use crate::levels::At; |
|
| 8 | + | use crate::variables::{self, Seen}; |
|
| 9 | + | use crate::Shared; |
|
| 10 | + | use serde::Deserialize; |
|
| 11 | + | ||
| 12 | + | #[derive(Deserialize)] |
|
| 13 | + | pub struct VariableForm { |
|
| 14 | + | #[serde(rename = "_csrf")] |
|
| 15 | + | pub csrf_token: String, |
|
| 16 | + | #[serde(default)] |
|
| 17 | + | action: String, |
|
| 18 | + | #[serde(default)] |
|
| 19 | + | name: String, |
|
| 20 | + | #[serde(default)] |
|
| 21 | + | value: String, |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | /// Everything the panel shows. |
|
| 25 | + | pub struct Panel { |
|
| 26 | + | /// The level's own variables. |
|
| 27 | + | pub own: Vec<Seen>, |
|
| 28 | + | /// The ones the levels above provide, outermost first. |
|
| 29 | + | pub inherited: Vec<Seen>, |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | pub async fn panel(app: &Shared, at: &At) -> sqlx::Result<Panel> { |
|
| 33 | + | let (own, inherited): (Vec<_>, Vec<_>) = |
|
| 34 | + | variables::seen(&app.db, at.scope, &at.owner).await?.into_iter().partition(|v| v.from.is_empty()); |
|
| 35 | + | Ok(Panel { own, inherited }) |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | /// Apply the panel's form: `Ok(notice)` or `Err(message for the user)`. |
|
| 39 | + | pub async fn apply(app: &Shared, viewer: &Viewer, at: &At, f: VariableForm) -> sqlx::Result<Result<String, String>> { |
|
| 40 | + | let name = f.name.trim(); |
|
| 41 | + | if !variables::name_ok(name) { |
|
| 42 | + | return Ok(Err( |
|
| 43 | + | "A variable's name is a letter or _, then letters, digits and _, up to 64, and not FLOW_… (Flow sets those).".into() |
|
| 44 | + | )); |
|
| 45 | + | } |
|
| 46 | + | Ok(Ok(match f.action.as_str() { |
|
| 47 | + | "set" => { |
|
| 48 | + | let value = f.value.replace("\r\n", "\n"); |
|
| 49 | + | if !variables::value_ok(&value) { |
|
| 50 | + | return Ok(Err(format!("A value is up to {} characters of text.", variables::MAX_VALUE))); |
|
| 51 | + | } |
|
| 52 | + | let own = variables::list(&app.db, at.scope).await?; |
|
| 53 | + | if own.len() >= variables::MAX_PER_LEVEL && !own.iter().any(|v| v.name == name) { |
|
| 54 | + | return Ok(Err(format!("Up to {} variables here.", variables::MAX_PER_LEVEL))); |
|
| 55 | + | } |
|
| 56 | + | variables::set(&app.db, at.scope, name, &value).await?; |
|
| 57 | + | accounts::audit(&app.db, viewer.account(), "flow.variable.set", &at.label, name).await.ok(); |
|
| 58 | + | format!("{name} set.") |
|
| 59 | + | } |
|
| 60 | + | "delete" => { |
|
| 61 | + | if !variables::delete(&app.db, at.scope, name).await? { |
|
| 62 | + | return Ok(Err(format!("There is no variable {name} here."))); |
|
| 63 | + | } |
|
| 64 | + | accounts::audit(&app.db, viewer.account(), "flow.variable.deleted", &at.label, name).await.ok(); |
|
| 65 | + | format!("{name} removed.") |
|
| 66 | + | } |
|
| 67 | + | _ => return Ok(Err("Unknown action.".into())), |
|
| 68 | + | })) |
|
| 69 | + | } |
added templates/variables_panel.html +27 −0
| 1 | + | {% macro panel(base, v, csrf, what) %} |
|
| 2 | + | <section class="panel" id="variables"> |
|
| 3 | + | <h2>Variables</h2> |
|
| 4 | + | <p class="small mute">{{ what }} Jobs get them in their environment under <code>FLOW_*</code> and the pipeline's own <code>env</code>; they are part of each job's receipt. Not for secrets: these are shown in full to everyone who can see this page.</p> |
|
| 5 | + | {% if !v.inherited.is_empty() %} |
|
| 6 | + | <h3>Inherited</h3> |
|
| 7 | + | <div class="table"><table> |
|
| 8 | + | <tr><th>Name</th><th>Value</th><th>From</th></tr> |
|
| 9 | + | {% for i in v.inherited %}<tr{% if i.overridden %} class="mute"{% endif %}><td><code>{{ i.variable.name }}</code></td><td class="small"><code>{{ i.variable.value }}</code></td><td class="small">{{ i.from }}{% if i.overridden %} · <span title="A level below sets the same name">overridden</span>{% endif %}</td></tr>{% endfor %} |
|
| 10 | + | </table></div> |
|
| 11 | + | {% endif %} |
|
| 12 | + | {% if v.own.is_empty() %}<p class="mute small">{% if v.inherited.is_empty() %}None.{% else %}None of its own.{% endif %}</p>{% else %} |
|
| 13 | + | <div class="table"><table> |
|
| 14 | + | <tr><th>Name</th><th>Value</th><th></th></tr> |
|
| 15 | + | {% for o in v.own %}<tr><td><code>{{ o.variable.name }}</code></td><td class="small"><code>{{ o.variable.value }}</code></td> |
|
| 16 | + | <td><form class="tiny" method="post" action="{{ base }}/variables"><input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="name" value="{{ o.variable.name }}"><button name="action" value="delete" class="danger-btn">remove</button></form></td></tr>{% endfor %} |
|
| 17 | + | </table></div>{% endif %} |
|
| 18 | + | <form class="card" method="post" action="{{ base }}/variables"> |
|
| 19 | + | <input type="hidden" name="_csrf" value="{{ csrf }}"><input type="hidden" name="action" value="set"> |
|
| 20 | + | <h3>Set a variable</h3> |
|
| 21 | + | <label for="vname">Name</label><input type="text" id="vname" name="name" placeholder="RUSTFLAGS" required> |
|
| 22 | + | <label for="vvalue">Value</label><textarea id="vvalue" name="value" rows="2"></textarea> |
|
| 23 | + | <p class="small mute">An existing name takes the new value; a name set above is overridden here.</p> |
|
| 24 | + | <button type="submit">SET</button> |
|
| 25 | + | </form> |
|
| 26 | + | </section> |
|
| 27 | + | {% endmacro %} |
modified CHANGELOG.md +2 −0
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | ## Unreleased |
|
| 6 | 6 | ||
| 7 | + | - **Variables for pipelines (#102).** Administration, organisation and repository settings hold variables for OxygenFlow jobs, inner levels overriding outer, each page showing what it inherits; a claim carries the repository's effective set and Flow adds them to the job's environment under `FLOW_*` and the pipeline's own. Not for secrets, which Flow mounts. |
|
| 8 | + | ||
| 7 | 9 | - **AI assistants, step three: drafted descriptions (#97).** The new-proposal form and an open proposal's editor have a draft-with button per assistant; the answer, written from the changes and diff, lands in the editor for the person to change before saving. |
|
| 8 | 10 | ||
| 9 | 11 | - **AI assistants, step two: review requests (#97).** Under Reviewers, the author or a writer asks an assistant the repository may use; the hub sends it the proposal's title, description, changes and diff with the site, organisation and repository prompts, and records the answer as a comment review via the assistant, shown as "bob ↳ agent Claude". Approvals count only people, and an assistant's answer never replaces the asker's own verdict. Also `POST …/proposals/{n}/review-requests` in the API, with `via` on reviews. |
|
| … | 95 unchanged lines |
modified crates/hub/src/assistants.rs +3 −43
| … | 26 unchanged lines |
||
| 27 | 27 | /// The most an answer is kept. |
|
| 28 | 28 | const MAX_ANSWER: usize = 64 * 1024; |
|
| 29 | 29 | ||
| 30 | - | /// Which level an assistant or a prompt belongs to. |
|
| 31 | - | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
|
| 32 | - | pub enum Scope { |
|
| 33 | - | Site, |
|
| 34 | - | Org(i64), |
|
| 35 | - | Repo(i64), |
|
| 36 | - | } |
|
| 37 | - | ||
| 38 | - | impl Scope { |
|
| 39 | - | pub fn kind(self) -> &'static str { |
|
| 40 | - | match self { |
|
| 41 | - | Scope::Site => "site", |
|
| 42 | - | Scope::Org(_) => "org", |
|
| 43 | - | Scope::Repo(_) => "repo", |
|
| 44 | - | } |
|
| 45 | - | } |
|
| 46 | - | ||
| 47 | - | pub fn id(self) -> i64 { |
|
| 48 | - | match self { |
|
| 49 | - | Scope::Site => 0, |
|
| 50 | - | Scope::Org(id) | Scope::Repo(id) => id, |
|
| 51 | - | } |
|
| 52 | - | } |
|
| 53 | - | } |
|
| 30 | + | pub use crate::levels::Scope; |
|
| 54 | 31 | ||
| 55 | 32 | #[derive(Clone, Debug, FromRow)] |
|
| 56 | 33 | pub struct Assistant { |
|
| … | 257 unchanged lines |
||
| 314 | 291 | } |
|
| 315 | 292 | ||
| 316 | 293 | // ----------------------------------------------------- what applies where |
|
| 317 | - | ||
| 318 | - | /// The levels above a scope, outermost first: the site, then the |
|
| 319 | - | /// organisation when the owner is one. |
|
| 320 | - | pub async fn levels_above(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<(Scope, String)>> { |
|
| 321 | - | let mut out = vec![]; |
|
| 322 | - | match scope { |
|
| 323 | - | Scope::Site => {} |
|
| 324 | - | Scope::Org(_) => out.push((Scope::Site, "the site".to_string())), |
|
| 325 | - | Scope::Repo(_) => { |
|
| 326 | - | out.push((Scope::Site, "the site".to_string())); |
|
| 327 | - | if let Some(org) = crate::db::accounts::org_by_name(pool, owner).await? { |
|
| 328 | - | out.push((Scope::Org(org.id), format!("the {} organisation", org.name))); |
|
| 329 | - | } |
|
| 330 | - | } |
|
| 331 | - | } |
|
| 332 | - | Ok(out) |
|
| 333 | - | } |
|
| 334 | 294 | ||
| 335 | 295 | /// An assistant seen from a level: where it comes from, and whether it is |
|
| 336 | 296 | /// on once outer levels have had their say. |
|
| … | 16 unchanged lines |
||
| 353 | 313 | /// innermost definition of a name wins, and a name disabled at an outer |
|
| 354 | 314 | /// level is off inside it whatever the inner level says. |
|
| 355 | 315 | pub async fn available(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<Available>> { |
|
| 356 | - | let mut levels = levels_above(pool, scope, owner).await?; |
|
| 316 | + | let mut levels = crate::levels::above(pool, scope, owner).await?; |
|
| 357 | 317 | levels.push((scope, String::new())); |
|
| 358 | 318 | let mut out: Vec<Available> = vec![]; |
|
| 359 | 319 | let mut off: Vec<String> = vec![]; |
|
| … | 19 unchanged lines |
||
| 379 | 339 | /// only the levels that set one. |
|
| 380 | 340 | pub async fn inherited_prompts(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<(String, String)>> { |
|
| 381 | 341 | let mut out = vec![]; |
|
| 382 | - | for (level, from) in levels_above(pool, scope, owner).await? { |
|
| 342 | + | for (level, from) in crate::levels::above(pool, scope, owner).await? { |
|
| 383 | 343 | let text = prompt(pool, level).await?; |
|
| 384 | 344 | if !text.is_empty() { |
|
| 385 | 345 | out.push((from, text)); |
|
| … | 406 unchanged lines |
modified crates/hub/src/flow.rs +2 −0
| … | 503 unchanged lines |
||
| 504 | 504 | state: run.state.clone(), |
|
| 505 | 505 | bookmark: run.bookmark.clone(), |
|
| 506 | 506 | run_number: run.number, |
|
| 507 | + | // The site's, the owner's and the repository's variables (#102). |
|
| 508 | + | env: crate::variables::env_for_repo(&app.db, &row).await?, |
|
| 507 | 509 | }; |
|
| 508 | 510 | let mut claim = claim; |
|
| 509 | 511 | let (inputs, ids) = subrepo_inputs(&app, &row, &run).await; |
|
| … | 541 unchanged lines |
modified crates/hub/src/lib.rs +2 −0
| … | 12 unchanged lines |
||
| 13 | 13 | pub mod flow; |
|
| 14 | 14 | pub mod hooks; |
|
| 15 | 15 | pub mod keys; |
|
| 16 | + | pub mod levels; |
|
| 16 | 17 | pub mod live; |
|
| 17 | 18 | pub mod mail; |
|
| 18 | 19 | pub mod maintenance; |
|
| … | 2 unchanged lines |
||
| 21 | 22 | pub mod protocol; |
|
| 22 | 23 | pub mod render; |
|
| 23 | 24 | pub mod repos; |
|
| 25 | + | pub mod variables; |
|
| 24 | 26 | pub mod web; |
|
| 25 | 27 | ||
| 26 | 28 | use crate::config::Config; |
|
| … | 103 unchanged lines |
modified crates/hub/src/web/admin.rs +19 −2
| … | 29 unchanged lines |
||
| 30 | 30 | my_email: String, |
|
| 31 | 31 | /// The site's AI assistants and prompt (#97). |
|
| 32 | 32 | assistants: super::assistants::Panel, |
|
| 33 | + | /// The site's Flow variables (#102). |
|
| 34 | + | variables: super::variables::Panel, |
|
| 33 | 35 | } |
|
| 34 | 36 | ||
| 35 | 37 | fn require_admin(viewer: &Viewer, shell: &Shell) -> Result<accounts::Account, Error> { |
|
| … | 17 unchanged lines |
||
| 53 | 55 | None => String::new(), |
|
| 54 | 56 | }; |
|
| 55 | 57 | let assistants = super::assistants::panel(app, &site_assistants()).await.map_err(e)?; |
|
| 58 | + | let variables = super::variables::panel(app, &site_assistants()).await.map_err(e)?; |
|
| 56 | 59 | Ok(Page(AdminPage { |
|
| 57 | 60 | assistants, |
|
| 61 | + | variables, |
|
| 58 | 62 | shell, |
|
| 59 | 63 | accounts: accounts_list, |
|
| 60 | 64 | orgs, |
|
| … | 305 unchanged lines |
||
| 366 | 370 | storage_page(&app, shell, Some(summary), error).await |
|
| 367 | 371 | } |
|
| 368 | 372 | ||
| 369 | - | fn site_assistants() -> super::assistants::Where { |
|
| 370 | - | super::assistants::Where { scope: crate::assistants::Scope::Site, owner: String::new(), label: "site".into() } |
|
| 373 | + | fn site_assistants() -> crate::levels::At { |
|
| 374 | + | crate::levels::At::site() |
|
| 371 | 375 | } |
|
| 372 | 376 | ||
| 373 | 377 | /// `POST /admin/assistants`: the site's assistants and prompt (#97). |
|
| … | 8 unchanged lines |
||
| 382 | 386 | let outcome = super::assistants::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 383 | 387 | admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await |
|
| 384 | 388 | } |
|
| 389 | + | ||
| 390 | + | /// `POST /admin/variables`: the site's Flow variables (#102). |
|
| 391 | + | pub async fn variables( |
|
| 392 | + | State(app): State<Shared>, |
|
| 393 | + | viewer: Viewer, |
|
| 394 | + | Form(f): Form<super::variables::VariableForm>, |
|
| 395 | + | ) -> Result<Page<AdminPage>, Error> { |
|
| 396 | + | let shell = Shell::new(&app, "/admin", &viewer); |
|
| 397 | + | require_admin(&viewer, &shell)?; |
|
| 398 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 399 | + | let outcome = super::variables::apply(&app, &viewer, &site_assistants(), f).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 400 | + | admin_page(&app, shell, Some(outcome.unwrap_or_else(|e| e))).await |
|
| 401 | + | } |
modified crates/hub/src/web/assistants.rs +4 −12
| … | 1 unchanged lines |
||
| 2 | 2 | //! administration, organisation and repository settings pages: the |
|
| 3 | 3 | //! level's own assistants, the ones it inherits, and its prompt. |
|
| 4 | 4 | ||
| 5 | - | use crate::assistants::{self, Assistant, Available, Fields, Scope, APIS, DEFAULT_BUDGET}; |
|
| 5 | + | use crate::assistants::{self, Assistant, Available, Fields, APIS, DEFAULT_BUDGET}; |
|
| 6 | 6 | use crate::auth::Viewer; |
|
| 7 | 7 | use crate::db::accounts; |
|
| 8 | + | use crate::levels::At; |
|
| 8 | 9 | use crate::Shared; |
|
| 9 | 10 | use serde::Deserialize; |
|
| 10 | 11 | ||
| 11 | - | /// Whose panel. |
|
| 12 | - | pub struct Where { |
|
| 13 | - | pub scope: Scope, |
|
| 14 | - | /// The owner name, so a repository finds its organisation. |
|
| 15 | - | pub owner: String, |
|
| 16 | - | /// What the audit log names. |
|
| 17 | - | pub label: String, |
|
| 18 | - | } |
|
| 19 | - | ||
| 20 | 12 | #[derive(Deserialize)] |
|
| 21 | 13 | pub struct AssistantForm { |
|
| 22 | 14 | #[serde(rename = "_csrf")] |
|
| … | 36 unchanged lines |
||
| 59 | 51 | pub default_budget: i64, |
|
| 60 | 52 | } |
|
| 61 | 53 | ||
| 62 | - | pub async fn panel(app: &Shared, at: &Where) -> sqlx::Result<Panel> { |
|
| 54 | + | pub async fn panel(app: &Shared, at: &At) -> sqlx::Result<Panel> { |
|
| 63 | 55 | let (own, inherited): (Vec<_>, Vec<_>) = |
|
| 64 | 56 | assistants::available(&app.db, at.scope, &at.owner).await?.into_iter().partition(|a| a.from.is_empty()); |
|
| 65 | 57 | Ok(Panel { |
|
| … | 50 unchanged lines |
||
| 116 | 108 | } |
|
| 117 | 109 | ||
| 118 | 110 | /// Apply the panel's form: `Ok(notice)` or `Err(message for the user)`. |
|
| 119 | - | pub async fn apply(app: &Shared, viewer: &Viewer, at: &Where, f: AssistantForm) -> sqlx::Result<Result<String, String>> { |
|
| 111 | + | pub async fn apply(app: &Shared, viewer: &Viewer, at: &At, f: AssistantForm) -> sqlx::Result<Result<String, String>> { |
|
| 120 | 112 | let owned = |a: Option<Assistant>| a.filter(|a| a.scope() == at.scope); |
|
| 121 | 113 | let actor = viewer.account(); |
|
| 122 | 114 | Ok(Ok(match (f.action.as_str(), f.id) { |
|
| … | 64 unchanged lines |
modified crates/hub/src/web/mod.rs +4 −0
| … | 30 unchanged lines |
||
| 31 | 31 | mod session; |
|
| 32 | 32 | pub(crate) mod settings; |
|
| 33 | 33 | mod statics; |
|
| 34 | + | mod variables; |
|
| 34 | 35 | mod webhooks; |
|
| 35 | 36 | ||
| 36 | 37 | use crate::auth::{self, Access, Role, Viewer}; |
|
| … | 47 unchanged lines |
||
| 84 | 85 | .route("/orgs/{name}/settings/teams", post(orgs::team)) |
|
| 85 | 86 | .route("/orgs/{name}/settings/hooks", post(orgs::hooks)) |
|
| 86 | 87 | .route("/orgs/{name}/settings/assistants", post(orgs::assistants)) |
|
| 88 | + | .route("/orgs/{name}/settings/variables", post(orgs::variables)) |
|
| 87 | 89 | .route("/orgs/{name}/settings/runners/token", post(orgs::runner_token)) |
|
| 88 | 90 | .route("/orgs/{name}/settings/runners/{id}/remove", post(orgs::runner_remove)) |
|
| 89 | 91 | .route("/orgs/{name}/settings/hooks/{id}/deliveries", get(orgs::deliveries)) |
|
| … | 19 unchanged lines |
||
| 109 | 111 | .route("/admin/mail", post(admin::set_mail)) |
|
| 110 | 112 | .route("/admin/mail/test", post(admin::test_mail)) |
|
| 111 | 113 | .route("/admin/assistants", post(admin::assistants)) |
|
| 114 | + | .route("/admin/variables", post(admin::variables)) |
|
| 112 | 115 | .route("/admin/runners/{id}/remove", post(runners::remove)) |
|
| 113 | 116 | .nest("/api/v1", crate::api::router(app.config.limits.asset_limit.0 as usize)) |
|
| 114 | 117 | .route("/{owner}", get(home::owner)) |
|
| … | 54 unchanged lines |
||
| 169 | 172 | .route("/{owner}/{name}/proposal/{number}/events", get(proposals::events)) |
|
| 170 | 173 | .route("/{owner}/{name}/settings/hooks", post(repo_settings::hooks)) |
|
| 171 | 174 | .route("/{owner}/{name}/settings/assistants", post(repo_settings::assistants)) |
|
| 175 | + | .route("/{owner}/{name}/settings/variables", post(repo_settings::variables)) |
|
| 172 | 176 | .route("/{owner}/{name}/settings/hooks/{id}/deliveries", get(repo_settings::deliveries)) |
|
| 173 | 177 | .route("/{owner}/{name}/settings/hooks/{id}/redeliver/{delivery}", post(repo_settings::redeliver)) |
|
| 174 | 178 | .route("/{owner}/{name}/settings/checks", post(repo_settings::required_checks)) |
|
| … | 460 unchanged lines |
modified crates/hub/src/web/orgs.rs +23 −3
| … | 71 unchanged lines |
||
| 72 | 72 | runner_token: Option<String>, |
|
| 73 | 73 | /// The organisation's AI assistants and prompt (#97). |
|
| 74 | 74 | assistants: super::assistants::Panel, |
|
| 75 | + | /// The organisation's Flow variables (#102). |
|
| 76 | + | variables: super::variables::Panel, |
|
| 75 | 77 | } |
|
| 76 | 78 | ||
| 77 | 79 | async fn org_page( |
|
| … | 22 unchanged lines |
||
| 100 | 102 | runners.push((r, serves, own)); |
|
| 101 | 103 | } |
|
| 102 | 104 | let assistants = super::assistants::panel(app, &org_assistants(&org)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 103 | - | Ok(Page(OrgSettingsPage { shell, org, members, teams, hooks, notice, error, runners, runner_token: None, assistants })) |
|
| 105 | + | let variables = super::variables::panel(app, &org_assistants(&org)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 106 | + | Ok(Page(OrgSettingsPage { shell, org, members, teams, hooks, notice, error, runners, runner_token: None, assistants, variables })) |
|
| 104 | 107 | } |
|
| 105 | 108 | ||
| 106 | 109 | async fn owned_org(app: &Shared, shell: &Shell, viewer: &Viewer, name: &str) -> Result<(accounts::Account, accounts::Organisation), Error> { |
|
| … | 256 unchanged lines |
||
| 363 | 366 | org_page(&app, shell, org, Some(format!("{} removed: its credential no longer works.", r.name)), None).await |
|
| 364 | 367 | } |
|
| 365 | 368 | ||
| 366 | - | fn org_assistants(org: &accounts::Organisation) -> super::assistants::Where { |
|
| 367 | - | super::assistants::Where { scope: crate::assistants::Scope::Org(org.id), owner: org.name.clone(), label: org.name.clone() } |
|
| 369 | + | fn org_assistants(org: &accounts::Organisation) -> crate::levels::At { |
|
| 370 | + | crate::levels::At::org(org) |
|
| 368 | 371 | } |
|
| 369 | 372 | ||
| 370 | 373 | /// `POST /orgs/{name}/settings/assistants`: the organisation's assistants |
|
| … | 12 unchanged lines |
||
| 383 | 386 | Err(error) => org_page(&app, shell, org, None, Some(error)).await, |
|
| 384 | 387 | } |
|
| 385 | 388 | } |
|
| 389 | + | ||
| 390 | + | /// `POST /orgs/{name}/settings/variables`: the organisation's Flow |
|
| 391 | + | /// variables (#102). |
|
| 392 | + | pub async fn variables( |
|
| 393 | + | State(app): State<Shared>, |
|
| 394 | + | viewer: Viewer, |
|
| 395 | + | Path(name): Path<String>, |
|
| 396 | + | Form(f): Form<super::variables::VariableForm>, |
|
| 397 | + | ) -> Result<Page<OrgSettingsPage>, Error> { |
|
| 398 | + | let shell = Shell::new(&app, &format!("/orgs/{name}/settings"), &viewer); |
|
| 399 | + | let (_, org) = owned_org(&app, &shell, &viewer, &name).await?; |
|
| 400 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 401 | + | match super::variables::apply(&app, &viewer, &org_assistants(&org), f).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 402 | + | Ok(notice) => org_page(&app, shell, org, Some(notice), None).await, |
|
| 403 | + | Err(error) => org_page(&app, shell, org, None, Some(error)).await, |
|
| 404 | + | } |
|
| 405 | + | } |
modified crates/hub/src/web/repo_settings.rs +25 −7
| … | 101 unchanged lines |
||
| 102 | 102 | runner_token: Option<String>, |
|
| 103 | 103 | /// The repository's AI assistants and prompt (#97). |
|
| 104 | 104 | assistants: super::assistants::Panel, |
|
| 105 | + | /// The repository's Flow variables (#102). |
|
| 106 | + | variables: super::variables::Panel, |
|
| 105 | 107 | } |
|
| 106 | 108 | ||
| 107 | 109 | async fn settings_page( |
|
| … | 34 unchanged lines |
||
| 142 | 144 | }) |
|
| 143 | 145 | .collect(); |
|
| 144 | 146 | let assistants = super::assistants::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 147 | + | let variables = super::variables::panel(app, &repo_assistants(&row)).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 145 | 148 | Ok(Page(RepoSettingsPage { |
|
| 146 | 149 | assistants, |
|
| 150 | + | variables, |
|
| 147 | 151 | runners, |
|
| 148 | 152 | runner_token: None, |
|
| 149 | 153 | shell, |
|
| … | 150 unchanged lines |
||
| 300 | 304 | let _guard = app.repos.write_guard(&repo).await; |
|
| 301 | 305 | app.repos.trash(&repo).map_err(|e| Error::from_repo(shell.clone(), e))?; |
|
| 302 | 306 | db::repos::delete(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 303 | - | crate::assistants::delete_scope(&app.db, crate::assistants::Scope::Repo(row.id)).await.ok(); |
|
| 307 | + | crate::assistants::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok(); |
|
| 308 | + | crate::variables::delete_scope(&app.db, crate::levels::Scope::Repo(row.id)).await.ok(); |
|
| 304 | 309 | accounts::audit(&app.db, viewer.account(), "repo.deleted", &repo.to_string(), "moved to trash").await.ok(); |
|
| 305 | 310 | Ok(Redirect::to(&format!("/{owner}")).into_response()) |
|
| 306 | 311 | } |
|
| … | 216 unchanged lines |
||
| 523 | 528 | settings_page(&app, shell, row, role, Some(format!("{} removed: its credential no longer works.", r.name)), None).await |
|
| 524 | 529 | } |
|
| 525 | 530 | ||
| 526 | - | fn repo_assistants(row: &db::repos::RepoRow) -> super::assistants::Where { |
|
| 527 | - | super::assistants::Where { |
|
| 528 | - | scope: crate::assistants::Scope::Repo(row.id), |
|
| 529 | - | owner: row.owner.clone(), |
|
| 530 | - | label: format!("{}/{}", row.owner, row.name), |
|
| 531 | - | } |
|
| 531 | + | fn repo_assistants(row: &db::repos::RepoRow) -> crate::levels::At { |
|
| 532 | + | crate::levels::At::repo(row) |
|
| 532 | 533 | } |
|
| 533 | 534 | ||
| 534 | 535 | /// `POST /{owner}/{name}/settings/assistants`: the repository's assistants |
|
| … | 8 unchanged lines |
||
| 543 | 544 | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 544 | 545 | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 545 | 546 | match super::assistants::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 547 | + | Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await, |
|
| 548 | + | Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await, |
|
| 549 | + | } |
|
| 550 | + | } |
|
| 551 | + | ||
| 552 | + | /// `POST /{owner}/{name}/settings/variables`: the repository's Flow |
|
| 553 | + | /// variables (#102), for its admins. |
|
| 554 | + | pub async fn variables( |
|
| 555 | + | State(app): State<Shared>, |
|
| 556 | + | viewer: Viewer, |
|
| 557 | + | Path((owner, name)): Path<(String, String)>, |
|
| 558 | + | Form(f): Form<super::variables::VariableForm>, |
|
| 559 | + | ) -> Result<Page<RepoSettingsPage>, Error> { |
|
| 560 | + | let shell = Shell::new(&app, &format!("/{owner}/{name}/settings"), &viewer); |
|
| 561 | + | let (_, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Admin).await?; |
|
| 562 | + | csrf(&viewer, &f.csrf_token, &shell)?; |
|
| 563 | + | match super::variables::apply(&app, &viewer, &repo_assistants(&row), f).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 546 | 564 | Ok(notice) => settings_page(&app, shell, row, role, Some(notice), None).await, |
|
| 547 | 565 | Err(error) => settings_page(&app, shell, row, role, None, Some(error)).await, |
|
| 548 | 566 | } |
|
| … | 1 unchanged lines |
modified crates/hub/tests/accounts.rs +1 −1
| … | 605 unchanged lines |
||
| 606 | 606 | &format!("_csrf={csrf}&smtp=smtps%3A%2F%2Fhub%3Asecret%40mail.example.org%3A465&from=Hub+%3Chub%40example.org%3E"), |
|
| 607 | 607 | ) |
|
| 608 | 608 | .await; |
|
| 609 | - | assert!(r.body.contains("smtps://hub:***@mail.example.org:465") && !r.body.contains("secret"), "{}", r.body); |
|
| 609 | + | assert!(r.body.contains("smtps://hub:***@mail.example.org:465") && !r.body.contains("hub:secret"), "{}", r.body); |
|
| 610 | 610 | let r = post( |
|
| 611 | 611 | app, |
|
| 612 | 612 | "/admin/mail", |
|
| … | 180 unchanged lines |
modified crates/hub/tests/flow.rs +86 −0
| … | 1717 unchanged lines |
||
| 1718 | 1718 | assert_eq!(names, ["nightly"]); |
|
| 1719 | 1719 | assert_eq!(runs[0].bookmark, "main", "on the default bookmark"); |
|
| 1720 | 1720 | } |
|
| 1721 | + | ||
| 1722 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 1723 | + | async fn variables_from_three_levels_reach_a_job_inner_overriding_outer() { |
|
| 1724 | + | use oxygenhub::levels::Scope; |
|
| 1725 | + | use oxygenhub::variables; |
|
| 1726 | + | let h = hub().await; |
|
| 1727 | + | let app = &h.app; |
|
| 1728 | + | // Andy, the site's administrator, owns the acme organisation. |
|
| 1729 | + | let res = oxygenhub::router(app.clone()) |
|
| 1730 | + | .oneshot( |
|
| 1731 | + | Request::post("/register") |
|
| 1732 | + | .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") |
|
| 1733 | + | .body(Body::from("username=andy&email=a%40example.com&password=correct-horse-battery")) |
|
| 1734 | + | .unwrap(), |
|
| 1735 | + | ) |
|
| 1736 | + | .await |
|
| 1737 | + | .unwrap(); |
|
| 1738 | + | let cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap().split(';').next().unwrap().to_string(); |
|
| 1739 | + | let andy = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap(); |
|
| 1740 | + | db::accounts::create_org(&app.db, "acme", "Acme", andy.id).await.unwrap(); |
|
| 1741 | + | create(app, "acme", "app").await; |
|
| 1742 | + | let page = |path: &str, form: Option<String>| { |
|
| 1743 | + | let mut req = |
|
| 1744 | + | Request::builder().method(if form.is_some() { "POST" } else { "GET" }).uri(path).header(header::COOKIE, cookie.clone()); |
|
| 1745 | + | if form.is_some() { |
|
| 1746 | + | req = req.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"); |
|
| 1747 | + | } |
|
| 1748 | + | let req = req.body(Body::from(form.unwrap_or_default())).unwrap(); |
|
| 1749 | + | let app = app.clone(); |
|
| 1750 | + | async move { |
|
| 1751 | + | let res = oxygenhub::router(app).oneshot(req).await.unwrap(); |
|
| 1752 | + | let status = res.status(); |
|
| 1753 | + | let body = String::from_utf8(res.into_body().collect().await.unwrap().to_bytes().to_vec()).unwrap(); |
|
| 1754 | + | (status, body) |
|
| 1755 | + | } |
|
| 1756 | + | }; |
|
| 1757 | + | let csrf_of = |page: &str| { |
|
| 1758 | + | let i = page.find("name=\"_csrf\" value=\"").expect("csrf field") + "name=\"_csrf\" value=\"".len(); |
|
| 1759 | + | page[i..].split('"').next().unwrap().to_string() |
|
| 1760 | + | }; |
|
| 1761 | + | ||
| 1762 | + | // The site sets CC and LANG, the organisation its own CC, the |
|
| 1763 | + | // repository RUST_LOG from its settings page. |
|
| 1764 | + | variables::set(&app.db, Scope::Site, "CC", "gcc").await.unwrap(); |
|
| 1765 | + | variables::set(&app.db, Scope::Site, "LANG", "C").await.unwrap(); |
|
| 1766 | + | let org = db::accounts::org_by_name(&app.db, "acme").await.unwrap().unwrap(); |
|
| 1767 | + | variables::set(&app.db, Scope::Org(org.id), "CC", "clang").await.unwrap(); |
|
| 1768 | + | let (st, settings) = page("/acme/app/settings", None).await; |
|
| 1769 | + | assert_eq!(st, StatusCode::OK, "{settings}"); |
|
| 1770 | + | assert!(settings.contains("Inherited") && settings.contains("the acme organisation") && settings.contains("overridden"), "{settings}"); |
|
| 1771 | + | let csrf = csrf_of(&settings); |
|
| 1772 | + | let (_, body) = page("/acme/app/settings/variables", Some(format!("_csrf={csrf}&action=set&name=RUST_LOG&value=debug"))).await; |
|
| 1773 | + | assert!(body.contains("RUST_LOG set.") && body.contains("<code>debug</code>"), "{body}"); |
|
| 1774 | + | let (_, body) = page("/acme/app/settings/variables", Some(format!("_csrf={csrf}&action=set&name=FLOW_REPO&value=x"))).await; |
|
| 1775 | + | assert!(body.contains("not FLOW_"), "{body}"); |
|
| 1776 | + | let repo = db::repos::get(&app.db, "acme", "app").await.unwrap().unwrap(); |
|
| 1777 | + | db::repos::set_flow(&app.db, repo.id, "on").await.unwrap(); |
|
| 1778 | + | let env = variables::env_for_repo(&app.db, &repo).await.unwrap(); |
|
| 1779 | + | assert_eq!( |
|
| 1780 | + | env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect::<Vec<_>>(), |
|
| 1781 | + | [("CC", "clang"), ("LANG", "C"), ("RUST_LOG", "debug")], |
|
| 1782 | + | "the organisation's CC wins over the site's" |
|
| 1783 | + | ); |
|
| 1784 | + | ||
| 1785 | + | // A claim carries them. |
|
| 1786 | + | let url = format!("{}/acme/app", h.base); |
|
| 1787 | + | let work = tempfile::tempdir().unwrap(); |
|
| 1788 | + | let c = client(&url, work.path()); |
|
| 1789 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 1790 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 1791 | + | c.snapshot("test").unwrap(); |
|
| 1792 | + | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 1793 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 1794 | + | let runs = runs_of(app, "acme", "app").await; |
|
| 1795 | + | assert_eq!(runs.len(), 1, "{runs:?}"); |
|
| 1796 | + | let got = claim(app).await.unwrap_or_else(|| panic!("a job: {:?}", runs[0])); |
|
| 1797 | + | assert_eq!(got["env"], serde_json::json!({ "CC": "clang", "LANG": "C", "RUST_LOG": "debug" }), "{got}"); |
|
| 1798 | + | ||
| 1799 | + | // Removing a site variable from the admin page takes effect at the next claim. |
|
| 1800 | + | let (_, admin) = page("/admin", None).await; |
|
| 1801 | + | assert!(admin.contains("Variables") && admin.contains("<code>gcc</code>"), "{admin}"); |
|
| 1802 | + | let (_, body) = page("/admin/variables", Some(format!("_csrf={}&action=delete&name=LANG", csrf_of(&admin)))).await; |
|
| 1803 | + | assert!(body.contains("LANG removed."), "{body}"); |
|
| 1804 | + | let got = claim(app).await.expect("another job"); |
|
| 1805 | + | assert_eq!(got["env"], serde_json::json!({ "CC": "clang", "RUST_LOG": "debug" }), "{got}"); |
|
| 1806 | + | } |
replaced deps/oxygenflow +0 −0
No line changes.
modified docs/spec.md +1 −0
| … | 495 unchanged lines |
||
| 496 | 496 | - its key is the one the runner's token first signed with. The first signed receipt under a token binds that token to its key in `flow_runner_keys`. |
|
| 497 | 497 | ||
| 498 | 498 | Otherwise the job's result still stands, and the run page says why the receipt was not kept. The run page links each kept receipt, with its signer. The change page lists the receipts for the state whose only head is that change. `/{owner}/{name}/receipts/{attest}` downloads a signed receipt for `oxy receipt verify` or `import`. |
|
| 499 | + | - **Variables** (oxygenhub#102): Administration, an organisation's settings and a repository's settings each hold `NAME=value` pairs (a name is a letter or `_` then letters, digits and `_`, up to 64, never `FLOW_*`; a value up to 4096 characters; up to 100 per level) under **Variables**, each page showing what it inherits and which inherited names it overrides. A claim carries the repository's effective set as `env`, inner levels overriding outer; Flow puts them in the job's environment under `FLOW_*` and the pipeline's own `env`, so they are part of the receipt. They are not secrets and are shown in full to whoever sees the page; secrets are mounted by Flow (OxygenFlow spec §9), never put in the environment. Changes are audited (`flow.variable.set`, `flow.variable.deleted`); `flow run --var NAME=VALUE` gives a local run the same. |
|
| 499 | 500 | - **Artifacts** (OxygenFlow spec §5.2): a job may declare artifacts with `output`. |
|
| 500 | 501 | - The runner holding the job uploads each one with `POST /api/v1/flow/jobs/{id}/artifacts/{name}` (raw bytes, at most 512 MiB). |
|
| 501 | 502 | - The hub refuses names the job does not declare. It keeps each artifact as a blob of the run's repository, records its path, size and BLAKE3 in `flow_artifacts`, and answers with the blob's ObjectRef. |
|
| … | 196 unchanged lines |
modified templates/admin.html +2 −0
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% import "assistants_panel.html" as ai %} |
|
| 3 | + | {% import "variables_panel.html" as va %} |
|
| 3 | 4 | {% block title %}Administration{% endblock %} |
|
| 4 | 5 | {% block content %} |
|
| 5 | 6 | <div class="head"><h1>Administration <small>{{ accounts.len() }} accounts · {{ orgs.len() }} organisations · {{ repos }} repositories</small></h1></div> |
|
| … | 50 unchanged lines |
||
| 56 | 57 | <p class="small mute">{% if mail_enabled %}Mail is on: verification and reset links go by email.{% else %}Mail is off: verification and reset links are issued by hand.{% endif %} An empty URL turns it off. The password stays on the server and is shown masked.</p> |
|
| 57 | 58 | </aside> |
|
| 58 | 59 | </div> |
|
| 60 | + | {% call va::panel("/admin", variables, shell.csrf, "Every repository's jobs get these, unless an organisation or repository sets the same name.") %} |
|
| 59 | 61 | {% call ai::panel("/admin", assistants, shell.csrf, "Every repository on the site may ask these, unless an organisation or repository turns one off.") %} |
|
| 60 | 62 | {% endblock %} |
modified templates/org_settings.html +3 −0
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% import "webhooks_panel.html" as w %} |
|
| 3 | 3 | {% import "assistants_panel.html" as ai %} |
|
| 4 | + | {% import "variables_panel.html" as va %} |
|
| 4 | 5 | {% block title %}{{ org.name }} settings{% endblock %} |
|
| 5 | 6 | {% block content %} |
|
| 6 | 7 | <div class="head"><h1>{{ org.name }} <small>organisation settings · <a href="/{{ org.name }}">repositories</a></small></h1></div> |
|
| … | 3 unchanged lines |
||
| 10 | 11 | <a href="#logo">Logo</a> |
|
| 11 | 12 | <a href="#members">Members</a> |
|
| 12 | 13 | <a href="#webhooks">Webhooks</a> |
|
| 14 | + | <a href="#variables">Variables</a> |
|
| 13 | 15 | <a href="#assistants">AI assistants</a> |
|
| 14 | 16 | <a href="#teams">Teams</a> |
|
| 15 | 17 | </nav> |
|
| … | 59 unchanged lines |
||
| 75 | 77 | <form method="post" action="/orgs/{{ org.name }}/settings/runners/token"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">Add a runner for the organisation</button></form> |
|
| 76 | 78 | </section> |
|
| 77 | 79 | {% call w::panel("/orgs/" ~ org.name ~ "/settings", hooks, shell.csrf, "These hear every repository of the organisation, as well as each repository's own webhooks.") %} |
|
| 80 | + | {% call va::panel("/orgs/" ~ org.name ~ "/settings", variables, shell.csrf, "Jobs of every repository of the organisation get these, beside the site's and each repository's own.") %} |
|
| 78 | 81 | {% call ai::panel("/orgs/" ~ org.name ~ "/settings", assistants, shell.csrf, "Every repository of the organisation may ask these, beside the site's and its own.") %} |
|
| 79 | 82 | </div> |
|
| 80 | 83 | </div> |
|
| … | 1 unchanged lines |
modified templates/repo_settings.html +3 −0
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | 2 | {% import "webhooks_panel.html" as w %} |
|
| 3 | 3 | {% import "assistants_panel.html" as ai %} |
|
| 4 | + | {% import "variables_panel.html" as va %} |
|
| 4 | 5 | {% import "repo_tabs.html" as m %} |
|
| 5 | 6 | {% block title %}Settings · {{ repo.owner }}/{{ repo.name }}{% endblock %} |
|
| 6 | 7 | {% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %} |
|
| … | 10 unchanged lines |
||
| 17 | 18 | <a href="#checks">Required checks</a> |
|
| 18 | 19 | <a href="#flow">OxygenFlow</a> |
|
| 19 | 20 | {% if role == crate::auth::Role::Admin %}<a href="#webhooks">Webhooks</a> |
|
| 21 | + | <a href="#variables">Variables</a> |
|
| 20 | 22 | <a href="#assistants">AI assistants</a> |
|
| 21 | 23 | <a href="#collaborators">Collaborators</a> |
|
| 22 | 24 | <a href="#danger">Delete</a>{% endif %} |
|
| … | 68 unchanged lines |
||
| 91 | 93 | <form method="post" action="{{ repo.url }}/settings/runners/token"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit">Add a runner for this repository</button></form> |
|
| 92 | 94 | </section> |
|
| 93 | 95 | {% call w::panel(repo.url ~ "/settings", hooks, shell.csrf, "Each delivery is signed with the secret (X-Oxyhub-Signature) and retried with backoff for a day.") %} |
|
| 96 | + | {% call va::panel(repo.url ~ "/settings", variables, shell.csrf, "This repository's jobs get these, beside the ones the site and its owner set.") %} |
|
| 94 | 97 | {% call ai::panel(repo.url ~ "/settings", assistants, shell.csrf, "This repository may ask these, beside the ones the site and its owner provide.") %} |
|
| 95 | 98 | <section class="panel" id="collaborators"> |
|
| 96 | 99 | <h2>Collaborators</h2> |
|
| … | 30 unchanged lines |