Skip to main content

minco_dev/
lib.rs

1//! Deterministic local development plans and coordinated process supervision.
2#![forbid(unsafe_code)]
3
4use serde::{Deserialize, Serialize, ser::SerializeStruct};
5use std::collections::{BTreeMap, BTreeSet};
6use thiserror::Error;
7
8mod supervisor;
9
10pub use supervisor::{DevEvent, DevStream, Supervisor, SupervisorError};
11
12const SUPPORTED_LOCAL_AWS_SERVICES: &[&str] = &["dynamodb", "s3", "sqs", "ssm", "sts"];
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum DevDatabase {
17    Postgres,
18    Sqlite,
19    None,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
23pub struct CommandSpec {
24    pub program: String,
25    #[serde(default)]
26    pub arguments: Vec<String>,
27    #[serde(default)]
28    pub environment: BTreeMap<String, String>,
29}
30
31impl Serialize for CommandSpec {
32    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
33    where
34        S: serde::Serializer,
35    {
36        let environment = self
37            .environment
38            .iter()
39            .map(|(name, value)| {
40                (
41                    name.as_str(),
42                    if is_sensitive_environment_name(name) {
43                        "<redacted>"
44                    } else {
45                        value.as_str()
46                    },
47                )
48            })
49            .collect::<BTreeMap<_, _>>();
50        let mut state = serializer.serialize_struct("CommandSpec", 3)?;
51        state.serialize_field("program", &self.program)?;
52        state.serialize_field("arguments", &self.arguments)?;
53        state.serialize_field("environment", &environment)?;
54        state.end()
55    }
56}
57
58pub(crate) fn is_sensitive_environment_name(name: &str) -> bool {
59    let name = name.to_ascii_uppercase();
60    name.ends_with("_URL")
61        || name.ends_with("_DSN")
62        || name.ends_with("_KEY")
63        || name.contains("_KEY_")
64        || [
65            "AUTHORIZATION",
66            "COOKIE",
67            "CREDENTIAL",
68            "PASSPHRASE",
69            "PASSWORD",
70            "SECRET",
71            "TOKEN",
72        ]
73        .iter()
74        .any(|marker| name.contains(marker))
75}
76
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(tag = "kind", rename_all = "snake_case")]
79pub enum ReadinessProbe {
80    Process,
81    Http { url: String },
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85pub struct ProcessConfig {
86    pub id: String,
87    pub command: CommandSpec,
88    pub readiness: ReadinessProbe,
89    #[serde(default)]
90    pub default_enabled: bool,
91}
92
93#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
94pub struct DevGraph {
95    pub application: String,
96    pub environment: String,
97    pub compose_file: String,
98    pub database: DevDatabase,
99    #[serde(default)]
100    pub local_aws_services: Vec<String>,
101    pub api: ProcessConfig,
102    #[serde(default)]
103    pub workers: Vec<ProcessConfig>,
104    pub frontend: Option<ProcessConfig>,
105    pub migration: Option<CommandSpec>,
106    #[serde(default)]
107    pub seeds: BTreeMap<String, CommandSpec>,
108    #[serde(default)]
109    pub schedules: Vec<String>,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct DevOptions {
114    pub profile: String,
115    pub migrate: bool,
116    pub seed: Option<String>,
117    pub with_workers: BTreeSet<String>,
118    pub without_workers: BTreeSet<String>,
119    pub frontend: Option<bool>,
120    pub port: Option<u16>,
121    pub rustack_port: Option<u16>,
122}
123
124impl Default for DevOptions {
125    fn default() -> Self {
126        Self {
127            profile: "default".into(),
128            migrate: true,
129            seed: None,
130            with_workers: BTreeSet::new(),
131            without_workers: BTreeSet::new(),
132            frontend: None,
133            port: None,
134            rustack_port: None,
135        }
136    }
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
140#[serde(rename_all = "snake_case")]
141pub enum ServiceKind {
142    Postgres,
143    Sqlite,
144    Rustack,
145}
146
147#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
148pub struct ServicePlan {
149    pub id: String,
150    pub kind: ServiceKind,
151    pub port: Option<u16>,
152    pub local_only: bool,
153    #[serde(default, skip_serializing_if = "Vec::is_empty")]
154    pub aws_services: Vec<String>,
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub start: Option<CommandSpec>,
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub stop: Option<CommandSpec>,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
162#[serde(rename_all = "snake_case")]
163pub enum LifecycleKind {
164    Migrate,
165    Seed,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169pub struct LifecyclePlan {
170    pub id: String,
171    pub kind: LifecycleKind,
172    pub command: CommandSpec,
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
176#[serde(rename_all = "snake_case")]
177pub enum ProcessRole {
178    Api,
179    Worker,
180    Frontend,
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
184pub struct ProcessPlan {
185    pub id: String,
186    pub role: ProcessRole,
187    pub command: CommandSpec,
188    pub readiness: ReadinessProbe,
189}
190
191#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
192pub struct DevPlan {
193    pub schema_version: u32,
194    pub application: String,
195    pub environment: String,
196    pub profile: String,
197    pub external_aws_contact: bool,
198    pub services: Vec<ServicePlan>,
199    pub lifecycle: Vec<LifecyclePlan>,
200    pub processes: Vec<ProcessPlan>,
201    pub omitted_schedule_ids: Vec<String>,
202}
203
204impl DevPlan {
205    pub fn derive(graph: &DevGraph, options: &DevOptions) -> Result<Self, DevPlanError> {
206        let mut process_ids = BTreeSet::new();
207        for process in std::iter::once(&graph.api)
208            .chain(graph.workers.iter())
209            .chain(graph.frontend.iter())
210        {
211            if !process_ids.insert(process.id.as_str()) {
212                return Err(DevPlanError::Invalid(format!(
213                    "duplicate development process identifier `{}`",
214                    process.id
215                )));
216            }
217        }
218        if let Some(worker) = options
219            .with_workers
220            .intersection(&options.without_workers)
221            .next()
222        {
223            return Err(DevPlanError::Invalid(format!(
224                "worker `{worker}` cannot be both included and omitted"
225            )));
226        }
227        let declared_workers = graph
228            .workers
229            .iter()
230            .map(|worker| worker.id.as_str())
231            .collect::<BTreeSet<_>>();
232        for worker in options
233            .with_workers
234            .iter()
235            .chain(options.without_workers.iter())
236        {
237            if !declared_workers.contains(worker.as_str()) {
238                return Err(DevPlanError::Invalid(format!(
239                    "worker `{worker}` is not declared"
240                )));
241            }
242        }
243
244        for service in &graph.local_aws_services {
245            if !SUPPORTED_LOCAL_AWS_SERVICES.contains(&service.as_str()) {
246                return Err(DevPlanError::Invalid(format!(
247                    "local AWS service `{service}` is not supported"
248                )));
249            }
250        }
251        if options.frontend == Some(true) && graph.frontend.is_none() {
252            return Err(DevPlanError::Invalid(
253                "frontend was requested but development.frontend is not declared".into(),
254            ));
255        }
256
257        let mut services = Vec::new();
258        match graph.database {
259            DevDatabase::Postgres => services.push(ServicePlan {
260                id: "postgres".into(),
261                kind: ServiceKind::Postgres,
262                port: Some(55_432),
263                local_only: true,
264                aws_services: Vec::new(),
265                start: Some(compose_command(
266                    &graph.compose_file,
267                    &["up", "-d", "--wait"],
268                    "postgres",
269                    BTreeMap::new(),
270                )),
271                stop: Some(compose_command(
272                    &graph.compose_file,
273                    &["stop"],
274                    "postgres",
275                    BTreeMap::new(),
276                )),
277            }),
278            DevDatabase::Sqlite => services.push(ServicePlan {
279                id: "sqlite".into(),
280                kind: ServiceKind::Sqlite,
281                port: None,
282                local_only: true,
283                aws_services: Vec::new(),
284                start: None,
285                stop: None,
286            }),
287            DevDatabase::None => {}
288        }
289
290        if !graph.local_aws_services.is_empty() {
291            let mut aws_services = graph.local_aws_services.clone();
292            aws_services.sort();
293            aws_services.dedup();
294            let rustack_port = options.rustack_port.unwrap_or(4_566);
295            let environment = BTreeMap::from([
296                ("MINCO_RUSTACK_PORT".into(), rustack_port.to_string()),
297                ("MINCO_RUSTACK_SERVICES".into(), aws_services.join(",")),
298            ]);
299            services.push(ServicePlan {
300                id: "rustack".into(),
301                kind: ServiceKind::Rustack,
302                port: Some(rustack_port),
303                local_only: true,
304                aws_services,
305                start: Some(compose_command(
306                    &graph.compose_file,
307                    &["up", "-d", "--wait"],
308                    "rustack",
309                    environment,
310                )),
311                stop: Some(compose_command(
312                    &graph.compose_file,
313                    &["stop"],
314                    "rustack",
315                    BTreeMap::new(),
316                )),
317            });
318        }
319
320        let mut lifecycle = Vec::new();
321        if options.migrate
322            && let Some(command) = &graph.migration
323        {
324            lifecycle.push(LifecyclePlan {
325                id: "migrate".into(),
326                kind: LifecycleKind::Migrate,
327                command: command.clone(),
328            });
329        }
330        if let Some(seed) = &options.seed {
331            let command = graph.seeds.get(seed).ok_or_else(|| {
332                DevPlanError::Invalid(format!("seed profile `{seed}` is not declared"))
333            })?;
334            lifecycle.push(LifecyclePlan {
335                id: format!("seed:{seed}"),
336                kind: LifecycleKind::Seed,
337                command: command.clone(),
338            });
339        }
340
341        let mut api_command = graph.api.command.clone();
342        if let Some(port) = options.port {
343            api_command
344                .environment
345                .insert("PORT".into(), port.to_string());
346        }
347        let api_readiness = override_readiness_port(&graph.api.readiness, options.port)?;
348        let mut processes = vec![ProcessPlan {
349            id: graph.api.id.clone(),
350            role: ProcessRole::Api,
351            command: api_command,
352            readiness: api_readiness,
353        }];
354        let mut workers = graph
355            .workers
356            .iter()
357            .filter(|worker| {
358                (worker.default_enabled || options.with_workers.contains(&worker.id))
359                    && !options.without_workers.contains(&worker.id)
360            })
361            .map(|worker| ProcessPlan {
362                id: worker.id.clone(),
363                role: ProcessRole::Worker,
364                command: worker.command.clone(),
365                readiness: worker.readiness.clone(),
366            })
367            .collect::<Vec<_>>();
368        workers.sort_by(|left, right| left.id.cmp(&right.id));
369        processes.extend(workers);
370
371        if let Some(frontend) = graph
372            .frontend
373            .as_ref()
374            .filter(|frontend| options.frontend.unwrap_or(frontend.default_enabled))
375        {
376            processes.push(ProcessPlan {
377                id: frontend.id.clone(),
378                role: ProcessRole::Frontend,
379                command: frontend.command.clone(),
380                readiness: frontend.readiness.clone(),
381            });
382        }
383
384        let mut omitted_schedule_ids = graph.schedules.clone();
385        omitted_schedule_ids.sort();
386        omitted_schedule_ids.dedup();
387
388        Ok(Self {
389            schema_version: 1,
390            application: graph.application.clone(),
391            environment: graph.environment.clone(),
392            profile: options.profile.clone(),
393            external_aws_contact: false,
394            services,
395            lifecycle,
396            processes,
397            omitted_schedule_ids,
398        })
399    }
400}
401
402fn override_readiness_port(
403    readiness: &ReadinessProbe,
404    port: Option<u16>,
405) -> Result<ReadinessProbe, DevPlanError> {
406    let (ReadinessProbe::Http { url }, Some(port)) = (readiness, port) else {
407        return Ok(readiness.clone());
408    };
409    let mut url = reqwest::Url::parse(url)
410        .map_err(|_| DevPlanError::Invalid("API readiness URL is invalid".into()))?;
411    url.set_port(Some(port))
412        .map_err(|()| DevPlanError::Invalid("API readiness URL cannot accept a port".into()))?;
413    Ok(ReadinessProbe::Http { url: url.into() })
414}
415
416fn compose_command(
417    compose_file: &str,
418    action: &[&str],
419    service: &str,
420    environment: BTreeMap<String, String>,
421) -> CommandSpec {
422    let mut arguments = vec!["compose".into(), "-f".into(), compose_file.into()];
423    arguments.extend(action.iter().map(ToString::to_string));
424    arguments.push(service.into());
425    CommandSpec {
426        program: "docker".into(),
427        arguments,
428        environment,
429    }
430}
431
432#[derive(Debug, Error, PartialEq, Eq)]
433pub enum DevPlanError {
434    #[error("invalid development plan: {0}")]
435    Invalid(String),
436}