1#![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}