1use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5use std::sync::{Mutex, MutexGuard};
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use olai_stack_topology::workspace::is_valid_name;
9use olai_stack_topology::{EnvManifest, MAX_SLOT};
10use rusqlite::{Connection, OptionalExtension, Transaction, TransactionBehavior, params};
11
12use crate::error::{Error, Result};
13use crate::model::{
14 Desired, Draft, Environment, EnvironmentId, Instance, InstanceId, Revision, SlotLease, VarSet,
15 Workspace, WorkspaceId, digest,
16};
17
18pub const TRESTLE_HOME_VAR: &str = "TRESTLE_HOME";
20
21pub const DB_FILE: &str = "state.db";
23
24const MIGRATIONS: &[&str] = &[include_str!("../migrations/0001_init.sql")];
27
28pub const SCHEMA_VERSION: u32 = MIGRATIONS.len() as u32;
30
31const BUSY_TIMEOUT: Duration = Duration::from_secs(10);
33
34pub fn trestle_home() -> Option<PathBuf> {
37 match std::env::var_os(TRESTLE_HOME_VAR) {
38 Some(home) if !home.is_empty() => Some(PathBuf::from(home)),
39 _ => dirs::data_dir().map(|d| d.join("trestle")),
40 }
41}
42
43fn now() -> i64 {
44 SystemTime::now()
45 .duration_since(UNIX_EPOCH)
46 .map(|d| d.as_secs() as i64)
47 .unwrap_or_default()
48}
49
50fn json<T: serde::Serialize>(value: &T) -> Result<String> {
51 Ok(serde_json::to_string(value)?)
52}
53
54fn from_json<T: serde::de::DeserializeOwned>(text: &str) -> Result<T> {
55 Ok(serde_json::from_str(text)?)
56}
57
58fn check_name(name: &str) -> Result<()> {
59 if is_valid_name(name) {
60 Ok(())
61 } else {
62 Err(Error::InvalidName(name.to_owned()))
63 }
64}
65
66fn is_unique_violation(e: &rusqlite::Error) -> bool {
67 matches!(
68 e,
69 rusqlite::Error::SqliteFailure(f, _)
70 if f.code == rusqlite::ErrorCode::ConstraintViolation
71 )
72}
73
74pub struct Store {
82 conn: Mutex<Connection>,
83 path: PathBuf,
84}
85
86impl std::fmt::Debug for Store {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 f.debug_struct("Store").field("path", &self.path).finish()
89 }
90}
91
92pub(crate) fn connect(path: &Path) -> Result<Connection> {
94 let conn = Connection::open(path)?;
95 conn.busy_timeout(BUSY_TIMEOUT)?;
96 conn.pragma_update(None, "journal_mode", "WAL")?;
97 conn.pragma_update(None, "foreign_keys", true)?;
98 conn.pragma_update(None, "synchronous", "NORMAL")?;
99 Ok(conn)
100}
101
102impl Store {
103 pub fn open_default() -> Result<Self> {
105 let home = trestle_home().ok_or_else(|| Error::NotFound {
106 kind: "data directory",
107 id: format!("(set {TRESTLE_HOME_VAR})"),
108 })?;
109 std::fs::create_dir_all(&home).map_err(|e| Error::io(&home, e))?;
110 Self::open(&home.join(DB_FILE))
111 }
112
113 pub fn open(path: &Path) -> Result<Self> {
117 let mut conn = connect(path)?;
118 migrate(&mut conn)?;
119 Ok(Store {
120 conn: Mutex::new(conn),
121 path: path.to_owned(),
122 })
123 }
124
125 pub fn path(&self) -> &Path {
127 &self.path
128 }
129
130 fn lock(&self) -> MutexGuard<'_, Connection> {
131 self.conn.lock().unwrap_or_else(|e| e.into_inner())
132 }
133
134 fn write<T>(&self, f: impl FnOnce(&Transaction<'_>) -> Result<T>) -> Result<T> {
137 let mut conn = self.lock();
138 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
139 let out = f(&tx)?;
140 tx.commit()?;
141 Ok(out)
142 }
143
144 fn read<T>(&self, f: impl FnOnce(&Connection) -> Result<T>) -> Result<T> {
146 f(&self.lock())
147 }
148
149 pub fn create_workspace(&self, name: &str, mesh: bool) -> Result<Workspace> {
153 check_name(name)?;
154 let ws = Workspace {
155 id: WorkspaceId::generate(),
156 name: name.to_owned(),
157 mesh,
158 vars: VarSet::new(),
159 };
160 self.write(|tx| {
161 tx.execute(
162 "INSERT INTO workspace (id, name, mesh, vars, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
163 params![ws.id.0, ws.name, ws.mesh, json(&ws.vars)?, now()],
164 )
165 .map_err(|e| {
166 if is_unique_violation(&e) {
167 Error::Duplicate {
168 kind: "workspace",
169 name: name.to_owned(),
170 }
171 } else {
172 e.into()
173 }
174 })?;
175 Ok(())
176 })?;
177 Ok(ws)
178 }
179
180 pub fn workspaces(&self) -> Result<Vec<Workspace>> {
182 self.read(|conn| {
183 let mut stmt =
184 conn.prepare("SELECT id, name, mesh, vars FROM workspace ORDER BY name")?;
185 let rows = stmt.query_map([], workspace_row)?;
186 rows.map(|r| r?).collect()
187 })
188 }
189
190 pub fn workspace(&self, name: &str) -> Result<Option<Workspace>> {
192 self.read(|conn| {
193 conn.query_row(
194 "SELECT id, name, mesh, vars FROM workspace WHERE name = ?1",
195 [name],
196 workspace_row,
197 )
198 .optional()?
199 .transpose()
200 })
201 }
202
203 pub fn workspace_by_id(&self, id: &WorkspaceId) -> Result<Workspace> {
205 self.read(|conn| {
206 conn.query_row(
207 "SELECT id, name, mesh, vars FROM workspace WHERE id = ?1",
208 [&id.0],
209 workspace_row,
210 )
211 .optional()?
212 .transpose()?
213 .ok_or_else(|| Error::NotFound {
214 kind: "workspace",
215 id: id.0.clone(),
216 })
217 })
218 }
219
220 pub fn set_workspace_vars(&self, id: &WorkspaceId, vars: &VarSet) -> Result<()> {
222 self.write(|tx| {
223 let n = tx.execute(
224 "UPDATE workspace SET vars = ?2 WHERE id = ?1",
225 params![id.0, json(vars)?],
226 )?;
227 found(n, "workspace", &id.0)
228 })
229 }
230
231 pub fn delete_workspace(&self, id: &WorkspaceId) -> Result<()> {
233 self.write(|tx| {
234 let n = tx.execute("DELETE FROM workspace WHERE id = ?1", [&id.0])?;
235 found(n, "workspace", &id.0)
236 })
237 }
238
239 pub fn create_environment(
244 &self,
245 workspace: &WorkspaceId,
246 name: &str,
247 mut draft: Draft,
248 ) -> Result<Environment> {
249 check_name(name)?;
250 draft.manifest.context.env_name = name.to_owned();
251 let env = Environment {
252 id: EnvironmentId::generate(),
253 workspace: workspace.clone(),
254 name: name.to_owned(),
255 draft,
256 vars: VarSet::new(),
257 head: None,
258 };
259 self.write(|tx| {
260 tx.execute(
261 "INSERT INTO environment (id, workspace_id, name, draft, draft_modules, draft_vars, \
262 vars, head, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8)",
263 params![
264 env.id.0,
265 workspace.0,
266 env.name,
267 env.draft.manifest.to_toml()?,
268 json(&env.draft.modules)?,
269 json(&env.draft.vars)?,
270 json(&env.vars)?,
271 now()
272 ],
273 )
274 .map_err(|e| {
275 if is_unique_violation(&e) {
276 Error::Duplicate {
277 kind: "environment",
278 name: name.to_owned(),
279 }
280 } else if matches!(&e, rusqlite::Error::SqliteFailure(f, _)
281 if f.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_FOREIGNKEY)
282 {
283 Error::NotFound {
284 kind: "workspace",
285 id: workspace.0.clone(),
286 }
287 } else {
288 e.into()
289 }
290 })?;
291 Ok(())
292 })?;
293 Ok(env)
294 }
295
296 pub fn environments(&self, workspace: &WorkspaceId) -> Result<Vec<Environment>> {
298 self.read(|conn| {
299 let mut stmt = conn.prepare(&format!(
300 "{ENV_COLUMNS} WHERE workspace_id = ?1 ORDER BY name"
301 ))?;
302 let rows = stmt.query_map([&workspace.0], environment_row)?;
303 rows.map(|r| r?).collect()
304 })
305 }
306
307 pub fn environment(&self, id: &EnvironmentId) -> Result<Environment> {
309 self.read(|conn| {
310 conn.query_row(
311 &format!("{ENV_COLUMNS} WHERE id = ?1"),
312 [&id.0],
313 environment_row,
314 )
315 .optional()?
316 .transpose()?
317 .ok_or_else(|| Error::NotFound {
318 kind: "environment",
319 id: id.0.clone(),
320 })
321 })
322 }
323
324 pub fn update_draft(&self, id: &EnvironmentId, draft: &Draft) -> Result<()> {
327 let env = self.environment(id)?;
328 let mut manifest = draft.manifest.clone();
329 manifest.context.env_name = env.name;
330 self.write(|tx| {
331 let n = tx.execute(
332 "UPDATE environment SET draft = ?2, draft_modules = ?3, draft_vars = ?4 WHERE id = ?1",
333 params![
334 id.0,
335 manifest.to_toml()?,
336 json(&draft.modules)?,
337 json(&draft.vars)?
338 ],
339 )?;
340 found(n, "environment", &id.0)
341 })
342 }
343
344 pub fn set_environment_vars(&self, id: &EnvironmentId, vars: &VarSet) -> Result<()> {
346 self.write(|tx| {
347 let n = tx.execute(
348 "UPDATE environment SET vars = ?2 WHERE id = ?1",
349 params![id.0, json(vars)?],
350 )?;
351 found(n, "environment", &id.0)
352 })
353 }
354
355 pub fn commit_draft(&self, id: &EnvironmentId) -> Result<Revision> {
358 self.write(|tx| {
359 let (draft, modules, vars, head): (String, String, String, Option<u32>) = tx
360 .query_row(
361 "SELECT draft, draft_modules, draft_vars, head FROM environment WHERE id = ?1",
362 [&id.0],
363 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
364 )
365 .optional()?
366 .ok_or_else(|| Error::NotFound {
367 kind: "environment",
368 id: id.0.clone(),
369 })?;
370 let number = head.unwrap_or(0) + 1;
371 let modules: BTreeMap<String, String> = from_json(&modules)?;
372 let vars: VarSet = from_json(&vars)?;
373 let digest = digest(&draft, &modules, &vars)?;
374 tx.execute(
375 "INSERT INTO revision (environment_id, number, manifest, modules, vars, digest, \
376 created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
377 params![
378 id.0,
379 number,
380 draft,
381 json(&modules)?,
382 json(&vars)?,
383 digest,
384 now()
385 ],
386 )?;
387 tx.execute(
388 "UPDATE environment SET head = ?2 WHERE id = ?1",
389 params![id.0, number],
390 )?;
391 Ok(Revision {
392 environment: id.clone(),
393 number,
394 manifest: EnvManifest::from_toml(&draft)?,
395 manifest_toml: draft,
396 modules,
397 vars,
398 digest,
399 })
400 })
401 }
402
403 pub fn revision(&self, env: &EnvironmentId, number: u32) -> Result<Revision> {
405 self.read(|conn| {
406 conn.query_row(
407 &format!("{REV_COLUMNS} WHERE environment_id = ?1 AND number = ?2"),
408 params![env.0, number],
409 revision_row,
410 )
411 .optional()?
412 .transpose()?
413 .ok_or_else(|| Error::NotFound {
414 kind: "revision",
415 id: format!("{env}#{number}"),
416 })
417 })
418 }
419
420 pub fn revisions(&self, env: &EnvironmentId) -> Result<Vec<Revision>> {
422 self.read(|conn| {
423 let mut stmt = conn.prepare(&format!(
424 "{REV_COLUMNS} WHERE environment_id = ?1 ORDER BY number"
425 ))?;
426 let rows = stmt.query_map([&env.0], revision_row)?;
427 rows.map(|r| r?).collect()
428 })
429 }
430
431 pub fn create_instance(&self, env: &EnvironmentId, revision: u32) -> Result<Instance> {
436 let instance = Instance {
437 id: InstanceId::generate(),
438 environment: env.clone(),
439 revision,
440 desired: Desired::Stopped,
441 overrides: VarSet::new(),
442 sources: Vec::new(),
443 slot: None,
444 };
445 self.write(|tx| {
446 tx.execute(
447 "INSERT INTO instance (id, environment_id, revision, desired, overrides, sources, \
448 created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
449 params![
450 instance.id.0,
451 env.0,
452 revision,
453 instance.desired.as_str(),
454 json(&instance.overrides)?,
455 json(&instance.sources)?,
456 now()
457 ],
458 )
459 .map_err(|e| match &e {
460 rusqlite::Error::SqliteFailure(f, _)
461 if f.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_FOREIGNKEY =>
462 {
463 Error::NotFound {
464 kind: "revision",
465 id: format!("{env}#{revision}"),
466 }
467 }
468 _ => e.into(),
469 })?;
470 Ok(())
471 })?;
472 Ok(instance)
473 }
474
475 pub fn instance(&self, id: &InstanceId) -> Result<Instance> {
477 self.read(|conn| {
478 conn.query_row(
479 &format!("{INST_COLUMNS} WHERE i.id = ?1"),
480 [&id.0],
481 instance_row,
482 )
483 .optional()?
484 .transpose()?
485 .ok_or_else(|| Error::NotFound {
486 kind: "instance",
487 id: id.0.clone(),
488 })
489 })
490 }
491
492 pub fn instances(&self) -> Result<Vec<Instance>> {
494 self.read(|conn| {
495 let mut stmt = conn.prepare(&format!("{INST_COLUMNS} ORDER BY i.created_at, i.id"))?;
496 let rows = stmt.query_map([], instance_row)?;
497 rows.map(|r| r?).collect()
498 })
499 }
500
501 pub fn set_desired(&self, id: &InstanceId, desired: Desired) -> Result<()> {
503 self.write(|tx| {
504 let n = tx.execute(
505 "UPDATE instance SET desired = ?2 WHERE id = ?1",
506 params![id.0, desired.as_str()],
507 )?;
508 found(n, "instance", &id.0)
509 })
510 }
511
512 pub fn set_overrides(&self, id: &InstanceId, overrides: &VarSet) -> Result<()> {
514 self.write(|tx| {
515 let n = tx.execute(
516 "UPDATE instance SET overrides = ?2 WHERE id = ?1",
517 params![id.0, json(overrides)?],
518 )?;
519 found(n, "instance", &id.0)
520 })
521 }
522
523 pub fn set_sources(&self, id: &InstanceId, sources: &[String]) -> Result<()> {
525 self.write(|tx| {
526 let n = tx.execute(
527 "UPDATE instance SET sources = ?2 WHERE id = ?1",
528 params![id.0, json(&sources)?],
529 )?;
530 found(n, "instance", &id.0)
531 })
532 }
533
534 pub fn delete_instance(&self, id: &InstanceId) -> Result<()> {
536 self.write(|tx| {
537 let n = tx.execute("DELETE FROM instance WHERE id = ?1", [&id.0])?;
538 found(n, "instance", &id.0)
539 })
540 }
541
542 pub fn lease_slot(&self, instance: &InstanceId) -> Result<SlotLease> {
547 self.write(|tx| {
548 let held: Option<u16> = tx
549 .query_row(
550 "SELECT slot FROM slot_lease WHERE instance_id = ?1",
551 [&instance.0],
552 |r| r.get(0),
553 )
554 .optional()?;
555 if let Some(slot) = held {
556 return Ok(SlotLease {
557 slot,
558 instance: instance.clone(),
559 });
560 }
561 let taken: Vec<u16> = tx
562 .prepare("SELECT slot FROM slot_lease ORDER BY slot")?
563 .query_map([], |r| r.get(0))?
564 .collect::<rusqlite::Result<_>>()?;
565 let slot = (0..=MAX_SLOT)
566 .find(|s| taken.binary_search(s).is_err())
567 .ok_or(Error::SlotsExhausted)?;
568 tx.execute(
569 "INSERT INTO slot_lease (slot, instance_id, leased_at) VALUES (?1, ?2, ?3)",
570 params![slot, instance.0, now()],
571 )
572 .map_err(|e| match &e {
573 rusqlite::Error::SqliteFailure(f, _)
574 if f.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_FOREIGNKEY =>
575 {
576 Error::NotFound {
577 kind: "instance",
578 id: instance.0.clone(),
579 }
580 }
581 _ => e.into(),
582 })?;
583 Ok(SlotLease {
584 slot,
585 instance: instance.clone(),
586 })
587 })
588 }
589
590 pub fn release_slot(&self, instance: &InstanceId) -> Result<()> {
592 self.write(|tx| {
593 tx.execute(
594 "DELETE FROM slot_lease WHERE instance_id = ?1",
595 [&instance.0],
596 )?;
597 Ok(())
598 })
599 }
600
601 pub fn leases(&self) -> Result<Vec<SlotLease>> {
603 self.read(|conn| {
604 let mut stmt =
605 conn.prepare("SELECT slot, instance_id FROM slot_lease ORDER BY slot")?;
606 let rows = stmt.query_map([], |r| {
607 Ok(SlotLease {
608 slot: r.get(0)?,
609 instance: InstanceId(r.get(1)?),
610 })
611 })?;
612 Ok(rows.collect::<rusqlite::Result<_>>()?)
613 })
614 }
615}
616
617fn found(rows: usize, kind: &'static str, id: &str) -> Result<()> {
618 if rows == 0 {
619 Err(Error::NotFound {
620 kind,
621 id: id.to_owned(),
622 })
623 } else {
624 Ok(())
625 }
626}
627
628fn migrate(conn: &mut Connection) -> Result<()> {
631 let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
632 let found: u32 = tx.query_row("PRAGMA user_version", [], |r| r.get(0))?;
633 if found > SCHEMA_VERSION {
634 return Err(Error::UnsupportedVersion {
635 found,
636 supported: SCHEMA_VERSION,
637 });
638 }
639 for sql in &MIGRATIONS[found as usize..] {
640 tx.execute_batch(sql)?;
641 }
642 tx.pragma_update(None, "user_version", SCHEMA_VERSION)?;
643 tx.commit()?;
644 Ok(())
645}
646
647fn workspace_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<Result<Workspace>> {
648 let vars: String = r.get(3)?;
649 let (id, name, mesh) = (r.get(0)?, r.get(1)?, r.get(2)?);
650 Ok(from_json(&vars).map(|vars| Workspace {
651 id: WorkspaceId(id),
652 name,
653 mesh,
654 vars,
655 }))
656}
657
658const ENV_COLUMNS: &str =
659 "SELECT id, workspace_id, name, draft, draft_modules, draft_vars, vars, head FROM environment";
660
661fn environment_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<Result<Environment>> {
662 let (id, workspace, name): (String, String, String) = (r.get(0)?, r.get(1)?, r.get(2)?);
663 let (draft, modules, draft_vars, vars): (String, String, String, String) =
664 (r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?);
665 let head: Option<u32> = r.get(7)?;
666 Ok((|| {
667 Ok(Environment {
668 id: EnvironmentId(id),
669 workspace: WorkspaceId(workspace),
670 name,
671 draft: Draft {
672 manifest: EnvManifest::from_toml(&draft)?,
673 modules: from_json(&modules)?,
674 vars: from_json(&draft_vars)?,
675 },
676 vars: from_json(&vars)?,
677 head,
678 })
679 })())
680}
681
682const REV_COLUMNS: &str =
683 "SELECT environment_id, number, manifest, modules, vars, digest FROM revision";
684
685fn revision_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<Result<Revision>> {
686 let (env, number, manifest_toml): (String, u32, String) = (r.get(0)?, r.get(1)?, r.get(2)?);
687 let (modules, vars, digest): (String, String, String) = (r.get(3)?, r.get(4)?, r.get(5)?);
688 Ok((|| {
689 Ok(Revision {
690 environment: EnvironmentId(env),
691 number,
692 manifest: EnvManifest::from_toml(&manifest_toml)?,
693 manifest_toml,
694 modules: from_json(&modules)?,
695 vars: from_json(&vars)?,
696 digest,
697 })
698 })())
699}
700
701const INST_COLUMNS: &str = "SELECT i.id, i.environment_id, i.revision, i.desired, i.overrides, \
702 i.sources, l.slot FROM instance i LEFT JOIN slot_lease l ON l.instance_id = i.id";
703
704fn instance_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<Result<Instance>> {
705 let (id, env, revision, desired): (String, String, u32, String) =
706 (r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?);
707 let (overrides, sources, slot): (String, String, Option<u16>) =
708 (r.get(4)?, r.get(5)?, r.get(6)?);
709 Ok((|| {
710 Ok(Instance {
711 id: InstanceId(id),
712 environment: EnvironmentId(env),
713 revision,
714 desired: Desired::parse(&desired),
715 overrides: from_json(&overrides)?,
716 sources: from_json(&sources)?,
717 slot,
718 })
719 })())
720}