Skip to content

Commit 54a9328

Browse files
committed
feat: Add safe ci-deploy-plan workflow
1 parent 2446dd5 commit 54a9328

7 files changed

Lines changed: 283 additions & 62 deletions

File tree

.github/workflows/sailr-ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ jobs:
2222
- ci
2323
- ci-build-plan
2424
- ci-generate
25+
- ci-deploy-plan
2526

2627
steps:
2728
- uses: actions/checkout@v4

sailr.workflow.toml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,16 @@ namespace = "default"
4646
approval = "prompt"
4747
apply = true
4848
report = "text"
49+
50+
[workflow.ci-deploy-plan]
51+
environment = "local"
52+
mode = "deploy"
53+
interactive = false
54+
build = "plan"
55+
generate = "run"
56+
deploy = "plan"
57+
deploy_context = "none"
58+
namespace = "default"
59+
approval = "none"
60+
apply = false
61+
report = "both"

src/default_config.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,15 @@ namespace = "default"
2020
approval = "prompt"
2121
apply = true
2222
report = "text"
23+
[workflow.ci-deploy-plan]
24+
environment = "local"
25+
mode = "deploy"
26+
interactive = false
27+
build = "plan"
28+
generate = "run"
29+
deploy = "plan"
30+
deploy_context = "none"
31+
namespace = "default"
32+
approval = "none"
33+
apply = false
34+
report = "both"

src/workflow/plan.rs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use crate::builder::SailrBuildPlan;
22
use crate::workflow::profile::NormalizedWorkflowProfile;
33
use crate::workflow::runner::RunnerContext;
4+
use serde::Serialize;
45

56
#[derive(Debug, Clone)]
67
pub struct WorkflowPlan {
@@ -48,3 +49,126 @@ pub struct WorkflowEffects {
4849
pub mutates_cluster: bool,
4950
pub prompts_user: bool,
5051
}
52+
53+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
54+
pub enum DeploymentPlanMode {
55+
Static,
56+
LiveDiff,
57+
}
58+
59+
#[derive(Debug, Clone, Serialize)]
60+
pub struct WorkflowDeploymentPlan {
61+
pub environment: String,
62+
pub context: String,
63+
pub namespace: String,
64+
pub mode: DeploymentPlanMode,
65+
pub resources: Vec<DeploymentResourcePlan>,
66+
pub summary: DeploymentPlanSummary,
67+
}
68+
69+
#[derive(Debug, Clone, Serialize)]
70+
pub struct DeploymentResourcePlan {
71+
pub kind: String,
72+
pub name: String,
73+
pub namespace: Option<String>,
74+
pub source_path: String,
75+
pub action: DeploymentPlanAction,
76+
}
77+
78+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
79+
#[serde(rename_all = "snake_case")]
80+
pub enum DeploymentPlanAction {
81+
WouldApply,
82+
WouldCreate,
83+
WouldUpdate,
84+
Unknown,
85+
}
86+
87+
impl std::fmt::Display for DeploymentPlanAction {
88+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89+
match self {
90+
Self::WouldApply => write!(f, "would apply"),
91+
Self::WouldCreate => write!(f, "would create"),
92+
Self::WouldUpdate => write!(f, "would update"),
93+
Self::Unknown => write!(f, "unknown"),
94+
}
95+
}
96+
}
97+
98+
#[derive(Debug, Clone, Serialize)]
99+
pub struct DeploymentPlanSummary {
100+
pub total_resources: usize,
101+
pub would_apply: usize,
102+
pub requires_cluster: bool,
103+
pub mutates_cluster: bool,
104+
}
105+
106+
pub fn generate_static_deployment_plan(
107+
environment: &str,
108+
context: &str,
109+
namespace: &str,
110+
) -> Result<WorkflowDeploymentPlan, String> {
111+
use serde_yaml::Value;
112+
use std::path::Path;
113+
use walkdir::WalkDir;
114+
115+
let mut resources = Vec::new();
116+
let root_path = format!("k8s/generated/{}", environment);
117+
118+
if Path::new(&root_path).exists() {
119+
for entry in WalkDir::new(&root_path).into_iter().filter_map(|e| e.ok()) {
120+
if entry.file_type().is_file() {
121+
if let Some(ext) = entry.path().extension() {
122+
if ext == "yaml" || ext == "yml" {
123+
if let Ok(contents) = std::fs::read_to_string(entry.path()) {
124+
for document in contents.split("---") {
125+
if document.trim().is_empty() {
126+
continue;
127+
}
128+
if let Ok(parsed) = serde_yaml::from_str::<Value>(document) {
129+
if let Some(kind) = parsed.get("kind").and_then(|v| v.as_str())
130+
{
131+
let meta = parsed.get("metadata");
132+
let name = meta
133+
.and_then(|m| m.get("name"))
134+
.and_then(|n| n.as_str())
135+
.unwrap_or("unknown")
136+
.to_string();
137+
let ns = meta
138+
.and_then(|m| m.get("namespace"))
139+
.and_then(|n| n.as_str())
140+
.map(|s| s.to_string());
141+
142+
resources.push(DeploymentResourcePlan {
143+
kind: kind.to_string(),
144+
name,
145+
namespace: ns,
146+
source_path: entry.path().to_string_lossy().to_string(),
147+
action: DeploymentPlanAction::WouldApply,
148+
});
149+
}
150+
}
151+
}
152+
}
153+
}
154+
}
155+
}
156+
}
157+
}
158+
159+
let summary = DeploymentPlanSummary {
160+
total_resources: resources.len(),
161+
would_apply: resources.len(),
162+
requires_cluster: false,
163+
mutates_cluster: false,
164+
};
165+
166+
Ok(WorkflowDeploymentPlan {
167+
environment: environment.to_string(),
168+
context: context.to_string(),
169+
namespace: namespace.to_string(),
170+
mode: DeploymentPlanMode::Static,
171+
resources,
172+
summary,
173+
})
174+
}

