Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ dotenvy = "0.15"
dialoguer = { version = "0.11", default-features = false }
console = "0.15"
indicatif = "0.17"
ctrlc = "3"

[dev-dependencies]
mockito = "1.6"
Expand Down
108 changes: 108 additions & 0 deletions src/commands/dev/compose.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
//! Docker-compose lifecycle for `mirrorstack dev`. We shell out to the
//! `docker compose` CLI rather than use a library — `docker compose` is the
//! canonical front-end and tracking its semantics in a Rust binding has a
//! perpetual maintenance cost we don't want to take on for one subcommand.

use std::fs;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};

use anyhow::{Context, Result, anyhow};
use console::style;

use super::super::ok_mark;

const COMPOSE_TEMPLATE: &str = include_str!("../../../templates/dev/docker-compose.yml.tmpl");

/// Write a `docker-compose.yml` into `dir` if absent. Returns whether a new
/// file was written.
pub(super) fn ensure_compose_file(dir: &Path) -> Result<bool> {
let path = dir.join("docker-compose.yml");
if path.exists() {
return Ok(false);
}
let mut f = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.with_context(|| format!("dev: create {}", path.display()))?;
f.write_all(COMPOSE_TEMPLATE.as_bytes())
.with_context(|| format!("dev: write {}", path.display()))?;
eprintln!(
"{} bootstrapped {}",
ok_mark(),
style(path.display()).cyan().bold()
);
Ok(true)
}

/// Bring the bundled services up and block until each healthcheck flips.
/// `--wait` is server-side polling so we don't replicate that loop here;
/// non-zero exit = something failed to come up healthy in time.
pub(super) fn up(dir: &Path) -> Result<()> {
let status = Command::new("docker")
.args(["compose", "up", "-d", "--wait"])
.current_dir(dir)
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => anyhow!(
"`docker` not found on PATH. Install Docker Desktop (or set --no-compose to skip) before running dev."
),
_ => anyhow!("dev: docker compose up: {e}"),
})?;
if !status.success() {
return Err(anyhow!(
"docker compose up failed (exit {}). Check the output above; \
`docker compose logs postgres` may explain a stuck healthcheck.",
status.code().unwrap_or(-1)
));
}
Ok(())
}

/// Tear the bundled services down. Surfaces a non-zero exit so a failed
/// teardown doesn't silently leak containers — the caller decides whether
/// to error out or just warn.
pub(super) fn down(dir: &Path) -> Result<()> {
let status = Command::new("docker")
.args(["compose", "down"])
.current_dir(dir)
.stdout(Stdio::inherit())
.stderr(Stdio::inherit())
.status()
.map_err(|e| anyhow!("dev: docker compose down: {e}"))?;
if !status.success() {
return Err(anyhow!(
"docker compose down failed (exit {}). Run `docker compose down` manually to clean up.",
status.code().unwrap_or(-1)
));
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn ensure_compose_file_writes_when_missing() {
let tmp = tempfile::tempdir().unwrap();
let written = ensure_compose_file(tmp.path()).unwrap();
assert!(written);
let body = fs::read_to_string(tmp.path().join("docker-compose.yml")).unwrap();
assert!(body.contains("postgres:17-alpine"));
}

#[test]
fn ensure_compose_file_skips_when_present() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("docker-compose.yml"), b"# user's own file").unwrap();
let written = ensure_compose_file(tmp.path()).unwrap();
assert!(!written);
let body = fs::read_to_string(tmp.path().join("docker-compose.yml")).unwrap();
assert_eq!(body, "# user's own file");
}
}
150 changes: 150 additions & 0 deletions src/commands/dev/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
//! `mirrorstack dev` — run a module locally with the supporting services
//! it needs (Postgres for now; Redis + WSS tunnel client land in later PRs).
//!
//! The lifecycle is:
//! 1. Bootstrap a `docker-compose.yml` in the cwd if missing
//! 2. `docker compose up -d --wait` (server-side healthcheck blocks until pg is ready)
//! 3. Spawn `go run .` with `MS_LOCAL_DB_URL` injected
//! 4. Stream the module's stdout/stderr through a labeled prefix
//! 5. On Ctrl-C: kill the module process (SIGKILL on unix; SIGTERM-then-SIGKILL is queued as a follow-up), then `docker compose down`
//!
//! No WSS tunnel yet — the module runs as a normal HTTP server on its own
//! port. Future PR layers the tunnel registration on top.

