Skip to content

Commit 14df704

Browse files
committed
refactor: introduce CommandRunner trait and mock support to inject environment namespaces into deployment pipeline steps
1 parent 3df5efb commit 14df704

16 files changed

Lines changed: 1155 additions & 352 deletions

Cargo.lock

Lines changed: 528 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ miette = { version = "7", features = ["fancy"] }
1313
ratatui = "0.30"
1414
serde = { version = "1", features = ["derive"] }
1515
serde_yaml = "0.9"
16+
serde_json = "1"
17+
ureq = "2"
18+
time = { version = "0.3", features = ["formatting"] }
1619
tracing = "0.1"
1720
tracing-subscriber = "0.3"
1821
walkdir = "2"

src/compose/logs.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,14 @@ impl Default for ComposeLogRequest {
2020
}
2121
}
2222

23-
/// Fetch compose logs (non-follow mode).
23+
/// Fetch compose logs.
2424
pub fn fetch(request: &ComposeLogRequest, project_root: &Path) -> Result<Vec<String>> {
2525
let mut args = vec!["compose".to_string(), "logs".to_string()];
2626

27+
if request.follow {
28+
args.push("--follow".to_string());
29+
}
30+
2731
if let Some(tail) = request.tail {
2832
args.push("--tail".to_string());
2933
args.push(tail.to_string());
@@ -39,6 +43,11 @@ pub fn fetch(request: &ComposeLogRequest, project_root: &Path) -> Result<Vec<Str
3943
.output()
4044
.context("Failed to execute docker compose logs")?;
4145

46+
if !output.status.success() {
47+
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
48+
anyhow::bail!("docker compose logs failed: {}", stderr);
49+
}
50+
4251
let stdout = String::from_utf8_lossy(&output.stdout);
4352
let stderr = String::from_utf8_lossy(&output.stderr);
4453
let combined = format!("{stdout}{stderr}");

src/compose/services.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ pub fn running(project_root: &Path) -> Result<Vec<String>> {
3434
.output()
3535
.context("Failed to execute docker compose ps --services")?;
3636

37+
if !output.status.success() {
38+
let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
39+
anyhow::bail!("docker compose ps --services failed: {err}");
40+
}
41+
3742
let stdout = String::from_utf8_lossy(&output.stdout);
3843
let services = stdout
3944
.lines()

src/deploy/environments.rs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,14 @@ pub fn from_string(s: &str) -> Environment {
1616
match s.to_lowercase().as_str() {
1717
"staging" | "stg" => Environment::Staging,
1818
"production" | "prod" => Environment::Production,
19-
_ => Environment::Development,
19+
"development" | "dev" | "" => Environment::Development,
20+
other => {
21+
tracing::warn!(
22+
"Unknown environment input '{}', falling back to Development",
23+
other
24+
);
25+
Environment::Development
26+
}
2027
}
2128
}
2229

src/deploy/pipeline.rs

Lines changed: 215 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -145,27 +145,81 @@ pub fn plan(capabilities: &ProjectCapabilities, runtime: &RuntimeCapabilities) -
145145
DeploymentPlan { steps, blockers }
146146
}
147147

148+
pub trait CommandRunner {
149+
fn run(
150+
&self,
151+
cmd: &str,
152+
args: &[&str],
153+
current_dir: Option<&std::path::Path>,
154+
) -> Result<std::process::Output>;
155+
}
156+
157+
pub struct RealCommandRunner;
158+
159+
impl CommandRunner for RealCommandRunner {
160+
fn run(
161+
&self,
162+
cmd: &str,
163+
args: &[&str],
164+
current_dir: Option<&std::path::Path>,
165+
) -> Result<std::process::Output> {
166+
let mut command = Command::new(cmd);
167+
command.args(args);
168+
if let Some(dir) = current_dir {
169+
command.current_dir(dir);
170+
}
171+
command
172+
.output()
173+
.context(format!("Failed to execute {}", cmd))
174+
}
175+
}
176+
148177
/// Execute the deployment pipeline against a real project.
149178
pub fn execute_pipeline(
150179
plan: &DeploymentPlan,
151180
project: &ProjectContext,
152181
capabilities: &ProjectCapabilities,
182+
environment_str: &str,
183+
) -> Result<PipelineExecution> {
184+
execute_pipeline_with_runner(
185+
plan,
186+
project,
187+
capabilities,
188+
environment_str,
189+
&RealCommandRunner,
190+
)
191+
}
192+
193+
pub fn execute_pipeline_with_runner(
194+
plan: &DeploymentPlan,
195+
project: &ProjectContext,
196+
capabilities: &ProjectCapabilities,
197+
environment_str: &str,
198+
runner: &dyn CommandRunner,
153199
) -> Result<PipelineExecution> {
154200
if !plan.ready() {
155201
anyhow::bail!("Deployment plan has blockers: {}", plan.blockers.join(", "));
156202
}
157203

204+
let env = crate::deploy::environments::from_string(environment_str);
205+
let namespace = crate::deploy::environments::resolve_namespace(&env);
206+
158207
let mut results = Vec::new();
159208
let mut overall_success = true;
160209

161210
for step in &plan.steps {
162211
let start = Instant::now();
163212
let step_result = match step {
164-
PipelineStep::Build => execute_build_step(project),
213+
PipelineStep::Build => execute_build_step(project, runner),
165214
PipelineStep::DockerBuild => execute_docker_build_step(project),
166215
PipelineStep::DockerPush => execute_docker_push_step(project),
167-
PipelineStep::DeploymentUpdate => execute_deployment_update_step(project, capabilities),
168-
PipelineStep::RolloutVerification => execute_rollout_verification_step(),
216+
PipelineStep::DeploymentUpdate => {
217+
execute_deployment_update_step(project, capabilities, &namespace, runner)
218+
}
219+
PipelineStep::RolloutVerification => {
220+
let deployment_name = project.name.to_lowercase().replace(' ', "-");
221+
execute_rollout_verification_step(&deployment_name, &namespace, runner)
222+
}
169223
};
170224
let duration_secs = start.elapsed().as_secs_f64();
171225

@@ -198,7 +252,7 @@ pub fn execute_pipeline(
198252
})
199253
}
200254

201-
fn execute_build_step(project: &ProjectContext) -> Result<String> {
255+
fn execute_build_step(project: &ProjectContext, runner: &dyn CommandRunner) -> Result<String> {
202256
let build_cmd =
203257
crate::templates::stacks::build_command(project.stack).unwrap_or("echo 'No build step'");
204258

@@ -207,11 +261,7 @@ fn execute_build_step(project: &ProjectContext) -> Result<String> {
207261
return Ok("No build command for this stack".to_string());
208262
}
209263

210-
let output = Command::new(parts[0])
211-
.args(&parts[1..])
212-
.current_dir(&project.root)
213-
.output()
214-
.with_context(|| format!("Failed to execute build command: {build_cmd}"))?;
264+
let output = runner.run(parts[0], &parts[1..], Some(&project.root))?;
215265

216266
if output.status.success() {
217267
Ok(format!("Build completed: {build_cmd}"))
@@ -247,23 +297,25 @@ fn execute_docker_push_step(project: &ProjectContext) -> Result<String> {
247297
fn execute_deployment_update_step(
248298
project: &ProjectContext,
249299
capabilities: &ProjectCapabilities,
300+
namespace: &str,
301+
runner: &dyn CommandRunner,
250302
) -> Result<String> {
251303
if !capabilities.kubernetes {
252304
return Ok("No Kubernetes manifests to apply".to_string());
253305
}
254306

255-
// Apply all detected Kubernetes manifests.
256307
let k8s_dir = project.root.join("k8s");
257-
let manifest_path = if k8s_dir.exists() {
258-
k8s_dir.to_string_lossy().to_string()
259-
} else {
260-
project.root.to_string_lossy().to_string()
261-
};
308+
if !k8s_dir.exists() || !k8s_dir.is_dir() {
309+
anyhow::bail!("k8s/ directory is absent");
310+
}
262311

263-
let output = Command::new("kubectl")
264-
.args(["apply", "-f", &manifest_path])
265-
.output()
266-
.context("Failed to execute kubectl apply")?;
312+
let manifest_path = k8s_dir.to_string_lossy().to_string();
313+
314+
let output = runner.run(
315+
"kubectl",
316+
&["apply", "-f", &manifest_path, "-n", namespace],
317+
None,
318+
)?;
267319

268320
if output.status.success() {
269321
let stdout = String::from_utf8_lossy(&output.stdout);
@@ -274,11 +326,23 @@ fn execute_deployment_update_step(
274326
}
275327
}
276328

277-
fn execute_rollout_verification_step() -> Result<String> {
278-
let output = Command::new("kubectl")
279-
.args(["rollout", "status", "deployment", "--timeout=120s"])
280-
.output()
281-
.context("Failed to execute kubectl rollout status")?;
329+
fn execute_rollout_verification_step(
330+
name: &str,
331+
namespace: &str,
332+
runner: &dyn CommandRunner,
333+
) -> Result<String> {
334+
let output = runner.run(
335+
"kubectl",
336+
&[
337+
"rollout",
338+
"status",
339+
&format!("deployment/{}", name),
340+
"-n",
341+
namespace,
342+
"--timeout=120s",
343+
],
344+
None,
345+
)?;
282346

283347
if output.status.success() {
284348
Ok("Rollout verified successfully".to_string())
@@ -362,3 +426,130 @@ mod tests {
362426
assert!(rendered.contains("✗ Docker Build"));
363427
}
364428
}
429+
430+
#[cfg(test)]
431+
mod pipeline_mock_tests {
432+
use super::*;
433+
use std::sync::Mutex;
434+
435+
struct MockRunner {
436+
calls: Mutex<Vec<(String, Vec<String>)>>,
437+
success: bool,
438+
}
439+
440+
impl CommandRunner for MockRunner {
441+
fn run(
442+
&self,
443+
cmd: &str,
444+
args: &[&str],
445+
_current_dir: Option<&std::path::Path>,
446+
) -> Result<std::process::Output> {
447+
self.calls.lock().unwrap().push((
448+
cmd.to_string(),
449+
args.iter().map(|s| s.to_string()).collect(),
450+
));
451+
let status = if self.success {
452+
Command::new("true").status().unwrap()
453+
} else {
454+
Command::new("false").status().unwrap()
455+
};
456+
Ok(std::process::Output {
457+
status,
458+
stdout: b"mock-output".to_vec(),
459+
stderr: b"mock-error".to_vec(),
460+
})
461+
}
462+
}
463+
464+
#[test]
465+
fn test_execute_deployment_update_step() {
466+
let temp = std::env::temp_dir().join(format!(
467+
"kdc-k8s-test-{}",
468+
std::time::SystemTime::now()
469+
.duration_since(std::time::UNIX_EPOCH)
470+
.unwrap()
471+
.as_nanos()
472+
));
473+
std::fs::create_dir_all(temp.join("k8s")).unwrap();
474+
475+
let project = ProjectContext {
476+
name: "test-proj".to_string(),
477+
root: temp.clone(),
478+
stack: crate::domain::project::ProjectStack::Rust,
479+
assets: vec![],
480+
};
481+
let caps = ProjectCapabilities {
482+
kubernetes: true,
483+
..Default::default()
484+
};
485+
let runner = MockRunner {
486+
calls: Mutex::new(vec![]),
487+
success: true,
488+
};
489+
490+
let result = execute_deployment_update_step(&project, &caps, "my-namespace", &runner);
491+
assert!(result.is_ok());
492+
493+
let calls = runner.calls.lock().unwrap();
494+
assert_eq!(calls.len(), 1);
495+
assert_eq!(calls[0].0, "kubectl");
496+
assert!(calls[0].1.contains(&"-n".to_string()));
497+
assert!(calls[0].1.contains(&"my-namespace".to_string()));
498+
assert!(calls[0]
499+
.1
500+
.contains(&temp.join("k8s").to_string_lossy().to_string()));
501+
502+
std::fs::remove_dir_all(temp).unwrap();
503+
}
504+
505+
#[test]
506+
fn test_execute_deployment_update_step_missing_k8s() {
507+
let temp = std::env::temp_dir().join(format!(
508+
"kdc-k8s-test-{}",
509+
std::time::SystemTime::now()
510+
.duration_since(std::time::UNIX_EPOCH)
511+
.unwrap()
512+
.as_nanos()
513+
));
514+
std::fs::create_dir_all(&temp).unwrap(); // no k8s folder
515+
516+
let project = ProjectContext {
517+
name: "test-proj".to_string(),
518+
root: temp.clone(),
519+
stack: crate::domain::project::ProjectStack::Rust,
520+
assets: vec![],
521+
};
522+
let caps = ProjectCapabilities {
523+
kubernetes: true,
524+
..Default::default()
525+
};
526+
let runner = MockRunner {
527+
calls: Mutex::new(vec![]),
528+
success: true,
529+
};
530+
531+
let result = execute_deployment_update_step(&project, &caps, "my-namespace", &runner);
532+
assert!(result.is_err());
533+
assert_eq!(result.unwrap_err().to_string(), "k8s/ directory is absent");
534+
535+
std::fs::remove_dir_all(temp).unwrap();
536+
}
537+
538+
#[test]
539+
fn test_execute_rollout_verification_step() {
540+
let runner = MockRunner {
541+
calls: Mutex::new(vec![]),
542+
success: true,
543+
};
544+
545+
let result = execute_rollout_verification_step("my-app", "my-namespace", &runner);
546+
assert!(result.is_ok());
547+
548+
let calls = runner.calls.lock().unwrap();
549+
assert_eq!(calls.len(), 1);
550+
assert_eq!(calls[0].0, "kubectl");
551+
assert!(calls[0].1.contains(&"deployment/my-app".to_string()));
552+
assert!(calls[0].1.contains(&"-n".to_string()));
553+
assert!(calls[0].1.contains(&"my-namespace".to_string()));
554+
}
555+
}

0 commit comments

Comments
 (0)