src/workflow/planner.rs

Lines changed: 80 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -194,53 +194,55 @@ impl WorkflowPlanner {
194194

195195
last_tasks = vec!["workflow:deployment-plan".to_string()];
196196

197-
if self.profile.approval == crate::workflow::profile::ApprovalMode::Prompt {
198-
effects.prompts_user = true;
199-
tasks.push(WorkflowTaskPlan {
200-
id: "workflow:approval".to_string(),
201-
label: "Approval".to_string(),
202-
kind: WorkflowTaskKind::Approval,
203-
dependencies: last_tasks.clone(),
204-
effects: WorkflowEffects {
205-
prompts_user: true,
206-
..Default::default()
207-
},
208-
description: "Ask for local confirmation before applying deployment changes."
209-
.to_string(),
210-
});
211-
212-
for t in &last_tasks {
213-
edges.push(WorkflowEdge {
214-
from: t.clone(),
215-
to: "workflow:approval".to_string(),
197+
if self.profile.deploy == crate::workflow::profile::WorkflowStepMode::Run {
198+
if self.profile.approval == crate::workflow::profile::ApprovalMode::Prompt {
199+
effects.prompts_user = true;
200+
tasks.push(WorkflowTaskPlan {
201+
id: "workflow:approval".to_string(),
202+
label: "Approval".to_string(),
203+
kind: WorkflowTaskKind::Approval,
204+
dependencies: last_tasks.clone(),
205+
effects: WorkflowEffects {
206+
prompts_user: true,
207+
..Default::default()
208+
},
209+
description:
210+
"Ask for local confirmation before applying deployment changes."
211+
.to_string(),
216212
});
217-
}
218213

219-
last_tasks = vec!["workflow:approval".to_string()];
220-
}
214+
for t in &last_tasks {
215+
edges.push(WorkflowEdge {
216+
from: t.clone(),
217+
to: "workflow:approval".to_string(),
218+
});
219+
}
221220

222-
if self.profile.deploy == crate::workflow::profile::WorkflowStepMode::Run
223-
&& self.profile.apply
224-
{
225-
effects.mutates_cluster = true;
226-
tasks.push(WorkflowTaskPlan {
227-
id: "workflow:deploy".to_string(),
228-
label: "Deploy".to_string(),
229-
kind: WorkflowTaskKind::Deploy,
230-
dependencies: last_tasks.clone(),
231-
effects: WorkflowEffects {
232-
mutates_cluster: true,
233-
..Default::default()
234-
},
235-
description: "Apply generated manifests to the configured Kubernetes context."
236-
.to_string(),
237-
});
221+
last_tasks = vec!["workflow:approval".to_string()];
222+
}
238223

239-
for t in &last_tasks {
240-
edges.push(WorkflowEdge {
241-
from: t.clone(),
242-
to: "workflow:deploy".to_string(),
224+
if self.profile.apply {
225+
effects.mutates_cluster = true;
226+
tasks.push(WorkflowTaskPlan {
227+
id: "workflow:deploy".to_string(),
228+
label: "Deploy".to_string(),
229+
kind: WorkflowTaskKind::Deploy,
230+
dependencies: last_tasks.clone(),
231+
effects: WorkflowEffects {
232+
mutates_cluster: true,
233+
..Default::default()
234+
},
235+
description:
236+
"Apply generated manifests to the configured Kubernetes context."
237+
.to_string(),
243238
});
239+
240+
for t in &last_tasks {
241+
edges.push(WorkflowEdge {
242+
from: t.clone(),
243+
to: "workflow:deploy".to_string(),
244+
});
245+
}
244246
}
245247
}
246248
}
@@ -368,24 +370,48 @@ impl WorkflowPlanner {
368370
.clone()
369371
.unwrap_or_else(|| "default".to_string());
370372

373+
let is_static_plan =
374+
self.profile.deploy == crate::workflow::profile::WorkflowStepMode::Plan;
375+
371376
task = task.exec_fn(move |_ctx| {
372377
let env_name = env_name.clone();
373378
let context = context.clone();
374379
let namespace = namespace.clone();
375380
async move {
376381
crate::LOGGER.info("Deployment plan:");
377-
let plan =
378-
crate::plan::generate_deployment_plan(&env_name, &context, &namespace)
379-
.await
380-
.map_err(|e| anyhow::anyhow!("Deployment plan failed: {}", e))?;
381-
382-
crate::plan::validate_plan_safety(&plan)
383-
.map_err(|e| anyhow::anyhow!("Deployment plan validation failed: {}", e))?;
384-
385-
// Display is a method on DeploymentPlan
386-
// Wait, let's look at src/plan.rs to see how to print the plan.
387-
// Actually, the spec says `plan.display()`, maybe that exists.
388-
// Let's assume there is a way to display.
382+
383+
if is_static_plan {
384+
let plan = crate::workflow::plan::generate_static_deployment_plan(
385+
&env_name, &context, &namespace,
386+
)
387+
.map_err(|e| anyhow::anyhow!("Static deployment plan failed: {}", e))?;
388+
389+
println!("Sailr deployment plan:");
390+
println!(" environment: {}", plan.environment);
391+
println!(" context: {}", plan.context);
392+
println!(" namespace: {}", plan.namespace);
393+
println!(" mode: static");
394+
println!(" requires cluster: no");
395+
println!(" mutates cluster: no\n");
396+
println!("Resources:");
397+
for res in plan.resources {
398+
println!(" - {} {} \twould apply", res.kind, res.name);
399+
}
400+
} else {
401+
let plan =
402+
crate::plan::generate_deployment_plan(&env_name, &context, &namespace)
403+
.await
404+
.map_err(|e| anyhow::anyhow!("Deployment plan failed: {}", e))?;
405+
406+
crate::plan::validate_plan_safety(&plan).map_err(|e| {
407+
anyhow::anyhow!("Deployment plan validation failed: {}", e)
408+
})?;
409+
410+
// Display is a method on DeploymentPlan
411+
// Wait, let's look at src/plan.rs to see how to print the plan.
412+
// Actually, the spec says `plan.display()`, maybe that exists.
413+
// Let's assume there is a way to display.
414+
}
389415

390416
Ok(())
391417
}

src/workflow/profile.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ impl WorkflowProfile {
165165
apply = false;
166166
}
167167
WorkflowMode::Go | WorkflowMode::Deploy => {
168-
if approval == ApprovalMode::None {
168+
if approval == ApprovalMode::None && deploy == WorkflowStepMode::Run {
169169
approval = if runner_is_ci {
170170
ApprovalMode::External
171171
} else {
@@ -761,4 +761,20 @@ mod tests {
761761
assert_eq!(normalized.deploy_context.as_deref(), Some("minikube"));
762762
assert_eq!(normalized.namespace.as_deref(), Some("default"));
763763
}
764+
#[test]
765+
fn normalize_ci_deploy_plan_profile() {
766+
let toml_str = r#"
767+
environment = "local"
768+
mode = "deploy"
769+
interactive = false
770+
deploy = "plan"
771+
deploy_context = "none"
772+
apply = false
773+
"#;
774+
let profile: WorkflowProfile = toml::from_str(toml_str).unwrap();
775+
let normalized = profile.normalize(true);
776+
assert_eq!(normalized.deploy, WorkflowStepMode::Plan);
777+
assert_eq!(normalized.deploy_context.as_deref(), Some("none"));
778+
assert!(!normalized.apply);
779+
}
764780
}

0 commit comments

Comments
 (0)