greentic-deployer-dev 1.1.28539266405

Greentic deployer runtime for plan construction and deployment-pack dispatch
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
//! Bootstrap-time RBAC rules-pack emitter for the K8s env-pack (Phase D
//! plan §6 step 6).
//!
//! Renders the one-time, cluster-admin-applied bootstrap boundary as
//! reviewable YAML — the K8s analogue of the AWS-ECS IAM Terraform pack:
//!
//! - `k8s-min-rbac.yaml` — Namespace + ServiceAccount + namespaced Role
//!   (rules aggregated from the SAME operations list `validate` probes
//!   via `SelfSubjectAccessReview`) + RoleBinding.
//! - `README.md` — operator-facing review/apply/bind instructions.
//!
//! The customer is in the loop, by design: a cluster admin reviews the
//! YAML, applies it, mints a SHORT-LIVED token for the ServiceAccount
//! (`kubectl create token` — long-lived bearer tokens are not the
//! production default, per the plan), and binds it via `op credentials
//! rotate`. Greentic never executes against the admin credential.
//!
//! Namespace creation is cluster-scoped, steady-state deployment is
//! namespace-scoped (Q6): this pack is exactly that split — the ONLY
//! cluster-scoped object is the Namespace itself; the Role/RoleBinding
//! confine the deployer to it.

use std::fmt::Write as _;

use crate::credentials::{RulesPack, RulesPackEntry};

use super::credentials::K8sOperation;

/// Name of the ServiceAccount the rules pack provisions.
pub const DEPLOYER_SERVICE_ACCOUNT: &str = "greentic-deployer";

/// Filename of the RBAC manifest entry in the rendered rules pack. The
/// `--bind` path (`K8sDeployerCredentials::bootstrap`) extracts this exact
/// entry and applies it live, so the live apply and the offline
/// `kubectl apply -f` stay byte-identical — keep the renderer and the
/// extractor referencing this one constant.
pub(crate) const K8S_RBAC_MANIFEST_FILENAME: &str = "k8s-min-rbac.yaml";

/// Store-aligned path (under `secret://<env>/…`) where the deployer's bound
/// ServiceAccount token lives: `<tenant>/<team>/<category>/<name>`. It MUST be
/// store-aligned so `op secrets put` can write it and the resolver
/// (`cli::secrets::resolve_credentials_token`) can read it back via
/// `SecretRef::to_store_uri` — a non-aligned ref (e.g. `…/k8s/deployer-token`)
/// has no store location and fails to resolve. The bootstrap README advertises
/// `secret://<env>/{this}` so the documented binding matches what resolves.
pub(crate) const DEPLOYER_TOKEN_STORE_PATH: &str = "default/_/k8s-deployer/deployer_token";

/// Name of the in-cluster `core/v1 Secret` the `--bind` path writes the
/// minted ServiceAccount bearer into, in the deployer namespace. This is the
/// durable, portable home for the bound identity: the local dev-store is a
/// per-machine cache, whereas a different operator machine with ambient
/// cluster read access resolves the bearer from this Secret. The write goes
/// through the same guarded `KubeCluster::apply` as the RBAC objects, so it
/// carries the env-ownership label and a re-bind converges in place.
pub(crate) const DEPLOYER_IDENTITY_SECRET_NAME: &str = "greentic-deployer-identity";

/// `data` key under [`DEPLOYER_IDENTITY_SECRET_NAME`] holding the bearer. Only
/// the `k8s-client` kube layer reads/writes it (the `--bind` Secret round-trip);
/// gated to avoid a dead-code warning in feature-less builds.
#[cfg(feature = "k8s-client")]
pub(crate) const DEPLOYER_IDENTITY_BEARER_KEY: &str = "bearer";

