Skip to main content

xianyun_infra/
dev_ca.rs

1//! Embeds the dev-CA kustomization used to deploy caproni's development
2//! certificate authority and its supporting cert-manager resources (CA
3//! Secret, ClusterIssuer, trust Bundle, and a wildcard Certificate for the
4//! cluster hostname).
5//!
6//! Ported from `internal/infra/assets/dev-ca/assets.go` and
7//! `devca_overlay_test.go`. The Go original uses `//go:embed *.yaml`
8//! against `embed.FS`; this port has no directly equivalent virtual
9//! filesystem type, so [`FILES`] is a `const` slice of `(name, content)`
10//! pairs built from `include_str!`, materialized into a
11//! `xianyun_backends::kustomize::FileSystem` by [`write_files`] the same
12//! way the Go test's `assetstest.WriteFS` walks an `embed.FS`. See
13//! `PORT_NOTES.md`.
14//!
15//! The Traefik TLSStore that makes that wildcard certificate Traefik's
16//! default is a separate module, [`crate::traefik_tlsstore`]: it needs the
17//! Traefik CRDs, which the resources here do not.
18
19use xianyun_backends::kustomize::FileSystem;
20
21/// Ported from the `//go:embed *.yaml` directive on `devca.FS`.
22pub const FILES: &[(&str, &str)] = &[
23    ("bundle.yaml", include_str!("dev_ca_assets/bundle.yaml")),
24    (
25        "certificate.yaml",
26        include_str!("dev_ca_assets/certificate.yaml"),
27    ),
28    (
29        "clusterissuer.yaml",
30        include_str!("dev_ca_assets/clusterissuer.yaml"),
31    ),
32    (
33        "kustomization.yaml",
34        include_str!("dev_ca_assets/kustomization.yaml"),
35    ),
36    (
37        "namespace.yaml",
38        include_str!("dev_ca_assets/namespace.yaml"),
39    ),
40];
41
42/// Ported from `assetstest.WriteFS`, specialized to [`FILES`] rather than
43/// a generic `io/fs.FS`, since this port embeds files as a `const` slice
44/// rather than a virtual filesystem type.
45pub fn write_files(fsys: &dyn FileSystem, dir: &str) -> std::io::Result<()> {
46    for (name, content) in FILES {
47        fsys.write_file(&format!("{dir}/{name}"), content.as_bytes())?;
48    }
49
50    Ok(())
51}
52
53#[cfg(test)]
54mod tests {
55    use std::collections::HashMap;
56    use std::sync::Mutex;
57
58    use xianyun_backends::kustomize::{Backend, Client, FileSystem};
59    use xianyun_executor::exec::Client as ExecClient;
60
61    use super::*;
62
63    /// A minimal in-memory [`FileSystem`], standing in for
64    /// `filesys.MakeFsInMemory()`.
65    #[derive(Default)]
66    struct InMemoryFs {
67        files: Mutex<HashMap<String, Vec<u8>>>,
68    }
69
70    impl FileSystem for InMemoryFs {
71        fn write_file(&self, path: &str, content: &[u8]) -> std::io::Result<()> {
72            self.files
73                .lock()
74                .unwrap()
75                .insert(path.to_string(), content.to_vec());
76            Ok(())
77        }
78
79        fn read_file(&self, path: &str) -> std::io::Result<Vec<u8>> {
80            self.files
81                .lock()
82                .unwrap()
83                .get(path)
84                .cloned()
85                .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, path.to_string()))
86        }
87
88        fn walk(&self, dir: &str) -> std::io::Result<Vec<String>> {
89            let prefix = if dir.ends_with('/') {
90                dir.to_string()
91            } else {
92                format!("{dir}/")
93            };
94            Ok(self
95                .files
96                .lock()
97                .unwrap()
98                .keys()
99                .filter(|path| path.starts_with(&prefix) || *path == dir)
100                .cloned()
101                .collect())
102        }
103    }
104
105    /// A decoded Kubernetes resource, keyed by GVK + namespace + name, so
106    /// two renderings compare independent of document order. Ported from
107    /// `resourceKey`/`decodeResources`.
108    #[derive(Debug, Clone, PartialEq, Eq, Hash)]
109    struct ResourceKey {
110        api_version: String,
111        kind: String,
112        namespace: String,
113        name: String,
114    }
115
116    /// Ported from `decodeResources`. Parses a multi-document YAML stream
117    /// into a map keyed by [`ResourceKey`]. Each value is the object's
118    /// normalized content, so key ordering and base64 line wrapping do not
119    /// affect equality.
120    ///
121    /// `serde_yaml` 0.9's [`serde_yaml::Deserializer`] implements
122    /// `Iterator`, yielding one deserializer per `---`-separated document
123    /// in the stream -- the direct equivalent of Go's
124    /// `yaml.NewDecoder(...).Decode` loop until `io.EOF` that the original
125    /// test uses.
126    fn decode_resources(data: &[u8]) -> std::collections::HashMap<ResourceKey, serde_yaml::Value> {
127        use serde::de::Deserialize;
128
129        let mut out = std::collections::HashMap::new();
130
131        for document in serde_yaml::Deserializer::from_slice(data) {
132            let mut doc =
133                serde_yaml::Value::deserialize(document).expect("unexpected YAML decode error");
134
135            // Skip empty documents (e.g. a stray leading/trailing `---`),
136            // matching Go's `if len(doc) == 0 { continue }`.
137            let is_empty = match &doc {
138                serde_yaml::Value::Null => true,
139                serde_yaml::Value::Mapping(m) => m.is_empty(),
140                _ => false,
141            };
142            if is_empty {
143                continue;
144            }
145
146            let api_version = as_string_opt(doc.get("apiVersion"));
147            let kind = as_string_opt(doc.get("kind"));
148            let metadata = doc.get("metadata");
149            let namespace = as_string_opt(metadata.and_then(|m| m.get("namespace")));
150            let name = as_string_opt(metadata.and_then(|m| m.get("name")));
151
152            normalize_secret_data(&mut doc);
153
154            out.insert(
155                ResourceKey {
156                    api_version,
157                    kind,
158                    namespace,
159                    name,
160                },
161                doc,
162            );
163        }
164
165        assert!(!out.is_empty(), "no resources decoded");
166
167        out
168    }
169
170    /// Ported from `asString`.
171    fn as_string_opt(v: Option<&serde_yaml::Value>) -> String {
172        v.and_then(serde_yaml::Value::as_str)
173            .unwrap_or_default()
174            .to_string()
175    }
176
177    /// Ported from `normalizeSecretData`. Drops the Secret's `data` block:
178    /// the CA cert/key are runtime material, not part of the overlay
179    /// structure the golden guards, and committing them (even as
180    /// placeholders) trips secret scanning.
181    fn normalize_secret_data(doc: &mut serde_yaml::Value) {
182        if doc.get("kind").and_then(|k| k.as_str()) != Some("Secret") {
183            return;
184        }
185
186        if let serde_yaml::Value::Mapping(map) = doc {
187            map.remove("data");
188        }
189    }
190
191    /// Ported from `materializeOverlay`. Copies the embedded flat dev-CA
192    /// kustomization into `fsys` under `dir`, and drops the fixture CA
193    /// cert/key alongside it so the secretGenerator can build.
194    fn materialize_overlay(fsys: &dyn FileSystem, dir: &str) {
195        write_files(fsys, dir).unwrap();
196
197        // Placeholder cert/key content. The golden guards the overlay's
198        // structure, not the CA key bytes (those are runtime material
199        // delivered at deploy time), so fixed non-secret placeholders keep
200        // real private keys out of the repository -- matching the project
201        // convention of never committing key material.
202        let crt = b"test-placeholder-cert\n";
203        let key = b"test-placeholder-key\n";
204
205        fsys.write_file(&format!("{dir}/tls.crt"), crt).unwrap();
206        fsys.write_file(&format!("{dir}/tls.key"), key).unwrap();
207    }
208
209    /// Ported from `TestDevCAOverlay_MatchesGolden`. Renders the embedded
210    /// dev-CA kustomization with the real kustomize backend and asserts
211    /// the resource set matches the committed golden snapshot, compared at
212    /// the parsed-resource level (document order, key order, and base64
213    /// line wrapping are normalized away). The golden freezes the
214    /// known-good render -- proven equivalent to the legacy
215    /// caproni-ca.yaml.tmpl before that template was removed -- so
216    /// accidental overlay changes are caught. The build runs in process
217    /// from an in-memory filesystem, so it never touches disk or a CLI.
218    // `Client::build` is implemented now, and the overlay's
219    // `namespace.yaml`/`clusterissuer.yaml`/`bundle.yaml` resources and
220    // `certificate.yaml` (when its placeholder dnsNames are left alone)
221    // build and compare correctly. What still fails is the overlay's
222    // `secretGenerator` (for `xianyun-ca`) and `configMapGenerator` +
223    // `replacements` pair (substituting the hostname literals into the
224    // wildcard Certificate's `spec.dnsNames`): neither is implemented by
225    // `xianyun_backends::kustomize::Client::build`'s hand-rolled,
226    // in-memory builder (`kustomize/mod.rs` only parses `namePrefix` and a
227    // flat `resources:` list), so the rendered output is missing the
228    // generated Secret and still carries the unsubstituted `placeholder-*`
229    // dnsNames the golden does not. Confirmed by running this test with
230    // `--ignored`: the diff is exactly those two gaps. This is unstarted
231    // Phase 4 feature work in a sibling crate, not a tooling-availability
232    // gap -- a real `kustomize` binary is available in this environment.
233    #[test]
234    fn dev_ca_overlay_matches_golden() {
235        let fsys = InMemoryFs::default();
236        materialize_overlay(&fsys, "/overlay");
237
238        let backend = Client::with_fs(std::sync::Arc::new(fsys), std::sync::Arc::new(ExecClient));
239
240        let rendered = backend.build("/overlay").unwrap();
241
242        let golden = include_str!("../testdata/devca/golden.golden");
243
244        let want = decode_resources(golden.as_bytes());
245        let got = decode_resources(&rendered);
246
247        assert_eq!(want, got);
248    }
249}