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}