/// Input shape for [`render_min_rbac_rules_pack`]. Borrowed; no heap cost.
pub struct K8sRulesPackInput<'a> {
    /// Env this pack is scoped to (names + labels).
    pub env_id: &'a str,
    /// Namespace the pack provisions and confines the deployer to.
    pub namespace: &'a str,
    /// Operator-supplied admin identity hint (kubeconfig context or
    /// admin name). Recorded in the README so reviewers see who was
    /// expected to apply the pack — never embedded as a credential.
    pub admin_context_hint: &'a str,
    /// Operations the Role allows. Mirrors the validate-time list 1:1 so
    /// the bootstrap-then-validate loop converges.
    pub operations: &'a [K8sOperation],
}

/// Render the rules pack. Pure function — no I/O; the shared writer
/// (`crate::credentials::write_rules_pack`) lands it on disk inside the
/// bootstrap flock.
pub fn render_min_rbac_rules_pack(input: &K8sRulesPackInput<'_>) -> RulesPack {
    RulesPack {
        entries: vec![
            RulesPackEntry {
                filename: K8S_RBAC_MANIFEST_FILENAME.into(),
                content: render_rbac_yaml(input),
                description: Some(format!(
                    "Namespace + minimum-privilege ServiceAccount/Role/RoleBinding for \
                     Greentic env `{}` (K8s rollout surface).",
                    input.env_id
                )),
            },
            RulesPackEntry {
                filename: "README.md".into(),
                content: render_readme(input),
                description: Some("Apply instructions for the K8s bootstrap rules pack.".into()),
            },
        ],
    }
}

