OXYGENHUB

Terraform state: list, download and restore kept versions 67523e06ed86

The hub kept each state's last 20 versions but nothing showed or used
them. The Terraform state page now lists each state's versions (serial,
writer, when); writers download the current state or any version as a
.tfstate file; maintainers restore one, after a confirmation.

A restore writes the version again with a serial above the current one,
since OpenTofu and Terraform only take a state forward, and the state it
replaces is kept as a version: nothing is lost, and going forward again
is restoring the newer one. Refused while the state is locked; audited
(terraform.restored). The API: GET …/versions, GET …/versions/ID with
the write role, POST …/versions/ID/restore with the maintain role,
never by a job.

Closes #255
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 74a29e4320a5
When
· 2026-10-18 11:30 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
67523e06ed8663fb51a88ecf19fcd4a33047ec133002cca4116b88f561a72b54
Depends on
16ee5d19d220 42fa95540468 8a48dd830178 8aa5ef384ef1 8dcaeeb848c6 93be9ce4653e bb018142cca7 d23464c0ac60
Depended on by
e97500b70f9a
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
25 atoms, 0 tree operations, 8 files

Browse the tree at this change

modified crates/hub/src/api/mod.rs +6 −0
…
49 unchanged lines
50 50
            axum::routing::any(crate::terraform::state)
51 51
                .layer(axum::extract::DefaultBodyLimit::max(crate::terraform::MAX_STATE_BYTES + 4096)),
52 52
        )
53 +
        .route("/repos/{owner}/{name}/terraform/state/{state}/versions", axum::routing::get(crate::terraform::versions))
54 +
        .route("/repos/{owner}/{name}/terraform/state/{state}/versions/{id}", axum::routing::get(crate::terraform::version))
55 +
        .route(
56 +
            "/repos/{owner}/{name}/terraform/state/{state}/versions/{id}/restore",
57 +
            axum::routing::post(crate::terraform::restore_version),
58 +
        )
53 59
        .route("/agents/grants/{id}", axum::routing::delete(crate::keyless::grants_revoke))
54 60
        .route("/flow/claim", post(crate::flow::claim))
55 61
        .route("/flow/runners/register", post(crate::flow::register))
…
2634 unchanged lines
modified crates/hub/src/db/terraform.rs +30 −0
…
34 unchanged lines
35 35
        .await
