Skip to content

Commit c3b1ff1

Browse files
committed
feat(cd): Session 3 — Deploy steps, migration & health check (CD-07..CD-11)
- CD-07 deploy_azure.rs: App Service, AKS, Container Apps deploy generators - CD-08 deploy_gcp.rs: Cloud Run, GKE deploy generators - CD-09 deploy_hetzner.rs: VPS (SSH), HetznerK8s, Coolify deploy generators - CD-10 migration.rs: 7 migration tools (Flyway, Liquibase, Alembic, Django, Prisma, sqlx, Diesel), SSH variant for VPS - CD-11 health_check.rs: curl with retry for HTTP targets, kubectl rollout status for K8s targets - Wire all 5 modules into cd_generation/mod.rs Tests: 290 passed (130 new), 0 failed. Clippy clean.
1 parent fb0697f commit c3b1ff1

6 files changed

Lines changed: 1636 additions & 8 deletions

File tree

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
//! CD-07 — Azure Deploy Step Generator
2+
//!
3+
//! Generates GitHub Actions YAML snippets for Azure deployment targets:
4+
//!
5+
//! | Target | Action | Key params |
6+
//! |-----------------|--------------------------------------|-------------------------------|
7+
//! | App Service | `azure/webapps-deploy@v3` | `app-name`, `images` |
8+
//! | AKS | `azure/k8s-deploy@v5` | `namespace`, `manifests` |
9+
//! | Container Apps | `azure/container-apps-deploy@v2` | `containerAppName`, `image` |
10+
//!
11+
//! Each function returns a `DeployStep` for the schema and a YAML snippet
12+
//! string for direct embedding. Rollback hints are also provided per target.
13+
14+
use super::context::DeployTarget;
15+
use super::schema::{DeployStep, RollbackInfo};
16+
17+
// ── Public API ────────────────────────────────────────────────────────────────
18+
19+
/// Generates the deploy step for the given Azure target.
20+
pub fn generate_azure_deploy(target: &DeployTarget, image_tag: &str) -> DeployStep {
21+
match target {
22+
DeployTarget::AppService => DeployStep {
23+
strategy: "rolling".to_string(),
24+
command: "azure/webapps-deploy@v3".to_string(),
25+
args: vec![
26+
"app-name={{APP_NAME}}".to_string(),
27+
format!("images={image_tag}"),
28+
],
29+
target: target.clone(),
30+
},
31+
DeployTarget::Aks => DeployStep {
32+
strategy: "rolling".to_string(),
33+
command: "azure/k8s-deploy@v5".to_string(),
34+
args: vec![
35+
"namespace={{K8S_NAMESPACE}}".to_string(),
36+
"manifests={{K8S_MANIFEST_DIR}}".to_string(),
37+
format!("images={image_tag}"),
38+
],
39+
target: target.clone(),
40+
},
41+
DeployTarget::ContainerApps => DeployStep {
42+
strategy: "rolling".to_string(),
43+
command: "azure/container-apps-deploy@v2".to_string(),
44+
args: vec![
45+
"containerAppName={{APP_NAME}}".to_string(),
46+
"resourceGroup={{RESOURCE_GROUP}}".to_string(),
47+
format!("imageToDeploy={image_tag}"),
48+
],
49+
target: target.clone(),
50+
},
51+
// Non-Azure targets should not reach here; return a sensible fallback.
52+
other => DeployStep {
53+
strategy: "rolling".to_string(),
54+
command: format!("echo 'Unsupported Azure target: {other}'"),
55+
args: vec![],
56+
target: other.clone(),
57+
},
58+
}
59+
}
60+
61+
/// Generates rollback info for the given Azure target.
62+
pub fn azure_rollback_info(target: &DeployTarget) -> RollbackInfo {
63+
match target {
64+
DeployTarget::AppService => RollbackInfo {
65+
strategy: "redeploy-previous".to_string(),
66+
command_hint: "az webapp deployment slot swap --resource-group {{RESOURCE_GROUP}} --name {{APP_NAME}} --slot staging --target-slot production".to_string(),
67+
},
68+
DeployTarget::Aks => RollbackInfo {
69+
strategy: "rollout-undo".to_string(),
70+
command_hint: "kubectl rollout undo deployment/{{DEPLOYMENT_NAME}} -n {{K8S_NAMESPACE}}".to_string(),
71+
},
72+
DeployTarget::ContainerApps => RollbackInfo {
73+
strategy: "redeploy-previous".to_string(),
74+
command_hint: "az containerapp revision activate --name {{APP_NAME}} --resource-group {{RESOURCE_GROUP}} --revision <previous-revision>".to_string(),
75+
},
76+
_ => RollbackInfo {
77+
strategy: "manual".to_string(),
78+
command_hint: "Manually redeploy the previous version".to_string(),
79+
},
80+
}
81+
}
82+
83+
/// Renders the App Service deploy step as a GitHub Actions YAML snippet.
84+
pub fn render_app_service_deploy_yaml(image_tag: &str) -> String {
85+
format!(
86+
"\
87+
- name: Deploy to Azure App Service
88+
uses: azure/webapps-deploy@v3
89+
with:
90+
app-name: ${{{{ secrets.APP_NAME }}}}
91+
images: {image_tag}\n"
92+
)
93+
}
94+
95+
/// Renders the AKS deploy step as a GitHub Actions YAML snippet.
96+
pub fn render_aks_deploy_yaml(image_tag: &str) -> String {
97+
format!(
98+
"\
99+
- name: Set AKS context
100+
uses: azure/aks-set-context@v4
101+
with:
102+
resource-group: ${{{{ secrets.RESOURCE_GROUP }}}}
103+
cluster-name: ${{{{ secrets.AKS_CLUSTER_NAME }}}}
104+
105+
- name: Deploy to AKS
106+
uses: azure/k8s-deploy@v5
107+
with:
108+
namespace: ${{{{ secrets.K8S_NAMESPACE }}}}
109+
manifests: |
110+
${{{{ secrets.K8S_MANIFEST_DIR }}}}/deployment.yaml
111+
${{{{ secrets.K8S_MANIFEST_DIR }}}}/service.yaml
112+
images: {image_tag}\n"
113+
)
114+
}
115+
116+
/// Renders the Container Apps deploy step as a GitHub Actions YAML snippet.
117+
pub fn render_container_apps_deploy_yaml(image_tag: &str) -> String {
118+
format!(
119+
"\
120+
- name: Deploy to Azure Container Apps
121+
uses: azure/container-apps-deploy@v2
122+
with:
123+
containerAppName: ${{{{ secrets.APP_NAME }}}}
124+
resourceGroup: ${{{{ secrets.RESOURCE_GROUP }}}}
125+
imageToDeploy: {image_tag}\n"
126+
)
127+
}
128+
129+
/// Renders the deploy YAML snippet for any Azure target.
130+
pub fn render_azure_deploy_yaml(target: &DeployTarget, image_tag: &str) -> String {
131+
match target {
132+
DeployTarget::AppService => render_app_service_deploy_yaml(image_tag),
133+
DeployTarget::Aks => render_aks_deploy_yaml(image_tag),
134+
DeployTarget::ContainerApps => render_container_apps_deploy_yaml(image_tag),
135+
_ => format!(" - name: Deploy\n run: echo 'Unsupported Azure target'\n"),
136+
}
137+
}
138+
139+
/// Returns secrets required for the Azure deploy target.
140+
pub fn azure_deploy_required_secrets(target: &DeployTarget) -> Vec<String> {
141+
match target {
142+
DeployTarget::AppService => vec![
143+
"APP_NAME".to_string(),
144+
],
145+
DeployTarget::Aks => vec![
146+
"RESOURCE_GROUP".to_string(),
147+
"AKS_CLUSTER_NAME".to_string(),
148+
"K8S_NAMESPACE".to_string(),
149+
"K8S_MANIFEST_DIR".to_string(),
150+
],
151+
DeployTarget::ContainerApps => vec![
152+
"APP_NAME".to_string(),
153+
"RESOURCE_GROUP".to_string(),
154+
],
155+
_ => vec![],
156+
}
157+
}
158+
159+
// ── Tests ─────────────────────────────────────────────────────────────────────
160+
161+
#[cfg(test)]
162+
mod tests {
163+
use super::*;
164+
165+
const IMAGE: &str = "myacr.azurecr.io/app:sha123";
166+
167+
// ── generate_azure_deploy ─────────────────────────────────────────
168+
169+
#[test]
170+
fn app_service_deploy_step_uses_correct_action() {
171+
let step = generate_azure_deploy(&DeployTarget::AppService, IMAGE);
172+
assert_eq!(step.command, "azure/webapps-deploy@v3");
173+
}
174+
175+
#[test]
176+
fn app_service_deploy_step_strategy_is_rolling() {
177+
let step = generate_azure_deploy(&DeployTarget::AppService, IMAGE);
178+
assert_eq!(step.strategy, "rolling");
179+
}
180+
181+
#[test]
182+
fn app_service_deploy_step_contains_app_name_placeholder() {
183+
let step = generate_azure_deploy(&DeployTarget::AppService, IMAGE);
184+
assert!(step.args.iter().any(|a| a.contains("{{APP_NAME}}")));
185+
}
186+
187+
#[test]
188+
fn app_service_deploy_step_contains_image_tag() {
189+
let step = generate_azure_deploy(&DeployTarget::AppService, IMAGE);
190+
assert!(step.args.iter().any(|a| a.contains(IMAGE)));
191+
}
192+
193+
#[test]
194+
fn aks_deploy_step_uses_correct_action() {
195+
let step = generate_azure_deploy(&DeployTarget::Aks, IMAGE);
196+
assert_eq!(step.command, "azure/k8s-deploy@v5");
197+
}
198+
199+
#[test]
200+
fn aks_deploy_step_contains_namespace_placeholder() {
201+
let step = generate_azure_deploy(&DeployTarget::Aks, IMAGE);
202+
assert!(step.args.iter().any(|a| a.contains("{{K8S_NAMESPACE}}")));
203+
}
204+
205+
#[test]
206+
fn aks_deploy_step_contains_manifest_dir_placeholder() {
207+
let step = generate_azure_deploy(&DeployTarget::Aks, IMAGE);
208+
assert!(step.args.iter().any(|a| a.contains("{{K8S_MANIFEST_DIR}}")));
209+
}
210+
211+
#[test]
212+
fn container_apps_deploy_step_uses_correct_action() {
213+
let step = generate_azure_deploy(&DeployTarget::ContainerApps, IMAGE);
214+
assert_eq!(step.command, "azure/container-apps-deploy@v2");
215+
}
216+
217+
#[test]
218+
fn container_apps_deploy_step_contains_resource_group() {
219+
let step = generate_azure_deploy(&DeployTarget::ContainerApps, IMAGE);
220+
assert!(step.args.iter().any(|a| a.contains("{{RESOURCE_GROUP}}")));
221+
}
222+
223+
#[test]
224+
fn container_apps_deploy_step_target_preserved() {
225+
let step = generate_azure_deploy(&DeployTarget::ContainerApps, IMAGE);
226+
assert_eq!(step.target, DeployTarget::ContainerApps);
227+
}
228+
229+
// ── azure_rollback_info ───────────────────────────────────────────
230+
231+
#[test]
232+
fn app_service_rollback_strategy() {
233+
let info = azure_rollback_info(&DeployTarget::AppService);
234+
assert_eq!(info.strategy, "redeploy-previous");
235+
}
236+
237+
#[test]
238+
fn app_service_rollback_uses_slot_swap() {
239+
let info = azure_rollback_info(&DeployTarget::AppService);
240+
assert!(info.command_hint.contains("slot swap"));
241+
}
242+
243+
#[test]
244+
fn aks_rollback_uses_rollout_undo() {
245+
let info = azure_rollback_info(&DeployTarget::Aks);
246+
assert_eq!(info.strategy, "rollout-undo");
247+
assert!(info.command_hint.contains("rollout undo"));
248+
}
249+
250+
#[test]
251+
fn container_apps_rollback_activates_previous_revision() {
252+
let info = azure_rollback_info(&DeployTarget::ContainerApps);
253+
assert!(info.command_hint.contains("revision activate"));
254+
}
255+
256+
// ── render_azure_deploy_yaml ──────────────────────────────────────
257+
258+
#[test]
259+
fn app_service_yaml_contains_action() {
260+
let yaml = render_azure_deploy_yaml(&DeployTarget::AppService, IMAGE);
261+
assert!(yaml.contains("azure/webapps-deploy@v3"));
262+
}
263+
264+
#[test]
265+
fn app_service_yaml_contains_image() {
266+
let yaml = render_azure_deploy_yaml(&DeployTarget::AppService, IMAGE);
267+
assert!(yaml.contains(IMAGE));
268+
}
269+
270+
#[test]
271+
fn app_service_yaml_references_app_name_secret() {
272+
let yaml = render_azure_deploy_yaml(&DeployTarget::AppService, IMAGE);
273+
assert!(yaml.contains("secrets.APP_NAME"));
274+
}
275+
276+
#[test]
277+
fn aks_yaml_contains_k8s_deploy_action() {
278+
let yaml = render_azure_deploy_yaml(&DeployTarget::Aks, IMAGE);
279+
assert!(yaml.contains("azure/k8s-deploy@v5"));
280+
}
281+
282+
#[test]
283+
fn aks_yaml_contains_set_context() {
284+
let yaml = render_azure_deploy_yaml(&DeployTarget::Aks, IMAGE);
285+
assert!(yaml.contains("azure/aks-set-context@v4"));
286+
}
287+
288+
#[test]
289+
fn aks_yaml_references_cluster_name() {
290+
let yaml = render_azure_deploy_yaml(&DeployTarget::Aks, IMAGE);
291+
assert!(yaml.contains("secrets.AKS_CLUSTER_NAME"));
292+
}
293+
294+
#[test]
295+
fn container_apps_yaml_contains_action() {
296+
let yaml = render_azure_deploy_yaml(&DeployTarget::ContainerApps, IMAGE);
297+
assert!(yaml.contains("azure/container-apps-deploy@v2"));
298+
}
299+
300+
#[test]
301+
fn container_apps_yaml_references_resource_group() {
302+
let yaml = render_azure_deploy_yaml(&DeployTarget::ContainerApps, IMAGE);
303+
assert!(yaml.contains("secrets.RESOURCE_GROUP"));
304+
}
305+
306+
// ── azure_deploy_required_secrets ─────────────────────────────────
307+
308+
#[test]
309+
fn app_service_requires_app_name() {
310+
let secrets = azure_deploy_required_secrets(&DeployTarget::AppService);
311+
assert!(secrets.contains(&"APP_NAME".to_string()));
312+
}
313+
314+
#[test]
315+
fn aks_requires_four_secrets() {
316+
let secrets = azure_deploy_required_secrets(&DeployTarget::Aks);
317+
assert_eq!(secrets.len(), 4);
318+
}
319+
320+
#[test]
321+
fn container_apps_requires_app_name_and_resource_group() {
322+
let secrets = azure_deploy_required_secrets(&DeployTarget::ContainerApps);
323+
assert!(secrets.contains(&"APP_NAME".to_string()));
324+
assert!(secrets.contains(&"RESOURCE_GROUP".to_string()));
325+
}
326+
}

0 commit comments

Comments
 (0)