use std::io::IsTerminal;
use std::path::PathBuf;

use anyhow::{Result, anyhow};
use clap::Args;
use console::style;

use super::{ok_mark, warn_prefix};

mod compose;
mod process;

#[derive(Args)]
pub struct DevArgs {
/// Working directory containing the module's main.go. Defaults to cwd.
#[arg(long)]
dir: Option<PathBuf>,
/// Skip bringing up `docker-compose` services. Use when you already have
/// Postgres running yourself.
#[arg(long)]
no_compose: bool,
/// `MS_LOCAL_DB_URL` override. Defaults to the bundled compose Postgres
/// when --no-compose is unset, otherwise must be supplied via env.
#[arg(long)]
db_url: Option<String>,
}

// Matches templates/dev/docker-compose.yml.tmpl: host port 5433 (chosen so
// the bundled compose can coexist with api-platform's own postgres on 5432).
const DEFAULT_DB_URL: &str =
"postgres://mirrorstack:mirrorstack@localhost:5433/mirrorstack?sslmode=disable";

pub fn run(args: DevArgs) -> Result<()> {
let cwd = args
.dir
.clone()
.unwrap_or_else(|| std::env::current_dir().expect("cwd"));
if !cwd.join("main.go").exists() {
return Err(anyhow!(
"{} doesn't look like a module — no main.go found. Run `mirrorstack module init` first, or pass --dir <module-path>.",
cwd.display()
));
}

let db_url = resolve_db_url(&args)?;

if !args.no_compose {
compose::ensure_compose_file(&cwd)?;
compose::up(&cwd)?;
}

eprintln!("{} module running — Ctrl-C to stop", ok_mark());
let module_status = process::run_module(&cwd, &db_url);

if !args.no_compose {
if let Err(e) = compose::down(&cwd) {
eprintln!(
"{} compose down failed: {e:#}. Run `docker compose down` to clean up.",
warn_prefix()
);
}
}

module_status
}

fn resolve_db_url(args: &DevArgs) -> Result<String> {
if let Some(url) = &args.db_url {
return Ok(url.clone());
}
if let Ok(url) = std::env::var("MS_LOCAL_DB_URL") {
return Ok(url);
}
if args.no_compose {
return Err(anyhow!(
"--no-compose requires MS_LOCAL_DB_URL (env or --db-url) — without compose there's no fallback"
));
}
Ok(DEFAULT_DB_URL.into())
}

/// Color-aware prefix used by both stdout and stderr forwarders. Skipped on
/// non-TTY targets so CI logs stay clean.
pub(super) fn line_prefix(label: &str) -> String {
if std::io::stderr().is_terminal() {
format!("{} {}", style(label).cyan().dim(), style("│").dim())
} else {
format!("[{label}]")
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn resolve_db_url_prefers_explicit_arg() {
let args = DevArgs {
dir: None,
no_compose: false,
db_url: Some("postgres://x".into()),
};
assert_eq!(resolve_db_url(&args).unwrap(), "postgres://x");
}

#[test]
fn resolve_db_url_no_compose_requires_env() {
// Skip when the runner happens to have MS_LOCAL_DB_URL set — the
// no-env error path is what we're after, and toggling process-global
// env from a test is a recipe for cross-test interference.
if std::env::var("MS_LOCAL_DB_URL").is_ok() {
return;
}
let args = DevArgs {
dir: None,
no_compose: true,
db_url: None,
};
let err = resolve_db_url(&args).unwrap_err().to_string();
assert!(err.contains("MS_LOCAL_DB_URL"));
}

#[test]
fn resolve_db_url_compose_default_when_unset() {
if std::env::var("MS_LOCAL_DB_URL").is_ok() {
return;
}
let args = DevArgs {
dir: None,
no_compose: false,
db_url: None,
};
let url = resolve_db_url(&args).unwrap();
assert!(url.starts_with("postgres://mirrorstack"));
assert!(url.contains(":5433/"));
}
}
Loading
Loading