36 36
}
37 37
38 +
/// A kept version of a state (#255), without its body.
39 +
#[derive(Clone, Debug, FromRow)]
40 +
pub struct TfVersion {
41 +
    pub id: i64,
42 +
    pub serial: i64,
43 +
    pub created_by: String,
44 +
    pub created_ms: i64,
45 +
}
46 +
47 +
/// A state's kept versions, newest first.
48 +
pub async fn version_list(pool: &Pool, repo_id: i64, name: &str) -> sqlx::Result<Vec<TfVersion>> {
49 +
    sqlx::query_as(
50 +
        "SELECT id, serial, created_by, created_ms FROM terraform_state_versions WHERE repo_id = $1 AND name = $2 ORDER BY id DESC",
51 +
    )
52 +
    .bind(repo_id)
53 +
    .bind(name)
54 +
    .fetch_all(pool)
55 +
    .await
56 +
}
57 +
58 +
/// One kept version's sealed body and serial.
59 +
pub async fn version_body(pool: &Pool, repo_id: i64, name: &str, id: i64) -> sqlx::Result<Option<(Vec<u8>, i64)>> {
60 +
    sqlx::query_as("SELECT body, serial FROM terraform_state_versions WHERE repo_id = $1 AND name = $2 AND id = $3")
61 +
        .bind(repo_id)
62 +
        .bind(name)
63 +
        .bind(id)
64 +
        .fetch_optional(pool)
65 +
        .await
66 +
}
67 +
38 68
/// Store a new body; the one it replaces becomes a version, and only the
39 69
/// last `keep` versions stay.
40 70
pub async fn put(pool: &Pool, repo_id: i64, name: &str, body: &[u8], serial: i64, by: &str, keep: i64) -> sqlx::Result<()> {
…
72 unchanged lines
modified crates/hub/src/terraform.rs +138 −1
…
9 unchanged lines
10 10
//!
11 11
//! State is kept outside the repository's history (it often holds secrets),
12 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.
13 +
//! kept, so a bad apply can be rolled back: a kept version is restored as a
14 +
//! new one (#255), so nothing is lost and going forward again is restoring
15 +
//! a newer one.
14 16
15 17
use crate::api::ApiError;
16 18
use crate::auth::roles::{Access, Role};
…
137 unchanged lines
154 156
    serde_json::from_slice::<serde_json::Value>(info).ok()?.get("ID")?.as_str().map(String::from)
155 157
}
156 158
159 +
/// Why a restore did not happen.
160 +
pub enum RestoreError {
161 +
    /// No such version of that state.
162 +
    NotFound,
163 +
    /// Someone holds the state's lock: who.
164 +
    Locked(String),
165 +
    /// The kept body does not open or is not JSON.
166 +
    Broken,
167 +
    Db(sqlx::Error),
168 +
}
169 +
170 +
impl RestoreError {
171 +
    pub fn message(&self) -> String {
172 +
        match self {
173 +
            RestoreError::NotFound => "no such version of this state".into(),
174 +
            RestoreError::Locked(by) => format!("the state is locked by {by}: restore it once the lock is released"),
175 +
            RestoreError::Broken => "that version does not open: it cannot be restored".into(),
176 +
            RestoreError::Db(e) => e.to_string(),
177 +
        }
178 +
    }
179 +
180 +
    fn status(&self) -> StatusCode {
181 +
        match self {
182 +
            RestoreError::NotFound => StatusCode::NOT_FOUND,
183 +
            RestoreError::Locked(_) => StatusCode::LOCKED,
184 +
            RestoreError::Broken | RestoreError::Db(_) => StatusCode::INTERNAL_SERVER_ERROR,
185 +
        }
186 +
    }
187 +
}
188 +
189 +
/// Restore kept version `id` of a state (#255): written as a new version
190 +
/// whose serial is above the current one (OpenTofu and Terraform take a
191 +
/// state only forward), so the state it replaces is kept as a version too.
192 +
/// Refused while the state is locked. The new serial.
193 +
pub async fn restore(app: &Shared, repo_id: i64, state: &str, id: i64, by: &str) -> Result<i64, RestoreError> {
194 +
    let current = db::terraform::get(&app.db, repo_id, state).await.map_err(RestoreError::Db)?;
195 +
    if let Some(l) = current.as_ref().filter(|s| !s.lock_id.is_empty()) {
196 +
        return Err(RestoreError::Locked(l.locked_by.clone()));
197 +
    }
198 +
    let (body, old_serial) =
199 +
        db::terraform::version_body(&app.db, repo_id, state, id).await.map_err(RestoreError::Db)?.ok_or(RestoreError::NotFound)?;
200 +
    let plain = app.secret_key.open(&body).ok_or(RestoreError::Broken)?;
201 +
    let mut json: serde_json::Value = serde_json::from_str(&plain).map_err(|_| RestoreError::Broken)?;
202 +
    let serial = current.map_or(0, |c| c.serial).max(old_serial) + 1;
203 +
    json["serial"] = serial.into();
204 +
    let text = serde_json::to_string_pretty(&json).map_err(|_| RestoreError::Broken)?;
205 +
    let label = format!("{by}, restoring serial {old_serial}");
206 +
    db::terraform::put(&app.db, repo_id, state, &app.secret_key.seal(&text), serial, &label, VERSIONS_KEPT)
207 +
        .await
208 +
        .map_err(RestoreError::Db)?;
209 +
    Ok(serial)
210 +
}
211 +
212 +
/// `GET /api/v1/repos/{owner}/{name}/terraform/state/{state}/versions`:
213 +
/// the state's kept versions, newest first (#255).
214 +
pub async fn versions(
215 +
    State(app): State<Shared>,
216 +
    headers: HeaderMap,
217 +
    Path((owner, name, state)): Path<(String, String, String)>,
218 +
) -> Result<Response, ApiError> {
219 +
    let (row, _who) = versions_caller(&app, &headers, &owner, &name, &state, Access::Write).await?;
220 +
    let list =
221 +
        db::terraform::version_list(&app.db, row.id, &state).await.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
222 +
    let out: Vec<serde_json::Value> = list
223 +
        .iter()
224 +
        .map(|v| serde_json::json!({ "id": v.id, "serial": v.serial, "written_by": v.created_by, "written_ms": v.created_ms }))
225 +
        .collect();
226 +
    Ok(json(StatusCode::OK, serde_json::Value::Array(out).to_string()))
227 +
}
228 +
229 +
/// `GET …/terraform/state/{state}/versions/{id}`: one kept version's state.
230 +
pub async fn version(
231 +
    State(app): State<Shared>,
232 +
    headers: HeaderMap,
233 +
    Path((owner, name, state, id)): Path<(String, String, String, i64)>,
234 +
) -> Result<Response, ApiError> {
235 +
    let (row, _who) = versions_caller(&app, &headers, &owner, &name, &state, Access::Write).await?;
236 +
    let (body, _) = db::terraform::version_body(&app.db, row.id, &state, id)
237 +
        .await
238 +
        .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
239 +
        .ok_or_else(|| err(StatusCode::NOT_FOUND, "no such version of this state"))?;
240 +
    let plain = app.secret_key.open(&body).ok_or_else(|| err(StatusCode::INTERNAL_SERVER_ERROR, "the version does not open"))?;
241 +
    Ok(json(StatusCode::OK, plain))
242 +
}
243 +
244 +
/// `POST …/terraform/state/{state}/versions/{id}/restore`: restore a kept
245 +
/// version, by a person with the maintain role (never a job), audited.
246 +
pub async fn restore_version(
247 +
    State(app): State<Shared>,
248 +
    headers: HeaderMap,
249 +
    Path((owner, name, state, id)): Path<(String, String, String, i64)>,
250 +
) -> Result<Response, ApiError> {
251 +
    let (row, who) = versions_caller(&app, &headers, &owner, &name, &state, Access::Maintain).await?;
252 +
    let Who::Person { account, .. } = &who else {
253 +
        return Err(err(StatusCode::FORBIDDEN, "a job does not restore a state: a person with the maintain role does"));
254 +
    };
255 +
    let serial = restore(&app, row.id, &state, id, &account.username).await.map_err(|e| err(e.status(), e.message()))?;
256 +
    db::accounts::audit(
257 +
        &app.db,
258 +
        Some(account),
259 +
        "terraform.restored",
260 +
        &format!("{}:{state}", row.slug()),
261 +
        &format!("version {id}, now serial {serial}"),
262 +
    )
263 +
    .await
264 +
    .ok();
265 +
    Ok(json(StatusCode::OK, serde_json::json!({ "serial": serial }).to_string()))
266 +
}
267 +
268 +
/// The repository and the caller of a versions endpoint, allowed `access`.
269 +
async fn versions_caller(
270 +
    app: &Shared,
271 +
    headers: &HeaderMap,
272 +
    owner: &str,
273 +
    name: &str,
274 +
    state: &str,
275 +
    access: Access,
276 +
) -> Result<(db::repos::RepoRow, Who), ApiError> {
277 +
    if !valid_name(state) {
278 +
        return Err(err(StatusCode::BAD_REQUEST, "a state's name is letters, digits, '-', '_' and '.', up to 100"));
279 +
    }
280 +
    let row = db::repos::get(&app.db, owner, name)
281 +
        .await
282 +
        .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
283 +
        .ok_or_else(|| err(StatusCode::NOT_FOUND, "no such repository"))?;
284 +
    let who = who(app, headers, &row).await?;
285 +
    if !who.may(access) {
286 +
        return Err(err(
287 +
            StatusCode::FORBIDDEN,
288 +
            if access == Access::Maintain { "restoring a state needs the maintain role" } else { "a Terraform state needs the write role" },
289 +
        ));
290 +
    }
291 +
    Ok((row, who))
292 +
}
293 +
157 294
/// `GET|POST|DELETE|LOCK|UNLOCK /api/v1/repos/{owner}/{name}/terraform/state/{state}`.
158 295
pub async fn state(
159 296
    State(app): State<Shared>,
…
88 unchanged lines
modified crates/hub/src/web/artifacts.rs +109 −10
…
1 unchanged lines
2 2
//! artifacts in one list, the runner images its jobs use, and its
3 3
//! Terraform state. Releases and reports keep their own pages.
4 4
5 -
use super::{filters, lookup, Error, Page, RepoShell, Shell};
6 -
use crate::auth::Viewer;
5 +
use super::settings::{csrf, require};
6 +
use super::{filters, lookup, lookup_role, Error, Page, RepoShell, Shell};
7 +
use crate::auth::{Access, Viewer};
7 8
use crate::db::flow as fdb;
8 9
use crate::{db, Shared};
9 10
use askama::Template;
10 -
use axum::extract::{Path, State};
11 +
use axum::extract::{Form, Path, Query, State};
12 +
use axum::http::header;
13 +
use axum::response::{IntoResponse, Response};
14 +
use serde::Deserialize;
11 15
12 16
/// At most this many artifacts are listed, newest first.
13 17
const ARTIFACTS_SHOWN: i64 = 200;
…
62 unchanged lines
76 80
    Ok(Page(ImagesPage { shell, repo: RepoShell::new(&app, &row, "images").with_role(role), images, days: IMAGE_DAYS }))
77 81
}
78 82
83 +
/// A state with its kept versions, newest first.
84 +
pub struct StateView {
85 +
    pub s: db::terraform::TfState,
86 +
    pub versions: Vec<db::terraform::TfVersion>,
87 +
}
88 +
79 89
#[derive(Template)]
80 90
#[template(path = "repo_terraform.html")]
81 91
pub struct TerraformPage {
82 92
    shell: Shell,
83 93
    repo: RepoShell,
84 -
    tf_states: Vec<(db::terraform::TfState, i64)>,
94 +
    states: Vec<StateView>,
95 +
    notice: Option<String>,
96 +
    error: Option<String>,
97 +
}
98 +
99 +
async fn terraform_page(
100 +
    app: &Shared,
101 +
    shell: Shell,
102 +
    row: db::repos::RepoRow,
103 +
    role: crate::auth::Role,
104 +
    notice: Option<String>,
105 +
    error: Option<String>,
106 +
) -> Result<Page<TerraformPage>, Error> {
107 +
    let e = |e| Error::from_db(shell.clone(), e);
108 +
    let mut states = Vec::new();
109 +
    // States hold secrets (#256): only writers see them listed.
110 +
    if crate::auth::roles::allows(Some(role), Access::Write) {
111 +
        for s in db::terraform::of_repo(&app.db, row.id).await.map_err(e)? {
112 +
            let versions = db::terraform::version_list(&app.db, row.id, &s.name).await.map_err(e)?;
113 +
            states.push(StateView { s, versions });
114 +
        }
115 +
    }
116 +
    Ok(Page(TerraformPage { shell, repo: RepoShell::new(app, &row, "terraform").with_role(role), states, notice, error }))
85 117
}
86 118
87 119
/// `GET /{owner}/{name}/terraform`: the states the hub keeps for this
88 -
/// repository (oxygenhub#252), and the backend block that uses them.
120 +
/// repository (oxygenhub#252), their versions, and the backend block.
89 121
pub async fn terraform(
90 122
    State(app): State<Shared>,
91 123
    viewer: Viewer,
…
1 unchanged lines
93 125
) -> Result<Page<TerraformPage>, Error> {
94 126
    let shell = Shell::new(&app, &format!("/{owner}/{name}/terraform"), &viewer);
95 127
    let (_repo, row, role) = lookup(&app, &shell, &viewer, &owner, &name).await?;
128 +
    terraform_page(&app, shell, row, role, None, None).await
129 +
}
130 +
131 +
#[derive(Deserialize)]
132 +
pub struct DownloadQuery {
133 +
    version: Option<i64>,
134 +
}
135 +
136 +
/// `GET /{owner}/{name}/terraform/{state}/download[?version=ID]`: the
137 +
/// current state, or a kept version, for people with the write role.
138 +
pub async fn download(
139 +
    State(app): State<Shared>,
140 +
    viewer: Viewer,
141 +
    Path((owner, name, state)): Path<(String, String, String)>,
142 +
    Query(q): Query<DownloadQuery>,
143 +
) -> Result<Response, Error> {
144 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/terraform"), &viewer);
145 +
    let (_repo, row, _role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Write).await?;
96 146
    let e = |e| Error::from_db(shell.clone(), e);
97 -
    let mut tf_states = Vec::new();
98 -
    for s in db::terraform::of_repo(&app.db, row.id).await.map_err(e)? {
99 -
        let n = db::terraform::versions(&app.db, row.id, &s.name).await.map_err(e)?;
100 -
        tf_states.push((s, n));
147 +
    let (body, serial) = match q.version {
148 +
        Some(id) => db::terraform::version_body(&app.db, row.id, &state, id).await.map_err(e)?,
149 +
        None => db::terraform::get(&app.db, row.id, &state).await.map_err(e)?.filter(|s| !s.body.is_empty()).map(|s| (s.body, s.serial)),
150 +
    }
151 +
    .ok_or_else(|| Error::not_found(shell.clone()))?;
152 +
    let plain = app.secret_key.open(&body).ok_or_else(|| Error::not_found(shell.clone()))?;
153 +
    let file = format!("{}-serial-{serial}.tfstate", state.replace(|c: char| !(c.is_ascii_alphanumeric() || "-_.".contains(c)), "_"));
154 +
    Ok((
155 +
        [
156 +
            (header::CONTENT_TYPE, "application/json".to_string()),
157 +
            (header::CONTENT_DISPOSITION, format!("attachment; filename=\"{file}\"")),
158 +
            (header::CACHE_CONTROL, "no-store".to_string()),
159 +
        ],
160 +
        plain,
161 +
    )
162 +
        .into_response())
163 +
}
164 +
165 +
#[derive(Deserialize)]
166 +
pub struct RestoreForm {
167 +
    #[serde(rename = "_csrf")]
168 +
    csrf_token: String,
169 +
    version: i64,
170 +
}
171 +
172 +
/// `POST /{owner}/{name}/terraform/{state}/restore` (#255): restore a kept
173 +
/// version, by someone with the maintain role, audited.
174 +
pub async fn restore(
175 +
    State(app): State<Shared>,
176 +
    viewer: Viewer,
177 +
    Path((owner, name, state)): Path<(String, String, String)>,
178 +
    Form(f): Form<RestoreForm>,
179 +
) -> Result<Page<TerraformPage>, Error> {
180 +
    let shell = Shell::new(&app, &format!("/{owner}/{name}/terraform"), &viewer);
181 +
    let me = require(&viewer, &shell)?;
182 +
    let (_repo, row, role) = lookup_role(&app, &shell, &viewer, &owner, &name, Access::Maintain).await?;
183 +
    csrf(&viewer, &f.csrf_token, &shell)?;
184 +
    match crate::terraform::restore(&app, row.id, &state, f.version, &me.account.username).await {
185 +
        Ok(serial) => {
186 +
            db::accounts::audit(
187 +
                &app.db,
188 +
                Some(&me.account),
189 +
                "terraform.restored",
190 +
                &format!("{owner}/{name}:{state}"),
191 +
                &format!("version {}, now serial {serial}", f.version),
192 +
            )
193 +
            .await
194 +
            .ok();
195 +
            let msg = format!(
196 +
                "Restored {state}: it is now serial {serial}. The state it replaced is kept as a version, so you can go back to it."
197 +
            );
198 +
            terraform_page(&app, shell, row, role, Some(msg), None).await
199 +
        }
200 +
        Err(e) => terraform_page(&app, shell, row, role, None, Some(format!("Could not restore {state}: {}.", e.message()))).await,
101 201
    }
102 -
    Ok(Page(TerraformPage { shell, repo: RepoShell::new(&app, &row, "terraform").with_role(role), tf_states }))
103 202
}
modified crates/hub/src/web/mod.rs +2 −0
…
209 unchanged lines
210 210
        .route("/{owner}/{name}/artifacts", get(artifacts::artifacts))
211 211
        .route("/{owner}/{name}/images", get(artifacts::images))
212 212
        .route("/{owner}/{name}/terraform", get(artifacts::terraform))
213 +
        .route("/{owner}/{name}/terraform/{state}/download", get(artifacts::download))
214 +
        .route("/{owner}/{name}/terraform/{state}/restore", post(artifacts::restore))
213 215
        .route("/{owner}/{name}/conflicts", get(refs::conflicts))
214 216
        .route("/{owner}/{name}/trust", get(refs::trust))
215 217
        .route("/{owner}/{name}/pushes", get(refs::pushes))
…
619 unchanged lines
modified crates/hub/tests/flow.rs +63 −1
…
5263 unchanged lines
5264 5264
        .unwrap();
5265 5265
    let cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap().split(';').next().unwrap().to_string();
5266 5266
    let res = oxygenhub::router(h.app.clone())
5267 -
        .oneshot(Request::get("/andy/infra/terraform").header(header::COOKIE, cookie).body(Body::empty()).unwrap())
5267 +
        .oneshot(Request::get("/andy/infra/terraform").header(header::COOKIE, cookie.clone()).body(Body::empty()).unwrap())
5268 5268
        .await
5269 5269
        .unwrap();
5270 5270
    let st = res.status();
…
1 unchanged lines
5272 5272
    assert_eq!(st, StatusCode::OK, "{page}");
5273 5273
    assert!(page.contains("<strong>prod</strong>") && page.contains("/api/v1/repos/andy/infra/terraform/state/NAME"), "{page}");
5274 5274
    assert!(page.contains("allow &quot;") || page.contains("allow \""), "the egress line names the hub");
5275 +
    assert!(page.contains("1 kept version") && page.contains(">restore</button>"), "its versions, restorable by its owner: {page}");
5276 +
5277 +
    // Versions (#255) through the API: listed and read with the write role,
5278 +
    // restored with the maintain role (never by a job, never under a lock),
5279 +
    // as a new serial, so nothing is lost and going forward again works.
5280 +
    let andy = db::accounts::account_by_name(&h.app.db, "andy").await.unwrap().unwrap();
5281 +
    let (pat, hash) = oxygenhub::auth::new_token();
5282 +
    db::accounts::create_token(&h.app.db, andy.id, "cli", &hash, "repo:write", "", None).await.unwrap();
5283 +
    let bearer = format!("Bearer {pat}");
5284 +
    let (st, list) = send("GET", format!("{path}/versions"), Some(bearer.clone()), "").await;
5285 +
    assert_eq!(st, StatusCode::OK, "{list}");
5286 +
    let list: serde_json::Value = serde_json::from_str(&list).unwrap();
5287 +
    assert_eq!((list.as_array().unwrap().len(), list[0]["serial"].as_i64()), (1, Some(1)), "{list}");
5288 +
    let first = list[0]["id"].as_i64().unwrap();
5289 +
    let (st, got) = send("GET", format!("{path}/versions/{first}"), Some(bearer.clone()), "").await;
5290 +
    assert_eq!((st, got.as_str()), (StatusCode::OK, state1));
5291 +
    assert_eq!(send("GET", format!("{path}/versions"), Some(format!("Bearer {secret}")), "").await.0, StatusCode::FORBIDDEN, "a stranger");
5292 +
    let restore = |id: i64, auth: String| send("POST", format!("{path}/versions/{id}/restore"), Some(auth), "");
5293 +
    assert_eq!(restore(first, basic.clone()).await.0, StatusCode::FORBIDDEN, "a job does not restore");
5294 +
    assert_eq!(restore(first, format!("Bearer {secret}")).await.0, StatusCode::FORBIDDEN, "nor a stranger");
5295 +
    assert_eq!(restore(9999, bearer.clone()).await.0, StatusCode::NOT_FOUND);
5296 +
    assert_eq!(send("LOCK", path.into(), Some(basic.clone()), lock).await.0, StatusCode::OK);
5297 +
    assert_eq!(restore(first, bearer.clone()).await.0, StatusCode::LOCKED, "not under someone's lock");
5298 +
    assert_eq!(send("UNLOCK", path.into(), Some(basic.clone()), lock).await.0, StatusCode::OK);
5299 +
    let (st, got) = restore(first, bearer.clone()).await;
5300 +
    assert_eq!((st, got.contains("\"serial\":3")), (StatusCode::OK, true), "{got}");
5301 +
    let (_, now) = send("GET", path.into(), Some(bearer.clone()), "").await;
5302 +
    let now: serde_json::Value = serde_json::from_str(&now).unwrap();
5303 +
    assert_eq!(
5304 +
        (now["serial"].as_i64(), now["lineage"].as_str(), now["resources"][0]["secret"].as_str()),
5305 +
        (Some(3), Some("l"), Some("hunter2"))
5306 +
    );
5307 +
    // Forward again: serial 2 is a kept version now.
5308 +
    let (_, list) = send("GET", format!("{path}/versions"), Some(bearer.clone()), "").await;
5309 +
    let list: serde_json::Value = serde_json::from_str(&list).unwrap();
5310 +
    let serials: Vec<i64> = list.as_array().unwrap().iter().map(|v| v["serial"].as_i64().unwrap()).collect();
5311 +
    assert_eq!(serials, vec![2, 1], "{list}");
5312 +
    let (st, got) = restore(list[0]["id"].as_i64().unwrap(), bearer.clone()).await;
5313 +
    assert_eq!((st, got.contains("\"serial\":4")), (StatusCode::OK, true), "{got}");
5314 +
5315 +
    // And from the page: download a version, restore one with the form.
5316 +
    let page_with = |req: axum::http::request::Builder, body: Body| {
5317 +
        let (app, cookie) = (h.app.clone(), cookie.clone());
5318 +
        async move {
5319 +
            let res = oxygenhub::router(app).oneshot(req.header(header::COOKIE, cookie).body(body).unwrap()).await.unwrap();
5320 +
            let st = res.status();
5321 +
            (st, String::from_utf8_lossy(&res.into_body().collect().await.unwrap().to_bytes()).into_owned())
5322 +
        }
5323 +
    };
5324 +
    let (st, file) = page_with(Request::get(format!("/andy/infra/terraform/prod/download?version={first}")), Body::empty()).await;
5325 +
    assert_eq!((st, file.as_str()), (StatusCode::OK, state1));
5326 +
    let (_, page) = page_with(Request::get("/andy/infra/terraform"), Body::empty()).await;
5327 +
    let csrf = page.split("name=\"_csrf\" value=\"").nth(1).unwrap().split('"').next().unwrap().to_string();
5328 +
    let (st, page) = page_with(
5329 +
        Request::post("/andy/infra/terraform/prod/restore").header(header::CONTENT_TYPE, "application/x-www-form-urlencoded"),
5330 +
        Body::from(format!("_csrf={csrf}&version={first}")),
5331 +
    )
5332 +
    .await;
5333 +
    assert_eq!(st, StatusCode::OK, "{page}");
5334 +
    assert!(page.contains("Restored prod: it is now serial 5."), "{page}");
5335 +
    let audit = db::accounts::audit_rows(&h.app.db, 50).await.unwrap();
5336 +
    assert_eq!(audit.iter().filter(|r| r.action == "terraform.restored").count(), 3, "every restore audited");
5275 5337
}
5276 5338
5277 5339
/// The real OpenTofu against the backend (oxygenhub#252): init, apply,
…
265 unchanged lines
modified docs/spec.md +3 −1
…
636 unchanged lines
637 637
638 638
The token goes as the bearer or as Basic auth's password, which is what the backend sends (`TF_HTTP_PASSWORD`).
639 639
640 -
**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 Artifacts › Terraform state page (`/{owner}/{name}/terraform`) 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.
640 +
**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 Artifacts › Terraform state page (`/{owner}/{name}/terraform`) lists its states, serials, writers, locks and versions, with the backend block to use.
641 641
642 +
**Versions (oxygenhub#255):** each state's kept versions are listed on that page (serial, writer, when), and the current state and each version download as `.tfstate` files, for people with the write role. A person with the maintain role restores a version: it is written again with a serial above the current one (OpenTofu and Terraform only take a state forward), and the state it replaces is kept as a version, so a restore never loses anything and going forward again is restoring the newer version. A restore is refused while the state is locked, asks for confirmation, and is audited (`terraform.restored`). The API: `GET …/terraform/state/STATE/versions` (newest first: `id`, `serial`, `written_by`, `written_ms`), `GET …/versions/ID` (the state) with the write role, and `POST …/versions/ID/restore` (the new `serial`) with the maintain role, never by a job. Not for the hub's own infrastructure (oxygenhub-gcp), whose state cannot live on the hub it deploys.
643 +
642 644
### 13.5.5 Metrics (oxygenhub#212, first slice 2026-10-18)
643 645
644 646
`GET /metrics` serves the hub's numbers in the Prometheus text format, only to a bearer of `[server] metrics_token` in hub.toml. While the token is unset the route answers 404. It reports:
…
178 unchanged lines
modified templates/repo_terraform.html +18 −5
…
3 unchanged lines
4 4
{% block bodyattrs %} data-repo="{{ repo.url }}"{% endblock %}
5 5
{% block tabs %}{% call m::tabs(repo, "terraform") %}{% endblock %}
6 6
{% block content %}
7 -
<div class="head"><h1>Terraform state <small>{{ tf_states.len() }}</small></h1></div>
7 +
<div class="head"><h1>Terraform state <small>{{ states.len() }}</small></h1></div>
8 8
<p>The hub keeps this repository's Terraform and OpenTofu state, sealed at rest, with its last {{ crate::terraform::VERSIONS_KEPT }} versions, outside the repository's history. Point a configuration at it with this backend block, naming the state in place of <code>NAME</code> (a repository may keep several):</p>
9 9
<pre class="snippet">terraform {
10 10
  backend "http" {
…
8 unchanged lines
19 19
<h2>From your machine</h2>
20 20
<p>Set <code>TF_HTTP_PASSWORD</code> to one of your tokens with <code>repo:write</code>, as someone with the write role here. Reading a state needs that too: states hold secrets.</p>
21 21
<h2>States</h2>
22 +
{% if let Some(n) = notice %}<p class="note">{{ n }}</p>{% endif %}
23 +
{% if let Some(e) = error %}<p class="error">{{ e }}</p>{% endif %}
22 24
{% if !repo.can(crate::auth::Access::Write) %}<p class="mute">The states are shown to people who can write to this repository.</p>
23 -
{% else if tf_states.is_empty() %}<p class="empty">No state yet: the first <code>tofu apply</code> through the backend makes one.</p>{% else %}
25 +
{% else if states.is_empty() %}<p class="empty">No state yet: the first <code>tofu apply</code> through the backend makes one.</p>{% else %}
24 26
<div class="table"><table>
25 -
<tr><th>State</th><th>Serial</th><th>Last written</th><th>Lock</th><th>Versions kept</th></tr>
26 -
{% 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 %}
27 -
</table></div>{% endif %}
27 +
<tr><th>State</th><th>Serial</th><th>Last written</th><th>Lock</th><th>Versions kept</th><th></th></tr>
28 +
{% for v in states %}<tr><td><strong>{{ v.s.name }}</strong></td><td>{% if v.s.body.is_empty() %}<span class="mute">deleted</span>{% else %}{{ v.s.serial }}{% endif %}</td><td class="small">{% if v.s.updated_by.is_empty() %}<span class="mute">never</span>{% else %}by {{ v.s.updated_by }} <time title="{{ v.s.updated_ms|when_i }}">{{ v.s.updated_ms|ago_i }}</time>{% endif %}</td><td class="small">{% if v.s.lock_id.is_empty() %}<span class="mute">free</span>{% else %}held by {{ v.s.locked_by }}{% if let Some(t) = v.s.locked_ms %} <time title="{{ t|when_i }}">{{ t|ago_i }}</time>{% endif %}{% endif %}</td><td>{{ v.versions.len() }}</td><td class="small">{% if !v.s.body.is_empty() %}<a href="{{ repo.url }}/terraform/{{ v.s.name|urlencode }}/download">download</a>{% endif %}</td></tr>{% endfor %}
29 +
</table></div>
30 +
{% for v in states %}{% if !v.versions.is_empty() %}
31 +
<details class="tf-versions">
32 +
  <summary><strong>{{ v.s.name }}</strong>: {{ v.versions.len() }} kept {% if v.versions.len() == 1 %}version{% else %}versions{% endif %}</summary>
33 +
  <p class="small mute">Each write keeps the state it replaced (the last {{ crate::terraform::VERSIONS_KEPT }}). Restoring one writes it again with the next serial, so OpenTofu and Terraform take it, and keeps the current state as a version: restoring never loses anything, and a later version can be restored in turn.</p>
34 +
  <div class="table"><table>
35 +
  <tr><th>Serial</th><th>Written by</th><th>When</th><th></th></tr>
36 +
  {% for k in v.versions %}<tr><td>{{ k.serial }}</td><td class="small">{{ k.created_by }}</td><td class="small"><time title="{{ k.created_ms|when_i }}">{{ k.created_ms|ago_i }}</time></td><td class="small"><a href="{{ repo.url }}/terraform/{{ v.s.name|urlencode }}/download?version={{ k.id }}">download</a>{% if repo.can_maintain() %} <form class="tiny" method="post" action="{{ repo.url }}/terraform/{{ v.s.name|urlencode }}/restore" data-confirm="Restore {{ v.s.name }} to serial {{ k.serial }}? It becomes the current state; the current one is kept as a version."><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><input type="hidden" name="version" value="{{ k.id }}"><button>restore</button></form>{% endif %}</td></tr>{% endfor %}
37 +
  </table></div>
38 +
</details>
39 +
{% endif %}{% endfor %}
40 +
{% endif %}
28 41
{% endblock %}