1use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6use std::time::Duration;
7
8use futures::StreamExt;
9use olai_stack_state::{Desired, Instance, InstanceId, Revision, Store};
10use olai_stack_topology::{
11 Catalog, LOCAL_CREDENTIALS, LaunchEnvError, LocalCredentials, LocalSecrets, MaterializedOutput,
12 Plan, PlanError, RenderError, RuntimePlacement, Secrets, Vantage, baseline_catalog_with,
13};
14use serde::Serialize;
15
16use crate::effective::{EffectiveEnv, EnvLayers, Layer};
17use crate::engine::{ComposeEngine, DownOptions, EngineStream, Progress, UpOptions};
18use crate::events::{EngineEvent, EventAction, EventFilter};
19use crate::ports::{BusyPort, probe_host_ports};
20use crate::secrets::{SecretError, SecretProviders};
21use crate::source::{Source, SourceCtx, SourceError};
22use crate::status::Health;
23
24pub const INSTANCES_DIR: &str = "instances";
26
27pub const PLACEMENT_KEYS: &[&str] = &[
29 "COMPOSE_PROJECT_NAME",
30 "TRESTLE_INSTANCE",
31 "TRESTLE_SLOT",
32 "TRESTLE_HOST_LABEL",
33];
34
35#[derive(Debug, thiserror::Error)]
37pub enum InstanceError {
38 #[error(transparent)]
40 Store(#[from] olai_stack_state::Error),
41 #[error(transparent)]
43 Engine(#[from] crate::Error),
44 #[error("module manifest `{path}` of the revision: {reason}")]
46 Module {
47 path: String,
49 reason: String,
51 },
52 #[error(transparent)]
54 Plan(#[from] PlanError),
55 #[error(transparent)]
57 Render(#[from] RenderError),
58 #[error(transparent)]
60 LaunchEnv(#[from] LaunchEnvError),
61 #[error(transparent)]
63 Secret(#[from] SecretError),
64 #[error(transparent)]
66 Source(#[from] SourceError),
67 #[error("local credential: {0}")]
69 LocalCredential(String),
70 #[error("host ports are already in use: {}", ports(.0))]
72 PortsBusy(Vec<BusyPort>),
73 #[error("instance {running} already runs revision {revision} of this environment")]
75 RevisionRunning {
76 revision: u32,
78 running: InstanceId,
80 },
81 #[error("{path}: {source}")]
83 Io {
84 path: PathBuf,
86 #[source]
88 source: std::io::Error,
89 },
90}
91
92fn ports(busy: &[BusyPort]) -> String {
93 busy.iter()
94 .map(|b| format!("{} ({})", b.port, b.owner))
95 .collect::<Vec<_>>()
96 .join(", ")
97}
98
99type Result<T, E = InstanceError> = std::result::Result<T, E>;
100
101#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
103pub struct InstanceEvent {
104 pub instance: InstanceId,
106 pub service: String,
108 pub action: EventAction,
110}
111
112#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
114pub struct Reconciled {
115 pub adopted: Vec<InstanceId>,
117 pub stale: Vec<InstanceId>,
119 pub orphans: Vec<String>,
121 pub removed: Vec<String>,
123}
124
125#[derive(Clone, Copy, Debug, Default)]
127pub struct ReconcileOptions {
128 pub remove_orphans: bool,
130}
131
132#[derive(Debug)]
134pub struct Materialized {
135 pub dir: PathBuf,
137 pub plan: Plan,
139 pub env: EffectiveEnv,
141 launch: BTreeMap<String, String>,
144}
145
146impl Materialized {
147 pub fn launch_var_names(&self) -> impl Iterator<Item = &str> {
149 self.launch.keys().map(String::as_str)
150 }
151}
152
153pub struct Instances<E> {
167 store: Arc<Store>,
168 engine: E,
169 root: PathBuf,
170 providers: SecretProviders,
171 sources: BTreeMap<String, Arc<dyn Source>>,
172 probe_ports: bool,
173 wait_timeout: Option<Duration>,
174}
175
176pub fn instance_project(workspace: &str, env: &str, revision: u32) -> String {
178 format!("{workspace}_{env}_r{revision}")
179}
180
181pub fn parse_instance_project(project: &str) -> Option<(&str, &str, u32)> {
183 let mut parts = project.split('_');
184 let (workspace, env, revision) = (parts.next()?, parts.next()?, parts.next()?);
185 if parts.next().is_some() {
186 return None;
187 }
188 let revision = revision.strip_prefix('r')?.parse().ok()?;
189 let valid = olai_stack_topology::workspace::is_valid_name;
190 (valid(workspace) && valid(env)).then_some((workspace, env, revision))
191}
192
193impl<E: ComposeEngine> Instances<E> {
194 pub fn new(store: Arc<Store>, engine: E, state_dir: impl Into<PathBuf>) -> Self {
198 Instances {
199 store,
200 engine,
201 root: state_dir.into().join(INSTANCES_DIR),
202 providers: SecretProviders::new().with(crate::secrets::EnvProvider),
203 sources: BTreeMap::new(),
204 probe_ports: true,
205 wait_timeout: None,
206 }
207 }
208
209 pub fn with_providers(mut self, providers: SecretProviders) -> Self {
211 self.providers = providers;
212 self
213 }
214
215 pub fn with_source(mut self, source: impl Source + 'static) -> Self {
217 self.sources
218 .insert(source.name().to_owned(), Arc::new(source));
219 self
220 }
221
222 pub fn without_port_probe(mut self) -> Self {
224 self.probe_ports = false;
225 self
226 }
227
228 pub fn with_wait_timeout(mut self, timeout: Duration) -> Self {
230 self.wait_timeout = Some(timeout);
231 self
232 }
233
234 pub fn engine(&self) -> &E {
236 &self.engine
237 }
238
239 pub fn store(&self) -> &Store {
241 &self.store
242 }
243
244 pub fn dir(&self, id: &InstanceId) -> PathBuf {
246 self.root.join(&id.0)
247 }
248
249 pub fn placement(&self, id: &InstanceId) -> Result<RuntimePlacement> {
252 let (instance, workspace, env) = self.describe(id)?;
253 let slot = match instance.slot {
254 Some(slot) => slot,
255 None => self.store.lease_slot(id)?.slot,
256 };
257 Ok(RuntimePlacement {
258 slot,
259 project_name: instance_project(&workspace.name, &env.name, instance.revision),
260 mesh_network: workspace
261 .mesh
262 .then(|| format!("trestle-{}-mesh", workspace.name)),
263 host_label: format!("{}-{}-r{}", workspace.name, env.name, instance.revision),
264 })
265 }
266
267 fn describe(
268 &self,
269 id: &InstanceId,
270 ) -> Result<(
271 Instance,
272 olai_stack_state::Workspace,
273 olai_stack_state::Environment,
274 )> {
275 let instance = self.store.instance(id)?;
276 let env = self.store.environment(&instance.environment)?;
277 let workspace = self.store.workspace_by_id(&env.workspace)?;
278 Ok((instance, workspace, env))
279 }
280
281 async fn base_layers(
283 &self,
284 id: &InstanceId,
285 ) -> Result<(EnvLayers, Revision, RuntimePlacement)> {
286 let (instance, workspace, env) = self.describe(id)?;
287 let revision = self
288 .store
289 .revision(&instance.environment, instance.revision)?;
290 let placement = self.placement(id)?;
291 let defaults: BTreeMap<String, String> = LOCAL_CREDENTIALS
292 .iter()
293 .map(|c| (c.env_var(), c.default.to_owned()))
294 .collect();
295 let reserved: BTreeMap<String, String> = [
296 ("COMPOSE_PROJECT_NAME", placement.project_name.clone()),
297 ("TRESTLE_INSTANCE", id.0.clone()),
298 ("TRESTLE_SLOT", placement.slot.to_string()),
299 ("TRESTLE_HOST_LABEL", placement.host_label.clone()),
300 ]
301 .into_iter()
302 .map(|(k, v)| (k.to_owned(), v))
303 .collect();
304 let mut layers = EnvLayers::new()
305 .with_secrets(Layer::CatalogDefaults, &defaults)
306 .with_plain(Layer::Placement, &reserved)
307 .with_vars(Layer::WorkspaceVars, &workspace.vars)
308 .with_vars(Layer::EnvironmentVars, &env.vars)
309 .with_vars(Layer::RevisionVars, revision.vars())
310 .with_vars(Layer::InstanceOverrides, &instance.overrides);
311 let ctx = SourceCtx {
312 instance: id.clone(),
313 workspace: workspace.name.clone(),
314 environment: env.name.clone(),
315 revision: instance.revision,
316 };
317 for name in &instance.sources {
318 let source = self.sources.get(name).ok_or_else(|| SourceError {
319 source_name: name.clone(),
320 reason: "no such source is configured".into(),
321 })?;
322 let mut overlay = source.overlay(&ctx).await?;
323 if overlay.is_expired(std::time::SystemTime::now()) {
324 overlay = source.refresh(&ctx, &overlay).await?;
325 }
326 layers = layers.with_overlay(name, &overlay);
327 }
328 Ok((layers, revision, placement))
329 }
330
331 fn plan(
334 &self,
335 revision: &Revision,
336 env_name: &str,
337 placement: &RuntimePlacement,
338 env: &EffectiveEnv,
339 ) -> Result<Plan> {
340 let secrets: Secrets = env.launch_vars().into_iter().collect();
341 let credentials = LocalCredentials::from_secrets(&secrets)
342 .map_err(|e| InstanceError::LocalCredential(e.to_string()))?;
343 let mut catalog = baseline_catalog_with(&credentials);
344 for (path, text) in revision.modules() {
345 let custom =
346 Catalog::from_manifests([text.as_str()]).map_err(|e| InstanceError::Module {
347 path: path.clone(),
348 reason: e.to_string(),
349 })?;
350 catalog = catalog.merge(custom);
351 }
352 let mut ctx = revision.manifest().context.clone();
353 ctx.env_name = env_name.to_owned();
354 placement.apply(&mut ctx);
355 ctx.local_secrets = LocalSecrets::Separate;
357 ctx.secrets = Some(secrets);
358 Ok(catalog.plan(&revision.manifest().selection, &ctx)?)
359 }
360
361 pub async fn effective_env(&self, id: &InstanceId) -> Result<EffectiveEnv> {
363 Ok(self.resolve(id).await?.0)
364 }
365
366 async fn resolve(&self, id: &InstanceId) -> Result<(EffectiveEnv, Plan)> {
367 let (layers, revision, placement) = self.base_layers(id).await?;
368 let env_name = self.store.environment(revision.environment())?.name;
369 let first = layers.resolve(&self.providers)?;
373 let plan = self.plan(&revision, &env_name, &placement, &first)?;
374 let secrets: Vec<&str> = first
377 .vars()
378 .values()
379 .filter(|r| r.value.is_secret() && !r.value.expose().is_empty())
380 .map(|r| r.value.expose())
381 .collect();
382 let split = |env: &olai_stack_topology::InjectedEnv| {
383 let (secret, plain): (BTreeMap<_, _>, BTreeMap<_, _>) = env
384 .iter()
385 .map(|(k, v)| (k.to_owned(), v.to_owned()))
386 .partition(|(_, v)| secrets.iter().any(|s| v.contains(s)));
387 (secret, plain)
388 };
389 let (injected_secret, injected) = split(&plan.env);
390 let (client_secret, client) = split(plan.client_env(Vantage::Host));
391 let env = layers
392 .with_plain(Layer::RevisionManifest, &injected)
393 .with_secrets(Layer::RevisionManifest, &injected_secret)
394 .with_plain(Layer::ClientEnv, &client)
395 .with_secrets(Layer::ClientEnv, &client_secret)
396 .resolve(&self.providers)?;
397 Ok((env, plan))
398 }
399
400 pub async fn materialize(&self, id: &InstanceId) -> Result<Materialized> {
403 let (env, plan) = self.resolve(id).await?;
404 let (output, launch_env) = plan.materialize()?.into_launch_env()?;
405 let dir = self.dir(id);
406 write_tree(&dir, &output)?;
407 let mut launch = env.launch_vars();
408 launch.extend(launch_env.into_vars());
409 Ok(Materialized {
410 dir,
411 plan,
412 env,
413 launch,
414 })
415 }
416
417 pub async fn start(&self, id: &InstanceId) -> Result<Materialized> {
421 let instance = self.store.instance(id)?;
422 let project = self.placement(id)?.project_name;
423 for other in self.store.instances()? {
424 if other.id != *id
425 && other.environment == instance.environment
426 && other.revision == instance.revision
427 && other.desired == Desired::Running
428 {
429 return Err(InstanceError::RevisionRunning {
430 revision: instance.revision,
431 running: other.id,
432 });
433 }
434 }
435 let materialized = self.materialize(id).await?;
436 if self.probe_ports {
437 let running = self
438 .engine
439 .ls()
440 .await?
441 .into_iter()
442 .any(|p| p.name == project && p.is_running());
443 if !running {
445 let busy = probe_host_ports(&materialized.plan);
446 if !busy.is_empty() {
447 return Err(InstanceError::PortsBusy(busy));
448 }
449 }
450 }
451 self.store.set_desired(id, Desired::Running)?;
452 let opts = UpOptions {
453 wait: true,
454 wait_timeout: self.wait_timeout,
455 env: materialized.launch.clone(),
456 progress: Progress::Quiet,
457 };
458 self.engine.up(&materialized.dir, &opts).await?;
459 Ok(materialized)
460 }
461
462 pub async fn stop(&self, id: &InstanceId, volumes: bool) -> Result<()> {
464 let materialized = self.materialize(id).await?;
466 let opts = DownOptions {
467 volumes,
468 env: materialized.launch,
469 progress: Progress::Quiet,
470 };
471 self.engine.down(&materialized.dir, &opts).await?;
472 self.store.set_desired(id, Desired::Stopped)?;
473 Ok(())
474 }
475
476 pub async fn remove(&self, id: &InstanceId, volumes: bool) -> Result<()> {
479 let project = self.placement(id)?.project_name;
480 if self.engine.ls().await?.iter().any(|p| p.name == project) {
481 self.stop(id, volumes).await?;
482 }
483 let dir = self.dir(id);
484 if dir.exists() {
485 std::fs::remove_dir_all(&dir)
486 .map_err(|source| InstanceError::Io { path: dir, source })?;
487 }
488 self.store.delete_instance(id)?;
489 Ok(())
490 }
491
492 pub async fn health(&self, id: &InstanceId) -> Result<BTreeMap<String, Health>> {
494 let project = self.placement(id)?.project_name;
495 Ok(self
496 .engine
497 .ps(&project)
498 .await?
499 .into_iter()
500 .map(|s| (s.service, s.health))
501 .collect())
502 }
503
504 pub async fn events(&self, id: &InstanceId) -> Result<EngineStream<InstanceEvent>> {
506 let project = self.placement(id)?.project_name;
507 let id = id.clone();
508 Ok(self
509 .engine
510 .events(&EventFilter::Project(project))
511 .await?
512 .map(move |e: crate::Result<EngineEvent>| {
513 e.map(|e| InstanceEvent {
514 instance: id.clone(),
515 service: e.service,
516 action: e.action,
517 })
518 })
519 .boxed())
520 }
521
522 pub async fn reconcile(&self, opts: ReconcileOptions) -> Result<Reconciled> {
527 let projects = self.engine.ls().await?;
528 let mut by_project: BTreeMap<String, InstanceId> = BTreeMap::new();
529 let instances = self.store.instances()?;
530 for instance in &instances {
531 let env = self.store.environment(&instance.environment)?;
532 let ws = self.store.workspace_by_id(&env.workspace)?;
533 by_project.insert(
534 instance_project(&ws.name, &env.name, instance.revision),
535 instance.id.clone(),
536 );
537 }
538 let mut out = Reconciled::default();
539 let mut present = BTreeSet::new();
540 for project in &projects {
541 if parse_instance_project(&project.name).is_none() {
542 continue;
543 }
544 present.insert(project.name.clone());
545 match by_project.get(&project.name) {
546 Some(id) if project.is_running() => {
547 self.store.set_desired(id, Desired::Running)?;
550 out.adopted.push(id.clone());
551 }
552 Some(_) => {}
553 None => {
554 out.orphans.push(project.name.clone());
555 if opts.remove_orphans && self.remove_orphan(&project.config_files).await? {
556 out.removed.push(project.name.clone());
557 }
558 }
559 }
560 }
561 for instance in instances {
562 if instance.desired != Desired::Running {
563 continue;
564 }
565 let project = by_project
566 .iter()
567 .find(|(_, id)| **id == instance.id)
568 .map(|(p, _)| p.clone())
569 .unwrap_or_default();
570 if !present.contains(&project) {
571 self.store.set_desired(&instance.id, Desired::Stopped)?;
572 out.stale.push(instance.id);
573 }
574 }
575 Ok(out)
576 }
577
578 async fn remove_orphan(&self, config_files: &str) -> Result<bool> {
581 let Some(dir) = config_files
583 .split(',')
584 .next()
585 .filter(|f| !f.is_empty())
586 .map(Path::new)
587 .and_then(Path::parent)
588 .filter(|d| d.is_dir())
589 else {
590 return Ok(false);
591 };
592 let opts = DownOptions {
593 progress: Progress::Quiet,
594 ..DownOptions::default()
595 };
596 self.engine.down(dir, &opts).await?;
597 Ok(true)
598 }
599}
600
601fn write_tree(dir: &Path, output: &MaterializedOutput) -> Result<()> {
603 let io = |path: &Path| {
604 let path = path.to_owned();
605 move |source| InstanceError::Io { path, source }
606 };
607 if dir.exists() {
608 std::fs::remove_dir_all(dir).map_err(io(dir))?;
609 }
610 std::fs::create_dir_all(dir).map_err(io(dir))?;
611 #[cfg(unix)]
612 {
613 use std::os::unix::fs::PermissionsExt as _;
614 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).map_err(io(dir))?;
615 }
616 output.write_to(dir).map_err(io(dir))
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622
623 #[test]
624 fn instance_projects_parse_back() {
625 let name = instance_project("demo-x", "prod", 3);
626 assert_eq!(name, "demo-x_prod_r3");
627 assert_eq!(parse_instance_project(&name), Some(("demo-x", "prod", 3)));
628 for other in ["demo-dev", "a_b", "a_b_c", "a_b_r", "A_b_r1", "a_b_r1_x"] {
629 assert_eq!(parse_instance_project(other), None, "{other}");
630 }
631 }
632}