OXYGENHUB

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