The hub keeps Terraform and OpenTofu state (closes #252) 8dcaeeb848c6
Terraform's and OpenTofu's HTTP backend, per repository, at
/api/v1/repos/OWNER/NAME/terraform/state/STATE: GET, POST (under ?ID=
while locked), DELETE, LOCK (423 with the holder's lock) and UNLOCK
(409 for another's). A job uses it with an identity token of this hub's
for {base}/terraform (its pipeline's identity block) or its runner's job
token, for its own repository only; people with a token of theirs and
their role. The token goes as the bearer or Basic auth's password
(TF_HTTP_PASSWORD).
State is sealed at rest with the hub's secrets key, and the last 20
versions are kept (migration 0079, SQLite and Postgres; schema test
green); backups carry the sealed bodies. Settings › Terraform state lists
a repository's states, serials, writers, locks and versions, with the
backend block. Tests: the backend's calls as Terraform makes them (lock
honoured, versions kept, sealed at rest, another repository's job and
token refused, identity tokens by repository and audience), and an
ignored test that runs the real OpenTofu 1.13 (init, apply, apply again:
no changes) through it. Hub spec §13.5.4. Pins oxygen 45b7327c (bucket
listing counters).
Closes #252
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 8910afc79df0
- When
- · 2026-10-18 01:55 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
8dcaeeb848c691cd04f0f23e9e6d638be3b22bdbd124ed16312f28f825cbc3d4- Depends on
- 0024ccb1b050 19bb31ff1f5c 380996db84e5 3b1bc27fe805 3eb491e9489b 42fa95540468 4f40eed0588f 617c2840446c 7974adb0be66 8aa5ef384ef1 8fb35df7d66e 9008bf6c2b8c 93be9ce4653e 965b950f9b96 a2a96bf21455 a46d15e72101 aa0bfbd11f02 d23464c0ac60 d3b048b27277
- Depended on by
- ba0a55bb5b3c 67523e06ed86 bb018142cca7 16ee5d19d220 8a48dd830178
- 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
- 14 atoms, 4 tree operations, 12 files
Browse the tree at this change
added crates/hub/migrations-pg/0079_terraform_states.sql +26 −0
| 1 | + | -- Terraform and OpenTofu state kept by the hub (oxygenhub#252): one row |
|
| 2 | + | -- per repository and state name, its body sealed with the hub's secrets |
|
| 3 | + | -- key, its lock, and the last versions for rolling back. |
|
| 4 | + | CREATE TABLE terraform_states ( |
|
| 5 | + | repo_id BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 6 | + | name TEXT NOT NULL, |
|
| 7 | + | body BYTEA NOT NULL, |
|
| 8 | + | serial BIGINT NOT NULL DEFAULT 0, |
|
| 9 | + | lock_id TEXT NOT NULL DEFAULT '', |
|
| 10 | + | lock_info TEXT NOT NULL DEFAULT '', |
|
| 11 | + | locked_by TEXT NOT NULL DEFAULT '', |
|
| 12 | + | locked_ms BIGINT, |
|
| 13 | + | updated_by TEXT NOT NULL DEFAULT '', |
|
| 14 | + | updated_ms BIGINT NOT NULL, |
|
| 15 | + | PRIMARY KEY (repo_id, name) |
|
| 16 | + | ); |
|
| 17 | + | CREATE TABLE terraform_state_versions ( |
|
| 18 | + | id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, |
|
| 19 | + | repo_id BIGINT NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 20 | + | name TEXT NOT NULL, |
|
| 21 | + | body BYTEA NOT NULL, |
|
| 22 | + | serial BIGINT NOT NULL, |
|
| 23 | + | created_by TEXT NOT NULL, |
|
| 24 | + | created_ms BIGINT NOT NULL |
|
| 25 | + | ); |
|
| 26 | + | CREATE INDEX terraform_state_versions_state ON terraform_state_versions(repo_id, name, id); |
added crates/hub/migrations/0079_terraform_states.sql +26 −0
| 1 | + | -- Terraform and OpenTofu state kept by the hub (oxygenhub#252): one row |
|
| 2 | + | -- per repository and state name, its body sealed with the hub's secrets |
|
| 3 | + | -- key, its lock, and the last versions for rolling back. |
|
| 4 | + | CREATE TABLE terraform_states ( |
|
| 5 | + | repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 6 | + | name TEXT NOT NULL, |
|
| 7 | + | body BLOB NOT NULL, |
|
| 8 | + | serial INTEGER NOT NULL DEFAULT 0, |
|
| 9 | + | lock_id TEXT NOT NULL DEFAULT '', |
|
| 10 | + | lock_info TEXT NOT NULL DEFAULT '', |
|
| 11 | + | locked_by TEXT NOT NULL DEFAULT '', |
|
| 12 | + | locked_ms INTEGER, |
|
| 13 | + | updated_by TEXT NOT NULL DEFAULT '', |
|
| 14 | + | updated_ms INTEGER NOT NULL, |
|
| 15 | + | PRIMARY KEY (repo_id, name) |
|
| 16 | + | ); |
|
| 17 | + | CREATE TABLE terraform_state_versions ( |
|
| 18 | + | id INTEGER PRIMARY KEY, |
|
| 19 | + | repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE, |
|
| 20 | + | name TEXT NOT NULL, |
|
| 21 | + | body BLOB NOT NULL, |
|
| 22 | + | serial INTEGER NOT NULL, |
|
| 23 | + | created_by TEXT NOT NULL, |
|
| 24 | + | created_ms INTEGER NOT NULL |
|
| 25 | + | ); |
|
| 26 | + | CREATE INDEX terraform_state_versions_state ON terraform_state_versions(repo_id, name, id); |
added crates/hub/src/db/terraform.rs +112 −0
| 1 | + | //! Terraform and OpenTofu states (migration 0079, oxygenhub#252). |
|
| 2 | + | ||
| 3 | + | use super::{now_ms, Pool}; |
|
| 4 | + | use sqlx::FromRow; |
|
| 5 | + | ||
| 6 | + | #[derive(Clone, Debug, FromRow)] |
|
| 7 | + | pub struct TfState { |
|
| 8 | + | pub repo_id: i64, |
|
| 9 | + | pub name: String, |
|
| 10 | + | /// Sealed with the hub's secrets key; empty once deleted. |
|
| 11 | + | pub body: Vec<u8>, |
|
| 12 | + | pub serial: i64, |
|
| 13 | + | pub lock_id: String, |
|
| 14 | + | pub lock_info: String, |
|
| 15 | + | pub locked_by: String, |
|
| 16 | + | pub locked_ms: Option<i64>, |
|
| 17 | + | pub updated_by: String, |
|
| 18 | + | pub updated_ms: i64, |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | pub async fn get(pool: &Pool, repo_id: i64, name: &str) -> sqlx::Result<Option<TfState>> { |
|
| 22 | + | sqlx::query_as("SELECT * FROM terraform_states WHERE repo_id = $1 AND name = $2").bind(repo_id).bind(name).fetch_optional(pool).await |
|
| 23 | + | } |
|
| 24 | + | ||
| 25 | + | pub async fn of_repo(pool: &Pool, repo_id: i64) -> sqlx::Result<Vec<TfState>> { |
|
| 26 | + | sqlx::query_as("SELECT * FROM terraform_states WHERE repo_id = $1 ORDER BY name").bind(repo_id).fetch_all(pool).await |
|
| 27 | + | } |
|
| 28 | + | ||
| 29 | + | /// How many earlier versions a state keeps. |
|
| 30 | + | pub async fn versions(pool: &Pool, repo_id: i64, name: &str) -> sqlx::Result<i64> { |
|
| 31 | + | sqlx::query_scalar("SELECT COUNT(*) FROM terraform_state_versions WHERE repo_id = $1 AND name = $2") |
|
| 32 | + | .bind(repo_id) |
|
| 33 | + | .bind(name) |
|
| 34 | + | .fetch_one(pool) |
|
| 35 | + | .await |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | /// Store a new body; the one it replaces becomes a version, and only the |
|
| 39 | + | /// last `keep` versions stay. |
|
| 40 | + | pub async fn put(pool: &Pool, repo_id: i64, name: &str, body: &[u8], serial: i64, by: &str, keep: i64) -> sqlx::Result<()> { |
|
| 41 | + | let now = now_ms(); |
|
| 42 | + | let mut tx = pool.begin().await?; |
|
| 43 | + | sqlx::query( |
|
| 44 | + | "INSERT INTO terraform_state_versions (repo_id, name, body, serial, created_by, created_ms) \ |
|
| 45 | + | SELECT repo_id, name, body, serial, updated_by, updated_ms FROM terraform_states \ |
|
| 46 | + | WHERE repo_id = $1 AND name = $2 AND length(body) > 0", |
|
| 47 | + | ) |
|
| 48 | + | .bind(repo_id) |
|
| 49 | + | .bind(name) |
|
| 50 | + | .execute(&mut *tx) |
|
| 51 | + | .await?; |
|
| 52 | + | sqlx::query( |
|
| 53 | + | "INSERT INTO terraform_states (repo_id, name, body, serial, updated_by, updated_ms) VALUES ($1, $2, $3, $4, $5, $6) \ |
|
| 54 | + | ON CONFLICT (repo_id, name) DO UPDATE SET body = $3, serial = $4, updated_by = $5, updated_ms = $6", |
|
| 55 | + | ) |
|
| 56 | + | .bind(repo_id) |
|
| 57 | + | .bind(name) |
|
| 58 | + | .bind(body) |
|
| 59 | + | .bind(serial) |
|
| 60 | + | .bind(by) |
|
| 61 | + | .bind(now) |
|
| 62 | + | .execute(&mut *tx) |
|
| 63 | + | .await?; |
|
| 64 | + | sqlx::query( |
|
| 65 | + | "DELETE FROM terraform_state_versions WHERE repo_id = $1 AND name = $2 AND id NOT IN \ |
|
| 66 | + | (SELECT id FROM terraform_state_versions WHERE repo_id = $1 AND name = $2 ORDER BY id DESC LIMIT $3)", |
|
| 67 | + | ) |
|
| 68 | + | .bind(repo_id) |
|
| 69 | + | .bind(name) |
|
| 70 | + | .bind(keep) |
|
| 71 | + | .execute(&mut *tx) |
|
| 72 | + | .await?; |
|
| 73 | + | tx.commit().await |
|
| 74 | + | } |
|
| 75 | + | ||
| 76 | + | /// Delete a state: its body goes (its versions stay, for a mistake). |
|
| 77 | + | pub async fn delete(pool: &Pool, repo_id: i64, name: &str) -> sqlx::Result<()> { |
|
| 78 | + | sqlx::query("DELETE FROM terraform_states WHERE repo_id = $1 AND name = $2").bind(repo_id).bind(name).execute(pool).await?; |
|
| 79 | + | Ok(()) |
|
| 80 | + | } |
|
| 81 | + | ||
| 82 | + | /// Take the lock; false when someone holds it. |
|
| 83 | + | pub async fn lock(pool: &Pool, repo_id: i64, name: &str, id: &str, info: &str, by: &str) -> sqlx::Result<bool> { |
|
| 84 | + | let now = now_ms(); |
|
| 85 | + | let r = sqlx::query( |
|
| 86 | + | "INSERT INTO terraform_states (repo_id, name, body, lock_id, lock_info, locked_by, locked_ms, updated_ms) \ |
|
| 87 | + | VALUES ($1, $2, $3, $4, $5, $6, $7, $7) \ |
|
| 88 | + | ON CONFLICT (repo_id, name) DO UPDATE SET lock_id = $4, lock_info = $5, locked_by = $6, locked_ms = $7 \ |
|
| 89 | + | WHERE terraform_states.lock_id = ''", |
|
| 90 | + | ) |
|
| 91 | + | .bind(repo_id) |
|
| 92 | + | .bind(name) |
|
| 93 | + | .bind(Vec::<u8>::new()) |
|
| 94 | + | .bind(id) |
|
| 95 | + | .bind(info) |
|
| 96 | + | .bind(by) |
|
| 97 | + | .bind(now) |
|
| 98 | + | .execute(pool) |
|
| 99 | + | .await?; |
|
| 100 | + | Ok(r.rows_affected() == 1) |
|
| 101 | + | } |
|
| 102 | + | ||
| 103 | + | pub async fn unlock(pool: &Pool, repo_id: i64, name: &str) -> sqlx::Result<()> { |
|
| 104 | + | sqlx::query( |
|
| 105 | + | "UPDATE terraform_states SET lock_id = '', lock_info = '', locked_by = '', locked_ms = NULL WHERE repo_id = $1 AND name = $2", |
|
| 106 | + | ) |
|
| 107 | + | .bind(repo_id) |
|
| 108 | + | .bind(name) |
|
| 109 | + | .execute(pool) |
|
| 110 | + | .await?; |
|
| 111 | + | Ok(()) |
|
| 112 | + | } |
added crates/hub/src/terraform.rs +251 −0
| 1 | + | //! Terraform and OpenTofu state, kept by the hub per repository |
|
| 2 | + | //! (oxygenhub#252): the HTTP backend Terraform and OpenTofu speak, with |
|
| 3 | + | //! locking, at `/api/v1/repos/OWNER/NAME/terraform/state/STATE`, so a Flow |
|
| 4 | + | //! job keeps its state without any cloud credential. A job authenticates |
|
| 5 | + | //! with an identity token of this hub's for the audience `{base}/terraform` |
|
| 6 | + | //! (its pipeline's `identity` block) or its runner's job token, either for |
|
| 7 | + | //! its own repository only; people with a token of theirs and their |
|
| 8 | + | //! repository role. Tokens go as the bearer, or as the password of Basic |
|
| 9 | + | //! auth, which is what the backend sends. |
|
| 10 | + | //! |
|
| 11 | + | //! State is kept outside the repository's history (it often holds secrets), |
|
| 12 | + | //! sealed at rest with the hub's secrets key, and its last versions are |
|
| 13 | + | //! kept, so a bad apply can be rolled back. |
|
| 14 | + | ||
| 15 | + | use crate::api::ApiError; |
|
| 16 | + | use crate::auth::roles::{Access, Role}; |
|
| 17 | + | use crate::{db, Shared}; |
|
| 18 | + | use axum::body::Bytes; |
|
| 19 | + | use axum::extract::{Path, Query, State}; |
|
| 20 | + | use axum::http::{header, HeaderMap, Method, StatusCode}; |
|
| 21 | + | use axum::response::{IntoResponse, Response}; |
|
| 22 | + | ||
| 23 | + | /// Versions of a state kept besides the current one. |
|
| 24 | + | pub const VERSIONS_KEPT: i64 = 20; |
|
| 25 | + | /// The most a state may weigh. |
|
| 26 | + | pub const MAX_STATE_BYTES: usize = 32 << 20; |
|
| 27 | + | ||
| 28 | + | /// Who may use a repository's states. |
|
| 29 | + | enum Who { |
|
| 30 | + | /// A running job of this repository. |
|
| 31 | + | Job { name: String }, |
|
| 32 | + | /// A person, with their role on the repository. |
|
| 33 | + | Person { account: Box<db::accounts::Account>, role: Role }, |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | impl Who { |
|
| 37 | + | fn label(&self) -> String { |
|
| 38 | + | match self { |
|
| 39 | + | Who::Job { name } => name.clone(), |
|
| 40 | + | Who::Person { account, .. } => account.username.clone(), |
|
| 41 | + | } |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | fn may(&self, access: Access) -> bool { |
|
| 45 | + | match self { |
|
| 46 | + | // A job reads and writes its own repository's states. |
|
| 47 | + | Who::Job { .. } => true, |
|
| 48 | + | Who::Person { role, .. } => crate::auth::roles::allows(Some(*role), access), |
|
| 49 | + | } |
|
| 50 | + | } |
|
| 51 | + | } |
|
| 52 | + | ||
| 53 | + | fn err(status: StatusCode, why: impl Into<String>) -> ApiError { |
|
| 54 | + | ApiError(status, why.into()) |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | /// The token a request carries: the bearer, or Basic auth's password. |
|
| 58 | + | fn token_of(headers: &HeaderMap) -> Option<String> { |
|
| 59 | + | if let Some(t) = oxy_server::bearer_token(headers) { |
|
| 60 | + | return Some(t.to_string()); |
|
| 61 | + | } |
|
| 62 | + | let v = headers.get(header::AUTHORIZATION)?.to_str().ok()?; |
|
| 63 | + | let b64 = v.strip_prefix("Basic ").or_else(|| v.strip_prefix("basic "))?; |
|
| 64 | + | let raw = String::from_utf8(oxy_core::dsse::base64_decode(b64.trim())?).ok()?; |
|
| 65 | + | let (_, password) = raw.split_once(':')?; |
|
| 66 | + | (!password.is_empty()).then(|| password.to_string()) |
|
| 67 | + | } |
|
| 68 | + | ||
| 69 | + | /// The audience a job's identity token needs here. |
|
| 70 | + | pub fn audience(base_url: &str) -> String { |
|
| 71 | + | format!("{}/terraform", base_url.trim_end_matches('/')) |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | fn valid_name(name: &str) -> bool { |
|
| 75 | + | !name.is_empty() |
|
| 76 | + | && name.len() <= 100 |
|
| 77 | + | && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') |
|
| 78 | + | && !name.starts_with('.') |
|
| 79 | + | } |
|
| 80 | + | ||
| 81 | + | async fn who(app: &Shared, headers: &HeaderMap, row: &db::repos::RepoRow) -> Result<Who, ApiError> { |
|
| 82 | + | let token = token_of(headers).ok_or_else(|| { |
|
| 83 | + | err( |
|
| 84 | + | StatusCode::UNAUTHORIZED, |
|
| 85 | + | "a Terraform state needs a token: a job's (TF_HTTP_PASSWORD), or one of yours with repo:read or repo:write", |
|
| 86 | + | ) |
|
| 87 | + | })?; |
|
| 88 | + | let hash = crate::auth::token_hash(&token); |
|
| 89 | + | if token.starts_with(crate::flow::JOB_TOKEN_PREFIX) { |
|
| 90 | + | let job = db::flow::job_by_token(&app.db, &hash, crate::flow::JOB_TOKEN_MAX_AGE_MS) |
|
| 91 | + | .await |
|
| 92 | + | .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? |
|
| 93 | + | .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "this job token is not valid (the job must be running)"))?; |
|
| 94 | + | let run = db::flow::run_by_id(&app.db, job.run_id) |
|
| 95 | + | .await |
|
| 96 | + | .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? |
|
| 97 | + | .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "this job token is not valid"))?; |
|
| 98 | + | if run.repo_id != row.id { |
|
| 99 | + | return Err(err(StatusCode::FORBIDDEN, "a job keeps the states of its own repository only")); |
|
| 100 | + | } |
|
| 101 | + | return Ok(Who::Job { name: format!("run #{} {}", run.number, job.name) }); |
|
| 102 | + | } |
|
| 103 | + | let personal = db::accounts::token_by_hash(&app.db, &hash).await.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
|
| 104 | + | let Some(token) = personal.filter(|t| !t.expired(db::now_ms())) else { |
|
| 105 | + | // A job's identity token for this hub's Terraform audience. |
|
| 106 | + | let issuer = app.oidc().await.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
|
| 107 | + | let aud = audience(app.config.base_url()); |
|
| 108 | + | let Some(claims) = issuer.verify(&token, &aud, db::now_ms()) else { |
|
| 109 | + | return Err(err(StatusCode::UNAUTHORIZED, format!("unknown or expired token (a job's identity token must be for {aud})"))); |
|
| 110 | + | }; |
|
| 111 | + | let sub = claims["sub"].as_str().unwrap_or(""); |
|
| 112 | + | let repo = claims["repository"] |
|
| 113 | + | .as_str() |
|
| 114 | + | .map(String::from) |
|
| 115 | + | .or_else(|| sub.strip_prefix("repo:").and_then(|r| r.split_once(":bookmark:")).map(|(r, _)| r.to_string())); |
|
| 116 | + | if repo.as_deref() != Some(row.slug().as_str()) { |
|
| 117 | + | return Err(err(StatusCode::FORBIDDEN, "a job keeps the states of its own repository only")); |
|
| 118 | + | } |
|
| 119 | + | return Ok(Who::Job { name: sub.to_string() }); |
|
| 120 | + | }; |
|
| 121 | + | if !(token.has_scope("repo:read") || token.has_scope("repo:write") || token.has_scope("repo:admin")) { |
|
| 122 | + | return Err(err(StatusCode::FORBIDDEN, "the token lacks repo:read (to read a state) or repo:write (to change one)")); |
|
| 123 | + | } |
|
| 124 | + | let slug = row.slug(); |
|
| 125 | + | if !token.covers(&slug) { |
|
| 126 | + | return Err(err(StatusCode::FORBIDDEN, format!("the token does not cover {slug}"))); |
|
| 127 | + | } |
|
| 128 | + | let account = db::accounts::account_by_id(&app.db, token.account_id) |
|
| 129 | + | .await |
|
| 130 | + | .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? |
|
| 131 | + | .filter(|a| !a.banned && !a.disabled) |
|
| 132 | + | .ok_or_else(|| err(StatusCode::UNAUTHORIZED, "unknown token"))?; |
|
| 133 | + | let role = crate::auth::roles::role_for(&app.db, Some(&account), row) |
|
| 134 | + | .await |
|
| 135 | + | .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; |
|
| 136 | + | // A read-only token reads, whatever the role. |
|
| 137 | + | let role = match role { |
|
| 138 | + | Some(r) if !(token.has_scope("repo:write") || token.has_scope("repo:admin")) => Some(r.min(Role::Read)), |
|
| 139 | + | r => r, |
|
| 140 | + | }; |
|
| 141 | + | match role { |
|
| 142 | + | Some(r) if crate::auth::roles::allows(Some(r), Access::Read) => Ok(Who::Person { account: Box::new(account), role: r }), |
|
| 143 | + | _ => Err(err(StatusCode::NOT_FOUND, "no such repository")), |
|
| 144 | + | } |
|
| 145 | + | } |
|
| 146 | + | ||
| 147 | + | #[derive(serde::Deserialize, Default)] |
|
| 148 | + | pub struct LockQuery { |
|
| 149 | + | #[serde(rename = "ID", default)] |
|
| 150 | + | id: Option<String>, |
|
| 151 | + | } |
|
| 152 | + | ||
| 153 | + | fn json(status: StatusCode, body: String) -> Response { |
|
| 154 | + | (status, [(header::CONTENT_TYPE, "application/json")], body).into_response() |
|
| 155 | + | } |
|
| 156 | + | ||
| 157 | + | /// The lock's ID in a lock-info body (`{"ID": …}`). |
|
| 158 | + | fn lock_id(info: &[u8]) -> Option<String> { |
|
| 159 | + | serde_json::from_slice::<serde_json::Value>(info).ok()?.get("ID")?.as_str().map(String::from) |
|
| 160 | + | } |
|
| 161 | + | ||
| 162 | + | /// `GET|POST|DELETE|LOCK|UNLOCK /api/v1/repos/{owner}/{name}/terraform/state/{state}`. |
|
| 163 | + | pub async fn state( |
|
| 164 | + | State(app): State<Shared>, |
|
| 165 | + | method: Method, |
|
| 166 | + | headers: HeaderMap, |
|
| 167 | + | Path((owner, name, state)): Path<(String, String, String)>, |
|
| 168 | + | Query(q): Query<LockQuery>, |
|
| 169 | + | body: Bytes, |
|
| 170 | + | ) -> Result<Response, ApiError> { |
|
| 171 | + | if !valid_name(&state) { |
|
| 172 | + | return Err(err(StatusCode::BAD_REQUEST, "a state's name is letters, digits, '-', '_' and '.', up to 100")); |
|
| 173 | + | } |
|
| 174 | + | let row = db::repos::get(&app.db, &owner, &name) |
|
| 175 | + | .await |
|
| 176 | + | .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? |
|
| 177 | + | .ok_or_else(|| err(StatusCode::NOT_FOUND, "no such repository"))?; |
|
| 178 | + | let who = who(&app, &headers, &row).await?; |
|
| 179 | + | let write = method != Method::GET; |
|
| 180 | + | if !who.may(if write { Access::Write } else { Access::Read }) { |
|
| 181 | + | return Err(err(StatusCode::FORBIDDEN, "changing a state needs the write role")); |
|
| 182 | + | } |
|
| 183 | + | let db_err = |e: sqlx::Error| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()); |
|
| 184 | + | let current = db::terraform::get(&app.db, row.id, &state).await.map_err(db_err)?; |
|
| 185 | + | let locked = current.as_ref().filter(|s| !s.lock_id.is_empty()); |
|
| 186 | + | match method.as_str() { |
|
| 187 | + | "GET" => match current.filter(|s| !s.body.is_empty()) { |
|
| 188 | + | Some(s) => { |
|
| 189 | + | let plain = |
|
| 190 | + | app.secret_key.open(&s.body).ok_or_else(|| err(StatusCode::INTERNAL_SERVER_ERROR, "the state does not open"))?; |
|
| 191 | + | Ok(json(StatusCode::OK, plain)) |
|
| 192 | + | } |
|
| 193 | + | // No state yet: Terraform starts afresh. |
|
| 194 | + | None => Ok(StatusCode::NO_CONTENT.into_response()), |
|
| 195 | + | }, |
|
| 196 | + | "POST" => { |
|
| 197 | + | if body.len() > MAX_STATE_BYTES { |
|
| 198 | + | return Err(err(StatusCode::PAYLOAD_TOO_LARGE, format!("a state may be {} MiB at most", MAX_STATE_BYTES >> 20))); |
|
| 199 | + | } |
|
| 200 | + | let text = std::str::from_utf8(&body).map_err(|_| err(StatusCode::BAD_REQUEST, "a state is JSON"))?; |
|
| 201 | + | let parsed: serde_json::Value = serde_json::from_str(text).map_err(|_| err(StatusCode::BAD_REQUEST, "a state is JSON"))?; |
|
| 202 | + | if let Some(l) = locked { |
|
| 203 | + | if q.id.as_deref() != Some(l.lock_id.as_str()) { |
|
| 204 | + | return Ok(json(StatusCode::LOCKED, l.lock_info.clone())); |
|
| 205 | + | } |
|
| 206 | + | } |
|
| 207 | + | let serial = parsed.get("serial").and_then(|v| v.as_i64()).unwrap_or(0); |
|
| 208 | + | let sealed = app.secret_key.seal(text); |
|
| 209 | + | db::terraform::put(&app.db, row.id, &state, &sealed, serial, &who.label(), VERSIONS_KEPT).await.map_err(db_err)?; |
|
| 210 | + | Ok(StatusCode::OK.into_response()) |
|
| 211 | + | } |
|
| 212 | + | "DELETE" => { |
|
| 213 | + | if let Some(l) = locked { |
|
| 214 | + | if q.id.as_deref() != Some(l.lock_id.as_str()) { |
|
| 215 | + | return Ok(json(StatusCode::LOCKED, l.lock_info.clone())); |
|
| 216 | + | } |
|
| 217 | + | } |
|
| 218 | + | db::terraform::delete(&app.db, row.id, &state).await.map_err(db_err)?; |
|
| 219 | + | Ok(StatusCode::OK.into_response()) |
|
| 220 | + | } |
|
| 221 | + | "LOCK" => { |
|
| 222 | + | let info = std::str::from_utf8(&body).unwrap_or(""); |
|
| 223 | + | let Some(id) = lock_id(body.as_ref()) else { |
|
| 224 | + | return Err(err(StatusCode::BAD_REQUEST, "a lock request is the lock's JSON, with its ID")); |
|
| 225 | + | }; |
|
| 226 | + | if let Some(l) = locked { |
|
| 227 | + | return Ok(json(if l.lock_id == id { StatusCode::OK } else { StatusCode::LOCKED }, l.lock_info.clone())); |
|
| 228 | + | } |
|
| 229 | + | if db::terraform::lock(&app.db, row.id, &state, &id, info, &who.label()).await.map_err(db_err)? { |
|
| 230 | + | Ok(json(StatusCode::OK, info.to_string())) |
|
| 231 | + | } else { |
|
| 232 | + | // Taken meanwhile. |
|
| 233 | + | let now = db::terraform::get(&app.db, row.id, &state).await.map_err(db_err)?; |
|
| 234 | + | Ok(json(StatusCode::LOCKED, now.map(|s| s.lock_info).unwrap_or_default())) |
|
| 235 | + | } |
|
| 236 | + | } |
|
| 237 | + | "UNLOCK" => { |
|
| 238 | + | let Some(l) = locked else { return Ok(StatusCode::OK.into_response()) }; |
|
| 239 | + | // `terraform force-unlock` sends the ID; a plain unlock its lock info. |
|
| 240 | + | let id = lock_id(body.as_ref()).or(q.id.clone()); |
|
| 241 | + | if id.as_deref() != Some(l.lock_id.as_str()) { |
|
| 242 | + | return Ok(json(StatusCode::CONFLICT, l.lock_info.clone())); |
|
| 243 | + | } |
|
| 244 | + | db::terraform::unlock(&app.db, row.id, &state).await.map_err(db_err)?; |
|
| 245 | + | Ok(StatusCode::OK.into_response()) |
|
| 246 | + | } |
|
| 247 | + | other => { |
|
| 248 | + | Err(err(StatusCode::METHOD_NOT_ALLOWED, format!("{other} is not a Terraform backend method (GET, POST, DELETE, LOCK, UNLOCK)"))) |
|
| 249 | + | } |
|
| 250 | + | } |
|
| 251 | + | } |
modified crates/hub/src/api/mod.rs +5 −0
| … | 44 unchanged lines |
||
| 45 | 45 | .fallback(no_route) |
|
| 46 | 46 | .route("/keyless/certify", post(crate::keyless::certify)) |
|
| 47 | 47 | .route("/agents/grants", get(crate::keyless::grants_list).post(crate::keyless::grants_create)) |
|
| 48 | + | .route( |
|
| 49 | + | "/repos/{owner}/{name}/terraform/state/{state}", |
|
| 50 | + | axum::routing::any(crate::terraform::state) |
|
| 51 | + | .layer(axum::extract::DefaultBodyLimit::max(crate::terraform::MAX_STATE_BYTES + 4096)), |
|
| 52 | + | ) |
|
| 48 | 53 | .route("/agents/grants/{id}", axum::routing::delete(crate::keyless::grants_revoke)) |
|
| 49 | 54 | .route("/flow/claim", post(crate::flow::claim)) |
|
| 50 | 55 | .route("/flow/runners/register", post(crate::flow::register)) |
|
| … | 2634 unchanged lines |
modified crates/hub/src/db/mod.rs +1 −0
| … | 13 unchanged lines |
||
| 14 | 14 | pub mod repos; |
|
| 15 | 15 | pub mod runners; |
|
| 16 | 16 | pub mod sessions; |
|
| 17 | + | pub mod terraform; |
|
| 17 | 18 | pub mod tlog; |
|
| 18 | 19 | ||
| 19 | 20 | #[cfg(not(feature = "postgres"))] |
|
| … | 148 unchanged lines |
modified crates/hub/src/lib.rs +1 −0
| … | 38 unchanged lines |
||
| 39 | 39 | pub mod social; |
|
| 40 | 40 | pub mod storage; |
|
| 41 | 41 | pub mod swarm; |
|
| 42 | + | pub mod terraform; |
|
| 42 | 43 | pub mod throttle; |
|
| 43 | 44 | pub mod tlog; |
|
| 44 | 45 | pub mod variables; |
|
| … | 307 unchanged lines |
modified crates/hub/src/web/repo_settings.rs +8 −0
| … | 195 unchanged lines |
||
| 196 | 196 | coverage: crate::coverage::Rules, |
|
| 197 | 197 | /// Its owner's backup, when one is set up (docs/backups.md §5). |
|
| 198 | 198 | backup_owner: Option<crate::backups::Setting>, |
|
| 199 | + | /// Terraform states the hub keeps for it, with their kept versions (#252). |
|
| 200 | + | tf_states: Vec<(db::terraform::TfState, i64)>, |
|
| 199 | 201 | } |
|
| 200 | 202 | ||
| 201 | 203 | impl RepoSettingsPage { |
|
| … | 47 unchanged lines |
||
| 249 | 251 | let storage = crate::storage::of(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 250 | 252 | let coverage = crate::coverage::rules(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 251 | 253 | let backup_owner = crate::backups::setting(&app.db, &row.owner).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 254 | + | let mut tf_states = Vec::new(); |
|
| 255 | + | for s in db::terraform::of_repo(&app.db, row.id).await.map_err(|e| Error::from_db(shell.clone(), e))? { |
|
| 256 | + | let n = db::terraform::versions(&app.db, row.id, &s.name).await.map_err(|e| Error::from_db(shell.clone(), e))?; |
|
| 257 | + | tf_states.push((s, n)); |
|
| 258 | + | } |
|
| 252 | 259 | Ok(Page(RepoSettingsPage { |
|
| 260 | + | tf_states, |
|
| 253 | 261 | backup_owner, |
|
| 254 | 262 | coverage, |
|
| 255 | 263 | storage, |
|
| … | 849 unchanged lines |
modified crates/hub/tests/flow.rs +138 −0
| … | 5152 unchanged lines |
||
| 5153 | 5153 | assert_eq!(st, StatusCode::NOT_FOUND); |
|
| 5154 | 5154 | } |
|
| 5155 | 5155 | ||
| 5156 | + | /// Terraform state (oxygenhub#252): the HTTP backend's calls as |
|
| 5157 | + | /// Terraform and OpenTofu make them, with a running job's token as the |
|
| 5158 | + | /// Basic-auth password; the lock is honoured, versions are kept, the state |
|
| 5159 | + | /// is sealed at rest, and another repository's job is refused. |
|
| 5160 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 5161 | + | async fn a_job_keeps_its_terraform_state_on_the_hub() { |
|
| 5162 | + | let h = hub().await; |
|
| 5163 | + | create(&h.app, "andy", "infra").await; |
|
| 5164 | + | create(&h.app, "andy", "other").await; |
|
| 5165 | + | let work = tempfile::tempdir().unwrap(); |
|
| 5166 | + | let c = client(&format!("{}/andy/infra", h.base), work.path()); |
|
| 5167 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 5168 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 5169 | + | c.snapshot("test").unwrap(); |
|
| 5170 | + | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 5171 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 5172 | + | assert_eq!(runs_of(&h.app, "andy", "infra").await.len(), 1); |
|
| 5173 | + | let job = claim(&h.app).await.expect("a job"); |
|
| 5174 | + | let token = job["token"].as_str().unwrap().to_string(); |
|
| 5175 | + | let basic = format!("Basic {}", oxy_core::dsse::base64(format!("flow:{token}").as_bytes())); |
|
| 5176 | + | let path = "/api/v1/repos/andy/infra/terraform/state/prod"; |
|
| 5177 | + | let send = |method: &str, uri: String, auth: Option<String>, body: &str| { |
|
| 5178 | + | let mut req = Request::builder().method(method).uri(uri); |
|
| 5179 | + | if let Some(a) = auth { |
|
| 5180 | + | req = req.header(header::AUTHORIZATION, a); |
|
| 5181 | + | } |
|
| 5182 | + | let req = req.body(Body::from(body.to_string())).unwrap(); |
|
| 5183 | + | let app = h.app.clone(); |
|
| 5184 | + | async move { |
|
| 5185 | + | let res = oxygenhub::router(app).oneshot(req).await.unwrap(); |
|
| 5186 | + | let st = res.status(); |
|
| 5187 | + | let b = res.into_body().collect().await.unwrap().to_bytes(); |
|
| 5188 | + | (st, String::from_utf8_lossy(&b).into_owned()) |
|
| 5189 | + | } |
|
| 5190 | + | }; |
|
| 5191 | + | let lock = r#"{"ID":"lock-1","Operation":"OperationTypeApply","Who":"flow@runner","Version":"1.8.0"}"#; |
|
| 5192 | + | // No state yet; no token, nothing. |
|
| 5193 | + | assert_eq!(send("GET", path.into(), None, "").await.0, StatusCode::UNAUTHORIZED); |
|
| 5194 | + | assert_eq!(send("GET", path.into(), Some(basic.clone()), "").await.0, StatusCode::NO_CONTENT); |
|
| 5195 | + | // Lock, write under the lock, unlock. |
|
| 5196 | + | let (st, body) = send("LOCK", path.into(), Some(basic.clone()), lock).await; |
|
| 5197 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 5198 | + | let other_lock = r#"{"ID":"lock-2","Operation":"OperationTypePlan"}"#; |
|
| 5199 | + | let (st, held) = send("LOCK", path.into(), Some(basic.clone()), other_lock).await; |
|
| 5200 | + | assert_eq!(st, StatusCode::LOCKED); |
|
| 5201 | + | assert!(held.contains("lock-1"), "the holder's lock info: {held}"); |
|
| 5202 | + | let state1 = r#"{"version":4,"serial":1,"lineage":"l","resources":[{"secret":"hunter2"}]}"#; |
|
| 5203 | + | assert_eq!(send("POST", path.into(), Some(basic.clone()), state1).await.0, StatusCode::LOCKED, "not under its lock"); |
|
| 5204 | + | assert_eq!(send("POST", format!("{path}?ID=lock-1"), Some(basic.clone()), state1).await.0, StatusCode::OK); |
|
| 5205 | + | assert_eq!(send("UNLOCK", path.into(), Some(basic.clone()), other_lock).await.0, StatusCode::CONFLICT, "not its lock"); |
|
| 5206 | + | assert_eq!(send("UNLOCK", path.into(), Some(basic.clone()), lock).await.0, StatusCode::OK); |
|
| 5207 | + | let (st, got) = send("GET", path.into(), Some(basic.clone()), "").await; |
|
| 5208 | + | assert_eq!((st, got.as_str()), (StatusCode::OK, state1)); |
|
| 5209 | + | // A second write keeps the first as a version; the body is sealed. |
|
| 5210 | + | let state2 = state1.replace("\"serial\":1", "\"serial\":2"); |
|
| 5211 | + | assert_eq!(send("POST", path.into(), Some(basic.clone()), &state2).await.0, StatusCode::OK); |
|
| 5212 | + | let row = db::repos::get(&h.app.db, "andy", "infra").await.unwrap().unwrap(); |
|
| 5213 | + | assert_eq!(db::terraform::versions(&h.app.db, row.id, "prod").await.unwrap(), 1); |
|
| 5214 | + | let kept = db::terraform::get(&h.app.db, row.id, "prod").await.unwrap().unwrap(); |
|
| 5215 | + | assert_eq!(kept.serial, 2); |
|
| 5216 | + | assert!(!String::from_utf8_lossy(&kept.body).contains("hunter2"), "sealed at rest"); |
|
| 5217 | + | // Another repository's state is not this job's; a bad name is refused. |
|
| 5218 | + | let (st, _) = send("GET", "/api/v1/repos/andy/other/terraform/state/prod".into(), Some(basic.clone()), "").await; |
|
| 5219 | + | assert_eq!(st, StatusCode::FORBIDDEN); |
|
| 5220 | + | assert_eq!( |
|
| 5221 | + | send("GET", "/api/v1/repos/andy/infra/terraform/state/..x".into(), Some(basic.clone()), "").await.0, |
|
| 5222 | + | StatusCode::BAD_REQUEST |
|
| 5223 | + | ); |
|
| 5224 | + | // The site token is no person's: refused. |
|
| 5225 | + | let (st, _) = send("GET", path.into(), Some("Bearer site".into()), "").await; |
|
| 5226 | + | assert_eq!(st, StatusCode::UNAUTHORIZED, "the site token is not a person's token"); |
|
| 5227 | + | // A job's identity token for the Terraform audience, as its pipeline's |
|
| 5228 | + | // identity block mounts it: its own repository only, that audience only. |
|
| 5229 | + | let issuer = h.app.oidc().await.unwrap(); |
|
| 5230 | + | let now = db::now_ms(); |
|
| 5231 | + | let aud = oxygenhub::terraform::audience(h.app.config.base_url()); |
|
| 5232 | + | let own = issuer.mint_for(h.app.config.base_url(), "repo:andy/infra:bookmark:main", &aud, now, 600); |
|
| 5233 | + | let (st, got) = send("GET", path.into(), Some(format!("Bearer {own}")), "").await; |
|
| 5234 | + | assert_eq!((st, got.contains("\"serial\":2")), (StatusCode::OK, true)); |
|
| 5235 | + | let other = issuer.mint_for(h.app.config.base_url(), "repo:andy/other:bookmark:main", &aud, now, 600); |
|
| 5236 | + | assert_eq!(send("GET", path.into(), Some(format!("Bearer {other}")), "").await.0, StatusCode::FORBIDDEN); |
|
| 5237 | + | let elsewhere = issuer.mint_for(h.app.config.base_url(), "repo:andy/infra:bookmark:main", "sts.amazonaws.com", now, 600); |
|
| 5238 | + | assert_eq!(send("GET", path.into(), Some(format!("Bearer {elsewhere}")), "").await.0, StatusCode::UNAUTHORIZED); |
|
| 5239 | + | } |
|
| 5240 | + | ||
| 5241 | + | /// The real OpenTofu against the backend (oxygenhub#252): init, apply, |
|
| 5242 | + | /// a second apply that changes nothing, all through the hub's state with a |
|
| 5243 | + | /// job's token. Needs Docker and the `oxygenflow-tofu` image; run with |
|
| 5244 | + | /// `cargo test -p oxygenhub --test flow -- --ignored tofu_keeps`. |
|
| 5245 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 5246 | + | #[ignore] |
|
| 5247 | + | async fn tofu_keeps_its_state_on_the_hub() { |
|
| 5248 | + | let h = hub().await; |
|
| 5249 | + | create(&h.app, "andy", "infra").await; |
|
| 5250 | + | let work = tempfile::tempdir().unwrap(); |
|
| 5251 | + | let c = client(&format!("{}/andy/infra", h.base), work.path()); |
|
| 5252 | + | write(work.path(), ".flow/pipeline.kdl", &pipeline("")); |
|
| 5253 | + | write(work.path(), "test.sh", "echo ok\n"); |
|
| 5254 | + | c.snapshot("test").unwrap(); |
|
| 5255 | + | c.commit("Add a pipeline", &[]).unwrap(); |
|
| 5256 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 5257 | + | assert_eq!(runs_of(&h.app, "andy", "infra").await.len(), 1); |
|
| 5258 | + | let job = claim(&h.app).await.expect("a job"); |
|
| 5259 | + | let token = job["token"].as_str().unwrap().to_string(); |
|
| 5260 | + | let tf = tempfile::tempdir().unwrap(); |
|
| 5261 | + | let address = format!("{}/api/v1/repos/andy/infra/terraform/state/prod", h.base); |
|
| 5262 | + | std::fs::write( |
|
| 5263 | + | tf.path().join("main.tf"), |
|
| 5264 | + | format!( |
|
| 5265 | + | "terraform {{\n backend \"http\" {{\n address = \"{address}\"\n lock_address = \"{address}\"\n unlock_address = \"{address}\"\n username = \"flow\"\n }}\n}}\nresource \"terraform_data\" \"x\" {{\n input = \"hello\"\n}}\n" |
|
| 5266 | + | ), |
|
| 5267 | + | ) |
|
| 5268 | + | .unwrap(); |
|
| 5269 | + | let tofu = |args: &[&str]| { |
|
| 5270 | + | let out = std::process::Command::new("docker") |
|
| 5271 | + | .args(["run", "--rm", "--network", "host", "-v"]) |
|
| 5272 | + | .arg(format!("{}:/w", tf.path().display())) |
|
| 5273 | + | .args(["-w", "/w", "-e"]) |
|
| 5274 | + | .arg(format!("TF_HTTP_PASSWORD={token}")) |
|
| 5275 | + | .args(["--entrypoint", "tofu", "oxygenflow-tofu:latest"]) |
|
| 5276 | + | .args(args) |
|
| 5277 | + | .output() |
|
| 5278 | + | .expect("docker"); |
|
| 5279 | + | let text = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); |
|
| 5280 | + | assert!(out.status.success(), "tofu {args:?}: {text}"); |
|
| 5281 | + | text |
|
| 5282 | + | }; |
|
| 5283 | + | tofu(&["init", "-input=false", "-no-color"]); |
|
| 5284 | + | let applied = tofu(&["apply", "-auto-approve", "-input=false", "-no-color"]); |
|
| 5285 | + | assert!(applied.contains("1 added"), "{applied}"); |
|
| 5286 | + | let again = tofu(&["apply", "-auto-approve", "-input=false", "-no-color"]); |
|
| 5287 | + | assert!(again.contains("No changes"), "the state was kept: {again}"); |
|
| 5288 | + | let row = db::repos::get(&h.app.db, "andy", "infra").await.unwrap().unwrap(); |
|
| 5289 | + | let kept = db::terraform::get(&h.app.db, row.id, "prod").await.unwrap().unwrap(); |
|
| 5290 | + | assert!(kept.lock_id.is_empty(), "unlocked after each run"); |
|
| 5291 | + | assert!(kept.serial >= 1); |
|
| 5292 | + | } |
|
| 5293 | + | ||
| 5156 | 5294 | /// Keyless step 4c (oxygenhub#241): when a key the hub certified moves a |
|
| 5157 | 5295 | /// bookmark, the hub signs when it accepted the update and serves that |
|
| 5158 | 5296 | /// with the bookmark, so a fetch checks the certificate at the hub's time |
|
| … | 181 unchanged lines |
replaced deps/oxygenflow +0 −0
No line changes.
modified docs/spec.md +17 −0
| … | 618 unchanged lines |
||
| 619 | 619 | ``` |
|
| 620 | 620 | - **Not yet:** proofs in push responses, the `oxy witness` daemon, and trusted time (docs/transparency.md). Backups do not copy the log yet. |
|
| 621 | 621 | ||
| 622 | + | ### 13.5.4 Terraform state (oxygenhub#252, built 2026-10-18) |
|
| 623 | + | ||
| 624 | + | The hub keeps Terraform and OpenTofu state per repository, so a pipeline needs no bucket or cloud credential just to hold it. It speaks their HTTP backend at `/api/v1/repos/OWNER/NAME/terraform/state/STATE` (STATE: letters, digits, `-`, `_`, `.`, up to 100): |
|
| 625 | + | - **GET** the state (204 when there is none); |
|
| 626 | + | - **POST** a new one: JSON, up to 32 MiB, with `?ID=` the lock's ID while it is locked; |
|
| 627 | + | - **DELETE** it; |
|
| 628 | + | - **LOCK** with the lock's JSON: 423 and the holder's lock when someone else holds it, 200 to its holder again; |
|
| 629 | + | - **UNLOCK** with the lock's JSON or `?ID=`: 409 for another lock's. |
|
| 630 | + | ||
| 631 | + | **Who may use it:** |
|
| 632 | + | - a job, with an identity token of this hub's for the audience `{base_url}/terraform` (its pipeline's `identity` block) or with its runner's job token, for its own repository only; |
|
| 633 | + | - a person, with a token of theirs (`repo:read` to read, `repo:write` to change) and the repository role to match. |
|
| 634 | + | ||
| 635 | + | The token goes as the bearer or as Basic auth's password, which is what the backend sends (`TF_HTTP_PASSWORD`). |
|
| 636 | + | ||
| 637 | + | **Storage:** each state is sealed at rest with the hub's secrets key, and the last 20 versions before it are kept (migration 0079: `terraform_states`, `terraform_state_versions`). Backups carry the sealed bodies, which open only with this hub's key. A repository's Settings › Terraform state lists its states, serials, writers, locks and versions, with the backend block to use. Not for the hub's own infrastructure (oxygenhub-gcp), whose state cannot live on the hub it deploys. |
|
| 638 | + | ||
| 622 | 639 | ### 13.6 Coverage reports (oxygenhub#113, done 2026-10-06) |
|
| 623 | 640 | ||
| 624 | 641 | Coverage is built in, as Coveralls or Codecov would be, fed by OxygenFlow. |
|
| … | 167 unchanged lines |
modified templates/repo_settings.html +17 −0
| … | 151 unchanged lines |
||
| 152 | 152 | </table></div>{% endif %} |
|
| 153 | 153 | <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> |
|
| 154 | 154 | </section> |
|
| 155 | + | <section class="panel" id="terraform"> |
|
| 156 | + | <h2>Terraform state</h2> |
|
| 157 | + | <p class="small">The hub keeps this repository's Terraform and OpenTofu state, sealed at rest, with its last {{ crate::terraform::VERSIONS_KEPT }} versions. A pipeline's job uses it with an identity token of this hub's (no cloud credential): give the job <code>identity "{{ shell.base_url }}/terraform" {% raw %}{{% endraw %} mount-path "/run/secrets/tf" {% raw %}}{% endraw %}</code>, and <code>TF_HTTP_PASSWORD=$(cat /run/secrets/tf)</code> beside:</p> |
|
| 158 | + | <pre class="snippet">terraform { |
|
| 159 | + | backend "http" { |
|
| 160 | + | address = "{{ shell.base_url }}/api/v1/repos/{{ row.owner }}/{{ row.name }}/terraform/state/NAME" |
|
| 161 | + | lock_address = "{{ shell.base_url }}/api/v1/repos/{{ row.owner }}/{{ row.name }}/terraform/state/NAME" |
|
| 162 | + | unlock_address = "{{ shell.base_url }}/api/v1/repos/{{ row.owner }}/{{ row.name }}/terraform/state/NAME" |
|
| 163 | + | username = "flow" |
|
| 164 | + | } |
|
| 165 | + | }</pre> |
|
| 166 | + | {% if tf_states.is_empty() %}<p class="mute small">No state yet.</p>{% else %} |
|
| 167 | + | <div class="table"><table> |
|
| 168 | + | <tr><th>State</th><th>Serial</th><th>Last written</th><th>Lock</th><th>Versions kept</th></tr> |
|
| 169 | + | {% for (s, n) in tf_states %}<tr><td><strong>{{ s.name }}</strong></td><td>{{ s.serial }}</td><td class="small">{% if s.updated_by.is_empty() %}<span class="mute">never</span>{% else %}by {{ s.updated_by }} <time title="{{ s.updated_ms|when_i }}">{{ s.updated_ms|ago_i }}</time>{% endif %}</td><td class="small">{% if s.lock_id.is_empty() %}<span class="mute">free</span>{% else %}held by {{ s.locked_by }}{% if let Some(t) = s.locked_ms %} <time title="{{ t|when_i }}">{{ t|ago_i }}</time>{% endif %}{% endif %}</td><td>{{ n }}</td></tr>{% endfor %} |
|
| 170 | + | </table></div>{% endif %} |
|
| 171 | + | </section> |
|
| 155 | 172 | {% 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.") %} |
|
| 156 | 173 | {% call va::panel(repo.url ~ "/settings", variables, shell.csrf, "This repository's jobs get these, beside the ones the site and its owner set.") %} |
|
| 157 | 174 | {% call se::panel(repo.url ~ "/settings", secrets, shell.csrf, "This repository's jobs may use these, beside the ones the site and its owner set.") %} |
|
| … | 100 unchanged lines |