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] = &["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}