minco-dev 1.3.0

Deterministic local process plans and coordinated development supervision for Minco
Documentation
use minco_dev::{
    CommandSpec, DevDatabase, DevGraph, DevOptions, DevPlan, LifecycleKind, ProcessConfig,
    ProcessRole, ReadinessProbe, ServiceKind,
};
use std::collections::{BTreeMap, BTreeSet};

fn command(program: &str, arguments: &[&str]) -> CommandSpec {
    CommandSpec {
        program: program.into(),
        arguments: arguments.iter().map(ToString::to_string).collect(),
        environment: BTreeMap::new(),
    }
}

fn command_with_environment(
    program: &str,
    arguments: &[&str],
    environment: &[(&str, &str)],
) -> CommandSpec {
    CommandSpec {
        program: program.into(),
        arguments: arguments.iter().map(ToString::to_string).collect(),
        environment: environment
            .iter()
            .map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
            .collect(),
    }
}

fn process(id: &str, default_enabled: bool) -> ProcessConfig {
    ProcessConfig {
        id: id.into(),
        command: command("cargo", &["run", "--bin", id]),
        readiness: ReadinessProbe::Process,
        default_enabled,
    }
}

fn graph() -> DevGraph {
    DevGraph {
        application: "orders".into(),
        environment: "local".into(),
        compose_file: "infra/local/compose.yaml".into(),
        database: DevDatabase::Postgres,
        local_aws_services: vec!["sts".into(), "ssm".into()],
        api: ProcessConfig {
            id: "api".into(),
            command: command("cargo", &["run", "--bin", "orders-local"]),
            readiness: ReadinessProbe::Http {
                url: "http://127.0.0.1:3000/health/ready".into(),
            },
            default_enabled: true,
        },
        workers: vec![process("worker-z", false), process("worker-a", true)],
        frontend: Some(process("frontend", false)),
        migration: Some(command("cargo", &["minco", "db", "migrate"])),
        seeds: BTreeMap::from([(
            "demo".into(),
            command("cargo", &["minco", "db", "seed", "--profile", "demo"]),
        )]),
        schedules: vec!["nightly-reconciliation".into()],
    }
}

#[test]
fn safe_defaults_derive_only_declared_local_services_and_never_seed_or_schedule() {
    let plan = DevPlan::derive(&graph(), &DevOptions::default()).expect("valid development plan");

    assert_eq!(plan.schema_version, 1);
    assert_eq!(plan.environment, "local");
    assert!(!plan.external_aws_contact);
    assert_eq!(
        plan.services
            .iter()
            .map(|service| (&service.kind, service.port))
            .collect::<Vec<_>>(),
        [
            (&ServiceKind::Postgres, Some(55_432)),
            (&ServiceKind::Rustack, Some(4_566)),
        ]
    );
    assert_eq!(plan.services[1].aws_services, ["ssm", "sts"]);
    assert_eq!(
        plan.lifecycle
            .iter()
            .map(|step| step.kind)
            .collect::<Vec<_>>(),
        [LifecycleKind::Migrate]
    );
    assert_eq!(
        plan.processes
            .iter()
            .map(|process| (process.id.as_str(), process.role))
            .collect::<Vec<_>>(),
        [("api", ProcessRole::Api), ("worker-a", ProcessRole::Worker),]
    );
    assert_eq!(plan.omitted_schedule_ids, ["nightly-reconciliation"]);
}

#[test]
fn explicit_worker_seed_frontend_and_ports_are_deterministic() {
    let options = DevOptions {
        seed: Some("demo".into()),
        with_workers: BTreeSet::from(["worker-z".into()]),
        without_workers: BTreeSet::from(["worker-a".into()]),
        frontend: Some(true),
        port: Some(31_000),
        rustack_port: Some(45_666),
        ..DevOptions::default()
    };

    let first = DevPlan::derive(&graph(), &options).expect("valid development plan");
    let second = DevPlan::derive(&graph(), &options).expect("repeatable development plan");

    assert_eq!(first, second);
    assert_eq!(first.services[1].port, Some(45_666));
    assert_eq!(
        first
            .lifecycle
            .iter()
            .map(|step| step.kind)
            .collect::<Vec<_>>(),
        [LifecycleKind::Migrate, LifecycleKind::Seed]
    );
    assert_eq!(
        first
            .processes
            .iter()
            .map(|process| (process.id.as_str(), process.role))
            .collect::<Vec<_>>(),
        [
            ("api", ProcessRole::Api),
            ("worker-z", ProcessRole::Worker),
            ("frontend", ProcessRole::Frontend),
        ]
    );
    assert_eq!(
        first.processes[0].command.environment.get("PORT"),
        Some(&"31000".to_owned())
    );
    assert_eq!(
        first.processes[0].readiness,
        ReadinessProbe::Http {
            url: "http://127.0.0.1:31000/health/ready".into(),
        }
    );
}

#[test]
fn unknown_workers_and_unsupported_local_aws_services_fail_closed() {
    let options = DevOptions {
        with_workers: BTreeSet::from(["missing-worker".into()]),
        ..DevOptions::default()
    };
    let error = DevPlan::derive(&graph(), &options).expect_err("unknown worker must fail");
    assert!(error.to_string().contains("missing-worker"));

    let mut unsupported = graph();
    unsupported.local_aws_services.push("appsync".into());
    let error =
        DevPlan::derive(&unsupported, &DevOptions::default()).expect_err("unsupported AWS seam");
    assert!(error.to_string().contains("appsync"));
}

