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