| 6 |
6 |
|
//! a removed tier is preserved silently — the FK is cleared by deleting the
|
| 7 |
7 |
|
//! parent last. If you actually need to forget a retired tier, do it by hand.
|
| 8 |
8 |
|
|
|
9 |
+ |
use crate::domain::AppId;
|
| 9 |
10 |
|
use crate::topology::Topology;
|
| 10 |
11 |
|
use anyhow::Result;
|
| 11 |
12 |
|
use sqlx::SqlitePool;
|
| 12 |
13 |
|
|
| 13 |
|
- |
pub async fn sync(pool: &SqlitePool, topo: &Topology) -> Result<()> {
|
|
14 |
+ |
/// Reconcile one product's topology into the tier and node tables.
|
|
15 |
+ |
///
|
|
16 |
+ |
/// Every statement is scoped to `app`. Without that, syncing one product would
|
|
17 |
+ |
/// treat every other product's tiers as removed from config and delete them:
|
|
18 |
+ |
/// the stale-row sweep asks "which rows are not in this TOML", and one TOML has
|
|
19 |
+ |
/// never described more than one product.
|
|
20 |
+ |
pub async fn sync(pool: &SqlitePool, app: &AppId, topo: &Topology) -> Result<()> {
|
| 14 |
21 |
|
let mut tx = pool.begin().await?;
|
| 15 |
22 |
|
|
| 16 |
23 |
|
let want_tiers: Vec<&str> = topo.tiers.iter().map(|t| t.name.as_str()).collect();
|
| 25 |
32 |
|
.collect();
|
| 26 |
33 |
|
|
| 27 |
34 |
|
// Drop stale nodes first (FK to tiers).
|
| 28 |
|
- |
let existing_nodes: Vec<(String, String)> = sqlx::query_as("SELECT name, tier FROM nodes")
|
| 29 |
|
- |
.fetch_all(&mut *tx)
|
| 30 |
|
- |
.await?;
|
|
35 |
+ |
let existing_nodes: Vec<(String, String)> =
|
|
36 |
+ |
sqlx::query_as("SELECT name, tier FROM nodes WHERE app = ?")
|
|
37 |
+ |
.bind(app)
|
|
38 |
+ |
.fetch_all(&mut *tx)
|
|
39 |
+ |
.await?;
|
| 31 |
40 |
|
for (name, tier) in existing_nodes {
|
| 32 |
41 |
|
if !want_nodes.iter().any(|(t, n)| *t == tier && *n == name) {
|
| 33 |
|
- |
sqlx::query("DELETE FROM nodes WHERE name = ?")
|
|
42 |
+ |
sqlx::query("DELETE FROM nodes WHERE app = ? AND name = ?")
|
|
43 |
+ |
.bind(app)
|
| 34 |
44 |
|
.bind(&name)
|
| 35 |
45 |
|
.execute(&mut *tx)
|
| 36 |
46 |
|
.await?;
|
| 40 |
50 |
|
// Drop stale tiers. tier_state rows referencing them are preserved by
|
| 41 |
51 |
|
// clearing the FK target only after a manual cleanup — for now we just
|
| 42 |
52 |
|
// refuse to delete a tier that still has tier_state with non-null version.
|
| 43 |
|
- |
let existing_tiers: Vec<String> = sqlx::query_scalar("SELECT name FROM tiers")
|
|
53 |
+ |
let existing_tiers: Vec<String> = sqlx::query_scalar("SELECT name FROM tiers WHERE app = ?")
|
|
54 |
+ |
.bind(app)
|
| 44 |
55 |
|
.fetch_all(&mut *tx)
|
| 45 |
56 |
|
.await?;
|
| 46 |
57 |
|
for t in existing_tiers {
|
| 47 |
58 |
|
if !want_tiers.contains(&t.as_str()) {
|
| 48 |
|
- |
let in_use: Option<String> =
|
| 49 |
|
- |
sqlx::query_scalar("SELECT current_version FROM tier_state WHERE tier = ?")
|
| 50 |
|
- |
.bind(&t)
|
| 51 |
|
- |
.fetch_optional(&mut *tx)
|
| 52 |
|
- |
.await?
|
| 53 |
|
- |
.flatten();
|
|
59 |
+ |
let in_use: Option<String> = sqlx::query_scalar(
|
|
60 |
+ |
"SELECT current_version FROM tier_state WHERE app = ? AND tier = ?",
|
|
61 |
+ |
)
|
|
62 |
+ |
.bind(app)
|
|
63 |
+ |
.bind(&t)
|
|
64 |
+ |
.fetch_optional(&mut *tx)
|
|
65 |
+ |
.await?
|
|
66 |
+ |
.flatten();
|
| 54 |
67 |
|
anyhow::ensure!(
|
| 55 |
68 |
|
in_use.is_none(),
|
| 56 |
|
- |
"refusing to remove tier {t} from config: tier_state still pins a version. \
|
| 57 |
|
- |
clean it up by hand before editing sando.toml.",
|
|
69 |
+ |
"refusing to remove tier {t} from app `{app}`'s config: tier_state still pins a \
|
|
70 |
+ |
version. clean it up by hand before editing the topology.",
|
| 58 |
71 |
|
);
|
| 59 |
|
- |
sqlx::query("DELETE FROM tier_state WHERE tier = ?")
|
|
72 |
+ |
sqlx::query("DELETE FROM tier_state WHERE app = ? AND tier = ?")
|
|
73 |
+ |
.bind(app)
|
| 60 |
74 |
|
.bind(&t)
|
| 61 |
75 |
|
.execute(&mut *tx)
|
| 62 |
76 |
|
.await?;
|
| 63 |
|
- |
sqlx::query("DELETE FROM tiers WHERE name = ?")
|
|
77 |
+ |
sqlx::query("DELETE FROM tiers WHERE app = ? AND name = ?")
|
|
78 |
+ |
.bind(app)
|
| 64 |
79 |
|
.bind(&t)
|
| 65 |
80 |
|
.execute(&mut *tx)
|
| 66 |
81 |
|
.await?;
|
| 71 |
86 |
|
// promotion sequence is queryable without re-reading the TOML.
|
| 72 |
87 |
|
for (i, t) in topo.tiers.iter().enumerate() {
|
| 73 |
88 |
|
sqlx::query(
|
| 74 |
|
- |
"INSERT INTO tiers (name, ord, provisioned, canary)
|
| 75 |
|
- |
VALUES (?, ?, ?, ?)
|
| 76 |
|
- |
ON CONFLICT(name) DO UPDATE SET
|
|
89 |
+ |
"INSERT INTO tiers (app, name, ord, provisioned, canary)
|
|
90 |
+ |
VALUES (?, ?, ?, ?, ?)
|
|
91 |
+ |
ON CONFLICT(app, name) DO UPDATE SET
|
| 77 |
92 |
|
ord = excluded.ord,
|
| 78 |
93 |
|
provisioned = excluded.provisioned,
|
| 79 |
94 |
|
canary = excluded.canary",
|
| 80 |
95 |
|
)
|
|
96 |
+ |
.bind(app)
|
| 81 |
97 |
|
.bind(&t.name)
|
| 82 |
98 |
|
.bind(i as i64)
|
| 83 |
99 |
|
.bind(t.provisioned as i64)
|
| 85 |
101 |
|
.execute(&mut *tx)
|
| 86 |
102 |
|
.await?;
|
| 87 |
103 |
|
|
| 88 |
|
- |
sqlx::query("INSERT OR IGNORE INTO tier_state (tier) VALUES (?)")
|
|
104 |
+ |
sqlx::query("INSERT OR IGNORE INTO tier_state (app, tier) VALUES (?, ?)")
|
|
105 |
+ |
.bind(app)
|
| 89 |
106 |
|
.bind(&t.name)
|
| 90 |
107 |
|
.execute(&mut *tx)
|
| 91 |
108 |
|
.await?;
|
| 92 |
109 |
|
|
| 93 |
110 |
|
for n in &t.nodes {
|
| 94 |
111 |
|
sqlx::query(
|
| 95 |
|
- |
"INSERT INTO nodes (name, tier, ssh_target, release_root)
|
| 96 |
|
- |
VALUES (?, ?, ?, ?)
|
| 97 |
|
- |
ON CONFLICT(name) DO UPDATE SET
|
|
112 |
+ |
"INSERT INTO nodes (app, name, tier, ssh_target, release_root)
|
|
113 |
+ |
VALUES (?, ?, ?, ?, ?)
|
|
114 |
+ |
ON CONFLICT(app, name) DO UPDATE SET
|
| 98 |
115 |
|
tier = excluded.tier,
|
| 99 |
116 |
|
ssh_target = excluded.ssh_target,
|
| 100 |
117 |
|
release_root = excluded.release_root",
|
| 101 |
118 |
|
)
|
|
119 |
+ |
.bind(app)
|
| 102 |
120 |
|
.bind(&n.name)
|
| 103 |
121 |
|
.bind(&t.name)
|
| 104 |
122 |
|
.bind(&n.ssh_target)
|
| 128 |
146 |
|
pool
|
| 129 |
147 |
|
}
|
| 130 |
148 |
|
|
|
149 |
+ |
fn app() -> AppId {
|
|
150 |
+ |
AppId::default()
|
|
151 |
+ |
}
|
|
152 |
+ |
|
| 131 |
153 |
|
fn topo(tiers: Vec<Tier>) -> Topology {
|
| 132 |
154 |
|
Topology {
|
| 133 |
155 |
|
repo: RepoConfig {
|
| 178 |
200 |
|
tier("c", false, vec![]),
|
| 179 |
201 |
|
]);
|
| 180 |
202 |
|
|
| 181 |
|
- |
sync(&pool, &t).await.unwrap();
|
|
203 |
+ |
sync(&pool, &app(), &t).await.unwrap();
|
| 182 |
204 |
|
|
| 183 |
205 |
|
let tier_names: Vec<String> = sqlx::query_scalar("SELECT name FROM tiers ORDER BY ord")
|
| 184 |
206 |
|
.fetch_all(&pool)
|
| 206 |
228 |
|
tier("host", true, vec![]),
|
| 207 |
229 |
|
tier("a", true, vec![node("n1")]),
|
| 208 |
230 |
|
]);
|
| 209 |
|
- |
sync(&pool, &t).await.unwrap();
|
| 210 |
|
- |
sync(&pool, &t).await.unwrap();
|
|
231 |
+ |
sync(&pool, &app(), &t).await.unwrap();
|
|
232 |
+ |
sync(&pool, &app(), &t).await.unwrap();
|
| 211 |
233 |
|
|
| 212 |
234 |
|
let nodes: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM nodes")
|
| 213 |
235 |
|
.fetch_one(&pool)
|
| 225 |
247 |
|
async fn removing_node_from_config_drops_row() {
|
| 226 |
248 |
|
let pool = fresh_pool().await;
|
| 227 |
249 |
|
let t1 = topo(vec![tier("a", true, vec![node("n1"), node("n2")])]);
|
| 228 |
|
- |
sync(&pool, &t1).await.unwrap();
|
|
250 |
+ |
sync(&pool, &app(), &t1).await.unwrap();
|
| 229 |
251 |
|
let t2 = topo(vec![tier("a", true, vec![node("n1")])]);
|
| 230 |
|
- |
sync(&pool, &t2).await.unwrap();
|
|
252 |
+ |
sync(&pool, &app(), &t2).await.unwrap();
|
| 231 |
253 |
|
|
| 232 |
254 |
|
let nodes: Vec<String> = sqlx::query_scalar("SELECT name FROM nodes")
|
| 233 |
255 |
|
.fetch_all(&pool)
|
| 236 |
258 |
|
assert_eq!(nodes, vec!["n1"]);
|
| 237 |
259 |
|
}
|
| 238 |
260 |
|
|
|
261 |
+ |
/// Syncing one product leaves every other product's tiers and nodes alone.
|
|
262 |
+ |
///
|
|
263 |
+ |
/// The sweep at the top of `sync` asks "which rows are not in this TOML",
|
|
264 |
+ |
/// and a TOML describes one product. Unscoped, syncing pom at startup would
|
|
265 |
+ |
/// answer "all of MNW's" and delete them — and since startup syncs each app
|
|
266 |
+ |
/// in turn, the last one to run would be the only one left standing.
|
|
267 |
+ |
#[tokio::test]
|
|
268 |
+ |
async fn syncing_one_app_does_not_touch_another() {
|
|
269 |
+ |
let pool = fresh_pool().await;
|
|
270 |
+ |
let mnw = AppId::new("mnw");
|
|
271 |
+ |
let pom = AppId::new("pom");
|
|
272 |
+ |
sync(
|
|
273 |
+ |
&pool,
|
|
274 |
+ |
&mnw,
|
|
275 |
+ |
&topo(vec![tier("host", true, vec![node("n1")])]),
|
|
276 |
+ |
)
|
|
277 |
+ |
.await
|
|
278 |
+ |
.unwrap();
|
|
279 |
+ |
sync(
|
|
280 |
+ |
&pool,
|
|
281 |
+ |
&pom,
|
|
282 |
+ |
&topo(vec![tier("host", true, vec![node("n2")])]),
|
|
283 |
+ |
)
|
|
284 |
+ |
.await
|
|
285 |
+ |
.unwrap();
|
|
286 |
+ |
|
|
287 |
+ |
// Both survive, and a tier name they share is two rows, not one.
|
|
288 |
+ |
let tiers: Vec<(String, String)> =
|
|
289 |
+ |
sqlx::query_as("SELECT app, name FROM tiers ORDER BY app")
|
|
290 |
+ |
.fetch_all(&pool)
|
|
291 |
+ |
.await
|
|
292 |
+ |
.unwrap();
|
|
293 |
+ |
assert_eq!(
|
|
294 |
+ |
tiers,
|
|
295 |
+ |
vec![
|
|
296 |
+ |
("mnw".to_string(), "host".to_string()),
|
|
297 |
+ |
("pom".to_string(), "host".to_string())
|
|
298 |
+ |
]
|
|
299 |
+ |
);
|
|
300 |
+ |
let nodes: Vec<(String, String)> =
|
|
301 |
+ |
sqlx::query_as("SELECT app, name FROM nodes ORDER BY app")
|
|
302 |
+ |
.fetch_all(&pool)
|
|
303 |
+ |
.await
|
|
304 |
+ |
.unwrap();
|
|
305 |
+ |
assert_eq!(
|
|
306 |
+ |
nodes,
|
|
307 |
+ |
vec![
|
|
308 |
+ |
("mnw".to_string(), "n1".to_string()),
|
|
309 |
+ |
("pom".to_string(), "n2".to_string())
|
|
310 |
+ |
]
|
|
311 |
+ |
);
|
|
312 |
+ |
// And one tier_state row each, not one shared.
|
|
313 |
+ |
let states: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM tier_state")
|
|
314 |
+ |
.fetch_one(&pool)
|
|
315 |
+ |
.await
|
|
316 |
+ |
.unwrap();
|
|
317 |
+ |
assert_eq!(states, 2);
|
|
318 |
+ |
}
|
|
319 |
+ |
|
|
320 |
+ |
/// A tier pinned in one product does not block removing the same-named tier
|
|
321 |
+ |
/// from another.
|
|
322 |
+ |
#[tokio::test]
|
|
323 |
+ |
async fn a_pin_in_one_app_does_not_block_another_apps_edit() {
|
|
324 |
+ |
let pool = fresh_pool().await;
|
|
325 |
+ |
let mnw = AppId::new("mnw");
|
|
326 |
+ |
let pom = AppId::new("pom");
|
|
327 |
+ |
let two = topo(vec![tier("host", true, vec![]), tier("a", true, vec![])]);
|
|
328 |
+ |
sync(&pool, &mnw, &two).await.unwrap();
|
|
329 |
+ |
sync(&pool, &pom, &two).await.unwrap();
|
|
330 |
+ |
|
|
331 |
+ |
// MNW pins a version on tier a.
|
|
332 |
+ |
sqlx::query("INSERT INTO versions (app, version, git_sha, built_at, artifact_path) VALUES ('mnw','0.1.0','deadbeef','2026-05-22T00:00:00Z','/r/0.1.0')")
|
|
333 |
+ |
.execute(&pool).await.unwrap();
|
|
334 |
+ |
sqlx::query(
|
|
335 |
+ |
"UPDATE tier_state SET current_version = '0.1.0' WHERE app = 'mnw' AND tier = 'a'",
|
|
336 |
+ |
)
|
|
337 |
+ |
.execute(&pool)
|
|
338 |
+ |
.await
|
|
339 |
+ |
.unwrap();
|
|
340 |
+ |
|
|
341 |
+ |
// pom dropping ITS tier a is fine; MNW's pin is not pom's business.
|
|
342 |
+ |
sync(&pool, &pom, &topo(vec![tier("host", true, vec![])]))
|
|
343 |
+ |
.await
|
|
344 |
+ |
.unwrap();
|
|
345 |
+ |
// MNW dropping the same tier is still refused.
|
|
346 |
+ |
let err = sync(&pool, &mnw, &topo(vec![tier("host", true, vec![])]))
|
|
347 |
+ |
.await
|
|
348 |
+ |
.unwrap_err();
|
|
349 |
+ |
assert!(err.to_string().contains("tier_state still pins"), "{err}");
|
|
350 |
+ |
}
|
|
351 |
+ |
|
| 239 |
352 |
|
#[tokio::test]
|
| 240 |
353 |
|
async fn refuses_to_drop_tier_with_pinned_version() {
|
| 241 |
354 |
|
let pool = fresh_pool().await;
|
| 242 |
355 |
|
let t1 = topo(vec![tier("host", true, vec![]), tier("a", true, vec![])]);
|
| 243 |
|
- |
sync(&pool, &t1).await.unwrap();
|
|
356 |
+ |
sync(&pool, &app(), &t1).await.unwrap();
|
| 244 |
357 |
|
|
| 245 |
358 |
|
// Simulate a version being deployed on tier a.
|
| 246 |
|
- |
sqlx::query("INSERT INTO versions (version, git_sha, built_at, artifact_path) VALUES ('0.1.0', 'deadbeef', '2026-05-22T00:00:00Z', '/r/0.1.0')")
|
|
359 |
+ |
sqlx::query("INSERT INTO versions (app, version, git_sha, built_at, artifact_path) VALUES ('mnw', '0.1.0', 'deadbeef', '2026-05-22T00:00:00Z', '/r/0.1.0')")
|
| 247 |
360 |
|
.execute(&pool).await.unwrap();
|
| 248 |
|
- |
sqlx::query("UPDATE tier_state SET current_version = '0.1.0' WHERE tier = 'a'")
|
| 249 |
|
- |
.execute(&pool)
|
| 250 |
|
- |
.await
|
| 251 |
|
- |
.unwrap();
|
|
361 |
+ |
sqlx::query(
|
|
362 |
+ |
"UPDATE tier_state SET current_version = '0.1.0' WHERE app = 'mnw' AND tier = 'a'",
|
|
363 |
+ |
)
|
|
364 |
+ |
.execute(&pool)
|
|
365 |
+ |
.await
|
|
366 |
+ |
.unwrap();
|
| 252 |
367 |
|
|
| 253 |
368 |
|
let t2 = topo(vec![tier("host", true, vec![])]);
|
| 254 |
|
- |
let err = sync(&pool, &t2).await.unwrap_err();
|
|
369 |
+ |
let err = sync(&pool, &app(), &t2).await.unwrap_err();
|
| 255 |
370 |
|
assert!(
|
| 256 |
371 |
|
err.to_string().contains("tier_state still pins"),
|
| 257 |
372 |
|
"got: {err}"
|