#[test]
fn all_current_rustack_services_are_accepted_and_sorted() {
    let mut graph = graph();
    graph.local_aws_services = [
        "sts",
        "ssm",
        "sqs",
        "sns",
        "ses",
        "secretsmanager",
        "s3",
        "logs",
        "lambda",
        "kms",
        "kinesis",
        "iam",
        "events",
        "dynamodbstreams",
        "dynamodb",
        "cloudwatch",
        "cloudfront",
        "apigatewayv2",
    ]
    .into_iter()
    .map(ToOwned::to_owned)
    .collect();

    let plan = DevPlan::derive(&graph, &DevOptions::default()).expect("supported Rustack services");

    assert_eq!(
        plan.services[1].aws_services,
        [
            "apigatewayv2",
            "cloudfront",
            "cloudwatch",
            "dynamodb",
            "dynamodbstreams",
            "events",
            "iam",
            "kinesis",
            "kms",
            "lambda",
            "logs",
            "s3",
            "secretsmanager",
            "ses",
            "sns",
            "sqs",
            "ssm",
            "sts",
        ]
    );
}

#[test]
fn service_activation_and_cleanup_commands_are_explicit_and_deterministic() {
    let plan = DevPlan::derive(&graph(), &DevOptions::default()).expect("valid development plan");

    assert_eq!(
        plan.services[0].start,
        Some(command_with_environment(
            "cargo-minco",
            &[
                "__local-service",
                "start",
                "postgres",
                "--application",
                "orders",
                "--compose-file",
                "infra/local/compose.yaml",
                "--port",
                "55432",
            ],
            &[("MINCO_POSTGRES_PORT", "55432")],
        ))
    );
    assert_eq!(
        plan.services[0].stop,
        Some(command(
            "cargo-minco",
            &[
                "__local-service",
                "stop",
                "postgres",
                "--application",
                "orders",
                "--compose-file",
                "infra/local/compose.yaml",
                "--port",
                "55432",
            ],
        ))
    );
    assert_eq!(
        plan.services[1].start,
        Some(command_with_environment(
            "cargo-minco",
            &[
                "__local-service",
                "start",
                "rustack",
                "--application",
                "orders",
                "--compose-file",
                "infra/local/compose.yaml",
                "--port",
                "4566",
                "--aws-services",
                "ssm,sts",
            ],
            &[
                ("MINCO_RUSTACK_PORT", "4566"),
                ("MINCO_RUSTACK_SERVICES", "ssm,sts"),
            ],
        ))
    );
    assert_eq!(
        plan.services[1].stop,
        Some(command(
            "cargo-minco",
            &[
                "__local-service",
                "stop",
                "rustack",
                "--application",
                "orders",
                "--compose-file",
                "infra/local/compose.yaml",
                "--port",
                "4566",
                "--aws-services",
                "ssm,sts",
            ],
        ))
    );
}

#[test]
fn explicitly_requested_undeclared_frontend_fails_closed() {
    let mut graph = graph();
    graph.frontend = None;
    let options = DevOptions {
        frontend: Some(true),
        ..DevOptions::default()
    };

    let error = DevPlan::derive(&graph, &options).expect_err("frontend must be declared");
    assert!(error.to_string().contains("frontend"));
}

#[test]
fn serialized_commands_retain_secret_names_but_never_secret_values() {
    let command = CommandSpec {
        program: "server".into(),
        arguments: Vec::new(),
        environment: BTreeMap::from([
            (
                "DATABASE_URL".into(),
                "postgres://user:secret@db/app".into(),
            ),
            ("API_KEY".into(), "minco-api-key-value".into()),
            ("AWS_ACCESS_KEY_ID".into(), "minco-access-key-id".into()),
            ("PORT".into(), "3000".into()),
        ]),
    };

    let serialized = serde_json::to_value(command).expect("serialized command");
    assert_eq!(serialized["environment"]["DATABASE_URL"], "<redacted>");
    assert_eq!(serialized["environment"]["API_KEY"], "<redacted>");
    assert_eq!(serialized["environment"]["AWS_ACCESS_KEY_ID"], "<redacted>");
    assert_eq!(serialized["environment"]["PORT"], "3000");
    assert!(!serialized.to_string().contains("user:secret"));
    assert!(!serialized.to_string().contains("minco-api-key-value"));
    assert!(!serialized.to_string().contains("minco-access-key-id"));
}

#[test]
fn conflicting_worker_overrides_fail_instead_of_silently_choosing_precedence() {
    let options = DevOptions {
        with_workers: BTreeSet::from(["worker-a".into()]),
        without_workers: BTreeSet::from(["worker-a".into()]),
        ..DevOptions::default()
    };

    let error = DevPlan::derive(&graph(), &options).expect_err("conflicting worker selection");
    assert!(error.to_string().contains("worker-a"));
    assert!(error.to_string().contains("both"));
}

#[test]
fn duplicate_process_identifiers_fail_before_any_process_plan_is_emitted() {
    let mut graph = graph();
    graph.workers.push(process("worker-a", true));

    let error = DevPlan::derive(&graph, &DevOptions::default()).expect_err("duplicate worker");
    assert!(error.to_string().contains("worker-a"));
    assert!(error.to_string().contains("duplicate"));
}