/// Aggregate the flat operations list into Role rules: one rule per
/// `(group, resource)` in first-appearance order, verbs in list order.
fn group_rules(
    operations: &[K8sOperation],
) -> Vec<((&'static str, &'static str), Vec<&'static str>)> {
    let mut rules: Vec<((&'static str, &'static str), Vec<&'static str>)> = Vec::new();
    for operation in operations {
        let key = (operation.group, operation.resource);
        match rules.iter_mut().find(|(k, _)| *k == key) {
            Some((_, verbs)) => {
                if !verbs.contains(&operation.verb) {
                    verbs.push(operation.verb);
                }
            }
            None => rules.push((key, vec![operation.verb])),
        }
    }
    rules
}

fn render_rbac_yaml(input: &K8sRulesPackInput<'_>) -> String {
    let mut rules_yaml = String::new();
    for ((group, resource), verbs) in group_rules(input.operations) {
        let verbs_csv = verbs.join(", ");
        let _ = writeln!(rules_yaml, "  - apiGroups: [\"{group}\"]");
        let _ = writeln!(rules_yaml, "    resources: [\"{resource}\"]");
        let _ = writeln!(rules_yaml, "    verbs: [{verbs_csv}]");
    }

    format!(
        r#"# Greentic env-pack bootstrap — K8s deployer credentials (Phase D).
#
# Apply this with `kubectl apply -f k8s-min-rbac.yaml` using a CLUSTER
# ADMIN identity. It provisions the one-time bootstrap boundary for
# Greentic env `{env_id}`:
#
#   - Namespace `{namespace}` (the only cluster-scoped object here)
#   - ServiceAccount `{sa}` — the identity Greentic deploys as
#   - A namespaced Role with the minimum verbs the deployer exercises
#     (validated against this exact list by
#     `gtc op credentials requirements {env_id}`)
#   - A RoleBinding confining the ServiceAccount to the namespace
#
# Generated by greentic-deployer; safe to commit to source control.
apiVersion: v1
kind: Namespace
metadata:
  name: {namespace}
  labels:
    app.kubernetes.io/managed-by: greentic
    greentic.ai/env: "{env_id}"
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: {sa}
  namespace: {namespace}
  labels:
    app.kubernetes.io/managed-by: greentic
    greentic.ai/env: "{env_id}"
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: {sa}-min
  namespace: {namespace}
  labels:
    app.kubernetes.io/managed-by: greentic
    greentic.ai/env: "{env_id}"
rules:
{rules_yaml}---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: {sa}-min
  namespace: {namespace}
  labels:
    app.kubernetes.io/managed-by: greentic
    greentic.ai/env: "{env_id}"
subjects:
  - kind: ServiceAccount
    name: {sa}
    namespace: {namespace}
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: {sa}-min
"#,
        env_id = input.env_id,
        namespace = input.namespace,
        sa = DEPLOYER_SERVICE_ACCOUNT,
        rules_yaml = rules_yaml,
    )
}

fn render_readme(input: &K8sRulesPackInput<'_>) -> String {
    let op_bullets = input
        .operations
        .iter()
        .map(|operation| {
            let group = if operation.group.is_empty() {
                "core"
            } else {
                operation.group
            };
            format!("- `{group}/{}: {}`", operation.resource, operation.verb)
        })
        .collect::<Vec<_>>()
        .join("\n");

    format!(
        r#"# K8s bootstrap rules pack — env `{env_id}`

Generated by `gtc op credentials bootstrap {env_id}` for the
`greentic.deployer.k8s` env-pack.

Expected applier (operator-supplied hint, review before applying):

```
{admin_hint}
```

## What this is

The one-time bootstrap boundary your cluster admin reviews and applies.
Namespace creation is cluster-scoped; everything else is confined to
namespace `{namespace}`. The Role grants ONLY the operations Greentic's
K8s rollout surface exercises:

{op_bullets}

`gtc op credentials requirements {env_id}` probes this exact list via
`SelfSubjectAccessReview`, so applying this pack and re-running
requirements converges to green.

## How to apply

1. Review `k8s-min-rbac.yaml`. Adjust the namespace name or labels to
   your conventions if required (keep the Role verbs intact — removing
   one fails the matching requirements probe).

2. With cluster-admin credentials:

   ```sh
   kubectl apply -f k8s-min-rbac.yaml
   ```

3. Mint a SHORT-LIVED token for the deployer ServiceAccount (do not
   create long-lived ServiceAccount token Secrets):

   ```sh
   kubectl create token {sa} -n {namespace} --duration=1h
   ```

4. Bind the credential to env `{env_id}`:

   ```sh
   gtc op credentials rotate {env_id} --provided-credentials-ref \
     "secret://{env_id}/{store_path}"
   ```

5. Re-run requirements:

   ```sh
   gtc op credentials requirements {env_id}
   ```

   The `k8s.api.reachable` capability plus one capability per operation
   above must pass before deploys are honored.

## What this does NOT do

This pack only creates the deploy-time identity boundary. Workload
identity for pods (IRSA / AKS / GKE workload identity), secret-store
projection (ESO / CSI), and ingress wiring are separate decisions
recorded in the Zain alignment doc and land with the respective
env-packs.
"#,
        env_id = input.env_id,
        namespace = input.namespace,
        sa = DEPLOYER_SERVICE_ACCOUNT,
        admin_hint = input.admin_context_hint,
        op_bullets = op_bullets,
        store_path = DEPLOYER_TOKEN_STORE_PATH,
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::env_packs::k8s::credentials::VALIDATED_K8S_OPERATIONS;

    fn input<'a>() -> K8sRulesPackInput<'a> {
        K8sRulesPackInput {
            env_id: "zain-prod",
            namespace: "gtc-zain-prod",
            admin_context_hint: "zain-admin@nonprod",
            operations: VALIDATED_K8S_OPERATIONS,
        }
    }

    #[test]
    fn renders_two_entries_yaml_and_readme() {
        let pack = render_min_rbac_rules_pack(&input());
        let filenames: Vec<&str> = pack.entries.iter().map(|e| e.filename.as_str()).collect();
        assert_eq!(filenames, ["k8s-min-rbac.yaml", "README.md"]);
    }

    #[test]
    fn yaml_contains_all_four_objects_scoped_to_the_namespace() {
        let pack = render_min_rbac_rules_pack(&input());
        let yaml = &pack.entries[0].content;
        for kind in [
            "kind: Namespace",
            "kind: ServiceAccount",
            "kind: Role",
            "kind: RoleBinding",
        ] {
            assert!(yaml.contains(kind), "missing `{kind}`:\n{yaml}");
        }
        assert!(yaml.contains("name: gtc-zain-prod"));
        assert!(yaml.contains("namespace: gtc-zain-prod"));
        // The YAML parses (well-formed multi-document stream).
        for doc in yaml.split("\n---\n") {
            let parsed: serde_yaml_bw::Value =
                serde_yaml_bw::from_str(doc).expect("each document parses as YAML");
            assert!(parsed.is_mapping(), "each document is a mapping");
        }
    }

    #[test]
    fn role_rules_aggregate_one_rule_per_group_resource_with_every_verb() {
        let pack = render_min_rbac_rules_pack(&input());
        let yaml = &pack.entries[0].content;
        // One rule per (group, resource) — 7 distinct pairs in the list.
        assert_eq!(yaml.matches("- apiGroups:").count(), 7);
        assert!(yaml.contains("resources: [\"deployments\"]"));
        assert!(yaml.contains("verbs: [get, create, patch, delete]"));
        // The Vault worker ServiceAccount is grantable (env-lifetime, upsert).
        assert!(yaml.contains("resources: [\"serviceaccounts\"]"));
        let sa_rule = yaml
            .split("- apiGroups:")
            .find(|s| s.contains("serviceaccounts"))
            .unwrap();
        assert!(
            sa_rule.contains("verbs: [get, create, patch]"),
            "serviceaccounts is upsert-only:\n{sa_rule}"
        );
        // Secrets carry delete so a Vault reconcile clears the stale Secret.
        let secrets_rule = yaml
            .split("- apiGroups:")
            .find(|s| s.contains("\"secrets\""))
            .unwrap();
        assert!(
            secrets_rule.contains("verbs: [get, create, patch, delete]"),
            "secrets must carry delete for DevStore→Vault cleanup:\n{secrets_rule}"
        );
        // Env-lifetime objects carry no delete.
        assert!(yaml.contains("resources: [\"configmaps\"]"));
        let configmap_rule = yaml
            .split("- apiGroups:")
            .find(|s| s.contains("configmaps"))
            .unwrap();
        assert!(
            configmap_rule.contains("verbs: [get, create, patch]"),
            "configmaps must not get delete:\n{configmap_rule}"
        );
        // Every validated verb appears somewhere in the Role.
        for operation in VALIDATED_K8S_OPERATIONS {
            assert!(yaml.contains(operation.verb));
            assert!(yaml.contains(operation.resource));
        }
    }

    #[test]
    fn readme_lists_every_operation_and_the_bind_loop() {
        let pack = render_min_rbac_rules_pack(&input());
        let readme = &pack.entries[1].content;
        assert!(readme.contains("zain-admin@nonprod"));
        assert!(readme.contains("kubectl apply -f k8s-min-rbac.yaml"));
        // Short-lived token guidance, not a long-lived Secret.
        assert!(readme.contains("kubectl create token greentic-deployer"));
        assert!(readme.contains("gtc op credentials rotate zain-prod"));
        for operation in VALIDATED_K8S_OPERATIONS {
            let group = if operation.group.is_empty() {
                "core"
            } else {
                operation.group
            };
            let bullet = format!("- `{group}/{}: {}`", operation.resource, operation.verb);
            assert!(readme.contains(&bullet), "missing bullet {bullet}");
        }
    }

    #[test]
    fn rendering_is_deterministic() {
        let a = render_min_rbac_rules_pack(&input());
        let b = render_min_rbac_rules_pack(&input());
        assert_eq!(a.entries[0].content, b.entries[0].content);
        assert_eq!(a.entries[1].content, b.entries[1].content);
    }
}