Skip to content

Commit fb0697f

Browse files
committed
feat(cd): Session 2 — Auth & Registry modules (CD-03, CD-04, CD-05, CD-06)
- CD-03: registry.rs — ACR, GAR, GHCR login steps + image tag strategy + render_registry_login_yaml() for each registry type + build_image_tag() / build_gar_image_tag() helpers + registry_secrets_doc_entries() for SECRETS_REQUIRED.md + 30 unit tests - CD-04: auth_azure.rs — Azure OIDC auth step (azure/login@v2) + generate_azure_auth() config + render_azure_auth_yaml() + OIDC permissions block helper + 20 unit tests - CD-05: auth_gcp.rs — GCP WIF auth step (google-github-actions/auth@v2) + generate_gcp_auth() config + render_gcp_auth_yaml() + GAR Docker auth helper (gcloud auth configure-docker) + 24 unit tests - CD-06: auth_hetzner.rs — Hetzner SSH + kubeconfig auth + VPS pattern: webfactory/ssh-agent + known_hosts + K8s pattern: kubeconfig write from secret + 28 unit tests All 160 cd_generation tests pass. Zero clippy warnings.
1 parent 71fdbda commit fb0697f

5 files changed

Lines changed: 1413 additions & 0 deletions

File tree

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
//! CD-04 — Azure OIDC Authentication Step
2+
//!
3+
//! Generates the GitHub Actions YAML snippet for Azure login using
4+
//! OpenID Connect (OIDC) / Workload Identity Federation. This is the
5+
//! recommended zero-secret-rotation approach:
6+
//!
7+
//! ```yaml
8+
//! - name: Azure login (OIDC)
9+
//! uses: azure/login@v2
10+
//! with:
11+
//! client-id: ${{ secrets.AZURE_CLIENT_ID }}
12+
//! tenant-id: ${{ secrets.AZURE_TENANT_ID }}
13+
//! subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
14+
//! ```
15+
//!
16+
//! The workflow must have `permissions: { id-token: write }` for OIDC to work.
17+
18+
use super::schema::AuthStep;
19+
20+
// ── Public types ──────────────────────────────────────────────────────────────
21+
22+
/// Resolved Azure auth configuration.
23+
#[derive(Debug, Clone, PartialEq, Eq)]
24+
pub struct AzureAuthConfig {
25+
/// GitHub Actions action reference.
26+
pub action: String,
27+
/// Auth method label.
28+
pub method: String,
29+
/// Secrets the user must configure.
30+
pub required_secrets: Vec<String>,
31+
}
32+
33+
// ── Public API ────────────────────────────────────────────────────────────────
34+
35+
/// Builds the Azure OIDC auth configuration.
36+
pub fn generate_azure_auth() -> AzureAuthConfig {
37+
AzureAuthConfig {
38+
action: "azure/login@v2".to_string(),
39+
method: "oidc".to_string(),
40+
required_secrets: vec![
41+
"AZURE_CLIENT_ID".to_string(),
42+
"AZURE_TENANT_ID".to_string(),
43+
"AZURE_SUBSCRIPTION_ID".to_string(),
44+
],
45+
}
46+
}
47+
48+
/// Converts an `AzureAuthConfig` into the schema `AuthStep` for pipeline assembly.
49+
pub fn to_auth_step(config: &AzureAuthConfig) -> AuthStep {
50+
AuthStep {
51+
action: Some(config.action.clone()),
52+
method: config.method.clone(),
53+
required_secrets: config.required_secrets.clone(),
54+
}
55+
}
56+
57+
/// Renders the Azure OIDC login step as a GitHub Actions YAML snippet.
58+
///
59+
/// The output includes the `permissions` block comment as a reminder and
60+
/// the login step itself with all three OIDC secrets.
61+
pub fn render_azure_auth_yaml(config: &AzureAuthConfig) -> String {
62+
format!(
63+
"\
64+
- name: Azure login (OIDC)
65+
uses: {action}
66+
with:
67+
client-id: ${{{{ secrets.AZURE_CLIENT_ID }}}}
68+
tenant-id: ${{{{ secrets.AZURE_TENANT_ID }}}}
69+
subscription-id: ${{{{ secrets.AZURE_SUBSCRIPTION_ID }}}}\n",
70+
action = config.action,
71+
)
72+
}
73+
74+
/// Returns the `permissions` block needed at the job level for OIDC.
75+
pub fn azure_oidc_permissions_yaml() -> &'static str {
76+
"\
77+
permissions:
78+
id-token: write
79+
contents: read\n"
80+
}
81+
82+
/// Renders secrets documentation entries for Azure OIDC.
83+
pub fn azure_auth_secrets_doc() -> String {
84+
"\
85+
### `AZURE_CLIENT_ID` *(required)*
86+
87+
Application (client) ID of the Azure AD App Registration used for OIDC federation.
88+
89+
**Where to set:** Repository → Settings → Secrets and variables → Actions
90+
91+
**How to obtain:** `az ad app show --id <app-id> --query appId -o tsv`
92+
93+
---
94+
95+
### `AZURE_TENANT_ID` *(required)*
96+
97+
Azure Active Directory tenant ID.
98+
99+
**Where to set:** Repository → Settings → Secrets and variables → Actions
100+
101+
**How to obtain:** `az account show --query tenantId -o tsv`
102+
103+
---
104+
105+
### `AZURE_SUBSCRIPTION_ID` *(required)*
106+
107+
Azure subscription ID for the target deployment.
108+
109+
**Where to set:** Repository → Settings → Secrets and variables → Actions
110+
111+
**How to obtain:** `az account show --query id -o tsv`\n"
112+
.to_string()
113+
}
114+
115+
// ── Tests ─────────────────────────────────────────────────────────────────────
116+
117+
#[cfg(test)]
118+
mod tests {
119+
use super::*;
120+
121+
// ── generate_azure_auth ───────────────────────────────────────────
122+
123+
#[test]
124+
fn config_uses_azure_login_v2() {
125+
let config = generate_azure_auth();
126+
assert_eq!(config.action, "azure/login@v2");
127+
}
128+
129+
#[test]
130+
fn config_method_is_oidc() {
131+
let config = generate_azure_auth();
132+
assert_eq!(config.method, "oidc");
133+
}
134+
135+
#[test]
136+
fn config_requires_three_secrets() {
137+
let config = generate_azure_auth();
138+
assert_eq!(config.required_secrets.len(), 3);
139+
}
140+
141+
#[test]
142+
fn config_requires_client_id() {
143+
let config = generate_azure_auth();
144+
assert!(config.required_secrets.contains(&"AZURE_CLIENT_ID".to_string()));
145+
}
146+
147+
#[test]
148+
fn config_requires_tenant_id() {
149+
let config = generate_azure_auth();
150+
assert!(config.required_secrets.contains(&"AZURE_TENANT_ID".to_string()));
151+
}
152+
153+
#[test]
154+
fn config_requires_subscription_id() {
155+
let config = generate_azure_auth();
156+
assert!(config.required_secrets.contains(&"AZURE_SUBSCRIPTION_ID".to_string()));
157+
}
158+
159+
// ── to_auth_step ──────────────────────────────────────────────────
160+
161+
#[test]
162+
fn to_auth_step_preserves_action() {
163+
let config = generate_azure_auth();
164+
let step = to_auth_step(&config);
165+
assert_eq!(step.action, Some("azure/login@v2".to_string()));
166+
}
167+
168+
#[test]
169+
fn to_auth_step_preserves_method() {
170+
let config = generate_azure_auth();
171+
let step = to_auth_step(&config);
172+
assert_eq!(step.method, "oidc");
173+
}
174+
175+
#[test]
176+
fn to_auth_step_preserves_secrets() {
177+
let config = generate_azure_auth();
178+
let step = to_auth_step(&config);
179+
assert_eq!(step.required_secrets.len(), 3);
180+
}
181+
182+
// ── render_azure_auth_yaml ────────────────────────────────────────
183+
184+
#[test]
185+
fn yaml_contains_action_reference() {
186+
let config = generate_azure_auth();
187+
let yaml = render_azure_auth_yaml(&config);
188+
assert!(yaml.contains("azure/login@v2"));
189+
}
190+
191+
#[test]
192+
fn yaml_references_client_id_secret() {
193+
let config = generate_azure_auth();
194+
let yaml = render_azure_auth_yaml(&config);
195+
assert!(yaml.contains("secrets.AZURE_CLIENT_ID"));
196+
}
197+
198+
#[test]
199+
fn yaml_references_tenant_id_secret() {
200+
let config = generate_azure_auth();
201+
let yaml = render_azure_auth_yaml(&config);
202+
assert!(yaml.contains("secrets.AZURE_TENANT_ID"));
203+
}
204+
205+
#[test]
206+
fn yaml_references_subscription_id_secret() {
207+
let config = generate_azure_auth();
208+
let yaml = render_azure_auth_yaml(&config);
209+
assert!(yaml.contains("secrets.AZURE_SUBSCRIPTION_ID"));
210+
}
211+
212+
#[test]
213+
fn yaml_contains_step_name() {
214+
let config = generate_azure_auth();
215+
let yaml = render_azure_auth_yaml(&config);
216+
assert!(yaml.contains("- name: Azure login"));
217+
}
218+
219+
#[test]
220+
fn yaml_no_hardcoded_secret_values() {
221+
let config = generate_azure_auth();
222+
let yaml = render_azure_auth_yaml(&config);
223+
// Should reference secrets, never embed UUIDs or real values
224+
assert!(!yaml.contains("00000000-"));
225+
assert!(yaml.contains("${{"));
226+
}
227+
228+
// ── azure_oidc_permissions_yaml ───────────────────────────────────
229+
230+
#[test]
231+
fn permissions_contains_id_token_write() {
232+
let perms = azure_oidc_permissions_yaml();
233+
assert!(perms.contains("id-token: write"));
234+
}
235+
236+
#[test]
237+
fn permissions_contains_contents_read() {
238+
let perms = azure_oidc_permissions_yaml();
239+
assert!(perms.contains("contents: read"));
240+
}
241+
242+
// ── azure_auth_secrets_doc ────────────────────────────────────────
243+
244+
#[test]
245+
fn secrets_doc_mentions_all_three_secrets() {
246+
let doc = azure_auth_secrets_doc();
247+
assert!(doc.contains("AZURE_CLIENT_ID"));
248+
assert!(doc.contains("AZURE_TENANT_ID"));
249+
assert!(doc.contains("AZURE_SUBSCRIPTION_ID"));
250+
}
251+
252+
#[test]
253+
fn secrets_doc_includes_az_cli_commands() {
254+
let doc = azure_auth_secrets_doc();
255+
assert!(doc.contains("az ad app show"));
256+
assert!(doc.contains("az account show"));
257+
}
258+
259+
#[test]
260+
fn secrets_doc_marks_all_as_required() {
261+
let doc = azure_auth_secrets_doc();
262+
assert_eq!(doc.matches("*(required)*").count(), 3);
263+
}
264+
}

0 commit comments

Comments
 (0)