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 "") || 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 %} |