use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use futures::StreamExt;
use olai_stack_state::{Desired, Instance, InstanceId, Revision, Store};
use olai_stack_topology::{
Catalog, LOCAL_CREDENTIALS, LaunchEnvError, LocalCredentials, LocalSecrets, MaterializedOutput,
Plan, PlanError, RenderError, RuntimePlacement, Secrets, Vantage, baseline_catalog_with,
};
use serde::Serialize;
use crate::effective::{EffectiveEnv, EnvLayers, Layer};
use crate::engine::{ComposeEngine, DownOptions, EngineStream, Progress, UpOptions};
use crate::events::{EngineEvent, EventAction, EventFilter};
use crate::ports::{BusyPort, probe_host_ports};
use crate::secrets::{SecretError, SecretProviders};
use crate::source::{Source, SourceCtx, SourceError};
use crate::status::Health;
pub const INSTANCES_DIR: &str = "instances";
pub const PLACEMENT_KEYS: &[&str] = &[
"COMPOSE_PROJECT_NAME",
"TRESTLE_INSTANCE",
"TRESTLE_SLOT",
"TRESTLE_HOST_LABEL",
];
#[derive(Debug, thiserror::Error)]
pub enum InstanceError {
#[error(transparent)]
Store(#[from] olai_stack_state::Error),
#[error(transparent)]
Engine(#[from] crate::Error),
#[error("module manifest `{path}` of the revision: {reason}")]
Module {
path: String,
reason: String,
},
#[error(transparent)]
Plan(#[from] PlanError),
#[error(transparent)]
Render(#[from] RenderError),
#[error(transparent)]
LaunchEnv(#[from] LaunchEnvError),
#[error(transparent)]
Secret(#[from] SecretError),
#[error(transparent)]
Source(#[from] SourceError),
#[error("local credential: {0}")]
LocalCredential(String),
#[error("host ports are already in use: {}", ports(.0))]
PortsBusy(Vec<BusyPort>),
#[error("instance {running} already runs revision {revision} of this environment")]
RevisionRunning {
revision: u32,
running: InstanceId,
},
#[error("{path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
fn ports(busy: &[BusyPort]) -> String {
busy.iter()
.map(|b| format!("{} ({})", b.port, b.owner))
.collect::<Vec<_>>()
.join(", ")
}
type Result<T, E = InstanceError> = std::result::Result<T, E>;
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct InstanceEvent {
pub instance: InstanceId,
pub service: String,
pub action: EventAction,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct Reconciled {
pub adopted: Vec<InstanceId>,
pub stale: Vec<InstanceId>,
pub orphans: Vec<String>,
pub removed: Vec<String>,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct ReconcileOptions {
pub remove_orphans: bool,
}
#[derive(Debug)]
pub struct Materialized {
pub dir: PathBuf,
pub plan: Plan,
pub env: EffectiveEnv,
launch: BTreeMap<String, String>,
}
impl Materialized {
pub fn launch_var_names(&self) -> impl Iterator<Item = &str> {
self.launch.keys().map(String::as_str)
}
}
pub struct Instances<E> {
store: Arc<Store>,
engine: E,
root: PathBuf,
providers: SecretProviders,
sources: BTreeMap<String, Arc<dyn Source>>,
probe_ports: bool,
wait_timeout: Option<Duration>,
}
pub fn instance_project(workspace: &str, env: &str, revision: u32) -> String {
format!("{workspace}_{env}_r{revision}")
}
pub fn parse_instance_project(project: &str) -> Option<(&str, &str, u32)> {
let mut parts = project.split('_');
let (workspace, env, revision) = (parts.next()?, parts.next()?, parts.next()?);
if parts.next().is_some() {
return None;
}
let revision = revision.strip_prefix('r')?.parse().ok()?;
let valid = olai_stack_topology::workspace::is_valid_name;
(valid(workspace) && valid(env)).then_some((workspace, env, revision))
}
impl<E: ComposeEngine> Instances<E> {
pub fn new(store: Arc<Store>, engine: E, state_dir: impl Into<PathBuf>) -> Self {
Instances {
store,
engine,
root: state_dir.into().join(INSTANCES_DIR),
providers: SecretProviders::new().with(crate::secrets::EnvProvider),
sources: BTreeMap::new(),
probe_ports: true,
wait_timeout: None,
}
}
pub fn with_providers(mut self, providers: SecretProviders) -> Self {
self.providers = providers;
self
}
pub fn with_source(mut self, source: impl Source + 'static) -> Self {
self.sources
.insert(source.name().to_owned(), Arc::new(source));
self
}
pub fn without_port_probe(mut self) -> Self {
self.probe_ports = false;
self
}
pub fn with_wait_timeout(mut self, timeout: Duration) -> Self {
self.wait_timeout = Some(timeout);
self
}
pub fn engine(&self) -> &E {
&self.engine
}
pub fn store(&self) -> &Store {
&self.store
}
pub fn dir(&self, id: &InstanceId) -> PathBuf {
self.root.join(&id.0)
}
pub fn placement(&self, id: &InstanceId) -> Result<RuntimePlacement> {
let (instance, workspace, env) = self.describe(id)?;
let slot = match instance.slot {
Some(slot) => slot,
None => self.store.lease_slot(id)?.slot,
};
Ok(RuntimePlacement {
slot,
project_name: instance_project(&workspace.name, &env.name, instance.revision),
mesh_network: workspace
.mesh
.then(|| format!("trestle-{}-mesh", workspace.name)),
host_label: format!("{}-{}-r{}", workspace.name, env.name, instance.revision),
})
}
fn describe(
&self,
id: &InstanceId,
) -> Result<(
Instance,
olai_stack_state::Workspace,
olai_stack_state::Environment,
)> {
let instance = self.store.instance(id)?;
let env = self.store.environment(&instance.environment)?;
let workspace = self.store.workspace_by_id(&env.workspace)?;
Ok((instance, workspace, env))
}
async fn base_layers(
&self,
id: &InstanceId,
) -> Result<(EnvLayers, Revision, RuntimePlacement)> {
let (instance, workspace, env) = self.describe(id)?;
let revision = self
.store
.revision(&instance.environment, instance.revision)?;
let placement = self.placement(id)?;
let defaults: BTreeMap<String, String> = LOCAL_CREDENTIALS
.iter()
.map(|c| (c.env_var(), c.default.to_owned()))
.collect();
let reserved: BTreeMap<String, String> = [
("COMPOSE_PROJECT_NAME", placement.project_name.clone()),
("TRESTLE_INSTANCE", id.0.clone()),
("TRESTLE_SLOT", placement.slot.to_string()),
("TRESTLE_HOST_LABEL", placement.host_label.clone()),
]
.into_iter()
.map(|(k, v)| (k.to_owned(), v))
.collect();
let mut layers = EnvLayers::new()
.with_secrets(Layer::CatalogDefaults, &defaults)
.with_plain(Layer::Placement, &reserved)
.with_vars(Layer::WorkspaceVars, &workspace.vars)
.with_vars(Layer::EnvironmentVars, &env.vars)
.with_vars(Layer::RevisionVars, revision.vars())
.with_vars(Layer::InstanceOverrides, &instance.overrides);
let ctx = SourceCtx {
instance: id.clone(),
workspace: workspace.name.clone(),
environment: env.name.clone(),
revision: instance.revision,
};
for name in &instance.sources {
let source = self.sources.get(name).ok_or_else(|| SourceError {
source_name: name.clone(),
reason: "no such source is configured".into(),
})?;
let mut overlay = source.overlay(&ctx).await?;
if overlay.is_expired(std::time::SystemTime::now()) {
overlay = source.refresh(&ctx, &overlay).await?;
}
layers = layers.with_overlay(name, &overlay);
}
Ok((layers, revision, placement))
}
fn plan(
&self,
revision: &Revision,
env_name: &str,
placement: &RuntimePlacement,
env: &EffectiveEnv,
) -> Result<Plan> {
let secrets: Secrets = env.launch_vars().into_iter().collect();
let credentials = LocalCredentials::from_secrets(&secrets)
.map_err(|e| InstanceError::LocalCredential(e.to_string()))?;
let mut catalog = baseline_catalog_with(&credentials);
for (path, text) in revision.modules() {
let custom =
Catalog::from_manifests([text.as_str()]).map_err(|e| InstanceError::Module {
path: path.clone(),
reason: e.to_string(),
})?;
catalog = catalog.merge(custom);
}
let mut ctx = revision.manifest().context.clone();
ctx.env_name = env_name.to_owned();
placement.apply(&mut ctx);
ctx.local_secrets = LocalSecrets::Separate;
ctx.secrets = Some(secrets);
Ok(catalog.plan(&revision.manifest().selection, &ctx)?)
}
pub async fn effective_env(&self, id: &InstanceId) -> Result<EffectiveEnv> {
Ok(self.resolve(id).await?.0)
}
async fn resolve(&self, id: &InstanceId) -> Result<(EffectiveEnv, Plan)> {
let (layers, revision, placement) = self.base_layers(id).await?;
let env_name = self.store.environment(revision.environment())?.name;
let first = layers.resolve(&self.providers)?;
let plan = self.plan(&revision, &env_name, &placement, &first)?;
let secrets: Vec<&str> = first
.vars()
.values()
.filter(|r| r.value.is_secret() && !r.value.expose().is_empty())
.map(|r| r.value.expose())
.collect();
let split = |env: &olai_stack_topology::InjectedEnv| {
let (secret, plain): (BTreeMap<_, _>, BTreeMap<_, _>) = env
.iter()
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.partition(|(_, v)| secrets.iter().any(|s| v.contains(s)));
(secret, plain)
};
let (injected_secret, injected) = split(&plan.env);
let (client_secret, client) = split(plan.client_env(Vantage::Host));
let env = layers
.with_plain(Layer::RevisionManifest, &injected)
.with_secrets(Layer::RevisionManifest, &injected_secret)
.with_plain(Layer::ClientEnv, &client)
.with_secrets(Layer::ClientEnv, &client_secret)
.resolve(&self.providers)?;
Ok((env, plan))
}
pub async fn materialize(&self, id: &InstanceId) -> Result<Materialized> {
let (env, plan) = self.resolve(id).await?;
let (output, launch_env) = plan.materialize()?.into_launch_env()?;
let dir = self.dir(id);
write_tree(&dir, &output)?;
let mut launch = env.launch_vars();
launch.extend(launch_env.into_vars());
Ok(Materialized {
dir,
plan,
env,
launch,
})
}
pub async fn start(&self, id: &InstanceId) -> Result<Materialized> {
let instance = self.store.instance(id)?;
let project = self.placement(id)?.project_name;
for other in self.store.instances()? {
if other.id != *id
&& other.environment == instance.environment
&& other.revision == instance.revision
&& other.desired == Desired::Running
{
return Err(InstanceError::RevisionRunning {
revision: instance.revision,
running: other.id,
});
}
}
let materialized = self.materialize(id).await?;
if self.probe_ports {
let running = self
.engine
.ls()
.await?
.into_iter()
.any(|p| p.name == project && p.is_running());
if !running {
let busy = probe_host_ports(&materialized.plan);
if !busy.is_empty() {
return Err(InstanceError::PortsBusy(busy));
}
}
}
self.store.set_desired(id, Desired::Running)?;
let opts = UpOptions {
wait: true,
wait_timeout: self.wait_timeout,
env: materialized.launch.clone(),
progress: Progress::Quiet,
};
self.engine.up(&materialized.dir, &opts).await?;
Ok(materialized)
}
pub async fn stop(&self, id: &InstanceId, volumes: bool) -> Result<()> {
let materialized = self.materialize(id).await?;
let opts = DownOptions {
volumes,
env: materialized.launch,
progress: Progress::Quiet,
};
self.engine.down(&materialized.dir, &opts).await?;
self.store.set_desired(id, Desired::Stopped)?;
Ok(())
}
pub async fn remove(&self, id: &InstanceId, volumes: bool) -> Result<()> {
let project = self.placement(id)?.project_name;
if self.engine.ls().await?.iter().any(|p| p.name == project) {
self.stop(id, volumes).await?;
}
let dir = self.dir(id);
if dir.exists() {
std::fs::remove_dir_all(&dir)
.map_err(|source| InstanceError::Io { path: dir, source })?;
}
self.store.delete_instance(id)?;
Ok(())
}
pub async fn health(&self, id: &InstanceId) -> Result<BTreeMap<String, Health>> {
let project = self.placement(id)?.project_name;
Ok(self
.engine
.ps(&project)
.await?
.into_iter()
.map(|s| (s.service, s.health))
.collect())
}
pub async fn events(&self, id: &InstanceId) -> Result<EngineStream<InstanceEvent>> {
let project = self.placement(id)?.project_name;
let id = id.clone();
Ok(self
.engine
.events(&EventFilter::Project(project))
.await?
.map(move |e: crate::Result<EngineEvent>| {
e.map(|e| InstanceEvent {
instance: id.clone(),
service: e.service,
action: e.action,
})
})
.boxed())
}
pub async fn reconcile(&self, opts: ReconcileOptions) -> Result<Reconciled> {
let projects = self.engine.ls().await?;
let mut by_project: BTreeMap<String, InstanceId> = BTreeMap::new();
let instances = self.store.instances()?;
for instance in &instances {
let env = self.store.environment(&instance.environment)?;
let ws = self.store.workspace_by_id(&env.workspace)?;
by_project.insert(
instance_project(&ws.name, &env.name, instance.revision),
instance.id.clone(),
);
}
let mut out = Reconciled::default();
let mut present = BTreeSet::new();
for project in &projects {
if parse_instance_project(&project.name).is_none() {
continue;
}
present.insert(project.name.clone());
match by_project.get(&project.name) {
Some(id) if project.is_running() => {
self.store.set_desired(id, Desired::Running)?;
out.adopted.push(id.clone());
}
Some(_) => {}
None => {
out.orphans.push(project.name.clone());
if opts.remove_orphans && self.remove_orphan(&project.config_files).await? {
out.removed.push(project.name.clone());
}
}
}
}
for instance in instances {
if instance.desired != Desired::Running {
continue;
}
let project = by_project
.iter()
.find(|(_, id)| **id == instance.id)
.map(|(p, _)| p.clone())
.unwrap_or_default();
if !present.contains(&project) {
self.store.set_desired(&instance.id, Desired::Stopped)?;
out.stale.push(instance.id);
}
}
Ok(out)
}
async fn remove_orphan(&self, config_files: &str) -> Result<bool> {
let Some(dir) = config_files
.split(',')
.next()
.filter(|f| !f.is_empty())
.map(Path::new)
.and_then(Path::parent)
.filter(|d| d.is_dir())
else {
return Ok(false);
};
let opts = DownOptions {
progress: Progress::Quiet,
..DownOptions::default()
};
self.engine.down(dir, &opts).await?;
Ok(true)
}
}
fn write_tree(dir: &Path, output: &MaterializedOutput) -> Result<()> {
let io = |path: &Path| {
let path = path.to_owned();
move |source| InstanceError::Io { path, source }
};
if dir.exists() {
std::fs::remove_dir_all(dir).map_err(io(dir))?;
}
std::fs::create_dir_all(dir).map_err(io(dir))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700)).map_err(io(dir))?;
}
output.write_to(dir).map_err(io(dir))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn instance_projects_parse_back() {
let name = instance_project("demo-x", "prod", 3);
assert_eq!(name, "demo-x_prod_r3");
assert_eq!(parse_instance_project(&name), Some(("demo-x", "prod", 3)));
for other in ["demo-dev", "a_b", "a_b_c", "a_b_r", "A_b_r1", "a_b_r1_x"] {
assert_eq!(parse_instance_project(other), None, "{other}");
}
}
}