Skip to main content

xianyun_cluster/kubeconfig/
merge.rs

1//! Port of `internal/kubeconfig/merge.go`: merges a cluster's self-contained
2//! kubeconfig into a user's existing kubeconfig, renaming the context,
3//! cluster and user entries to a stable name, and round-tripping everything
4//! else (comments included) untouched.
5//!
6//! The Go implementation manipulates a `gopkg.in/yaml.v3` node tree
7//! directly to preserve comments and formatting outside the entries it
8//! owns; `serde_yaml::Value` has no comment-preserving CST equivalent. This
9//! port instead operates on the destination as **text lines**: `src` (a
10//! driver-generated kubeconfig, never hand-edited, so nothing worth
11//! preserving) is fully parsed and re-rendered through `serde_yaml`, but
12//! `dst` (the user's own kubeconfig, which may carry comments and keys
13//! Caproni does not model) is only ever spliced at the exact line ranges
14//! of the `clusters`/`contexts`/`users` list entries and the
15//! `current-context` scalar this module owns — every other byte of `dst`
16//! passes through untouched, including comments. This assumes each of
17//! those keys appears in the plain block form kubectl and every cluster
18//! driver in this codebase emits (`key:` on its own line, followed by
19//! `- `-prefixed block-sequence items); a flow-style `clusters: [...]` in a
20//! hand-edited kubeconfig would not be recognized. See `PORT_NOTES.md`.
21
22use thiserror::Error;
23
24/// Port of the kubeconfig top-level and entry keys `merge.go` reads/writes.
25pub const KEY_CLUSTERS: &str = "clusters";
26pub const KEY_CONTEXTS: &str = "contexts";
27pub const KEY_USERS: &str = "users";
28pub const KEY_CURRENT_CTX: &str = "current-context";
29pub const KEY_NAME: &str = "name";
30pub const KEY_CONTEXT: &str = "context";
31pub const KEY_CONTEXT_USER: &str = "user";
32pub const KEY_CONTEXT_CLUSTER: &str = "cluster";
33
34/// Port of `kubeconfig.ErrNoContext`, `ErrAmbiguousContext`, `ErrNotMapping`.
35#[derive(Debug, Error, PartialEq, Eq)]
36pub enum MergeError {
37    #[error("kubeconfig contains no context: {0}")]
38    NoContext(String),
39    #[error("kubeconfig has multiple contexts and no current-context")]
40    AmbiguousContext,
41    #[error("kubeconfig is not a YAML mapping")]
42    NotMapping,
43}
44
45/// Port of `kubeconfig.installEntries`: holds the three renamed list
46/// entries `install` merges into the destination, each already rendered as
47/// the lines of a single `- `-prefixed block-sequence item ready to splice
48/// into a `clusters`/`contexts`/`users` section.
49pub struct InstallEntries {
50    pub cluster: Vec<String>,
51    pub context: Vec<String>,
52    pub user: Vec<String>,
53}
54
55/// Port of `kubeconfig.Install`. `src` is a self-contained kubeconfig as
56/// returned by a driver's `get_kubeconfig`; `dst` may be empty. The context
57/// and the cluster/user it references are all renamed to `name` before
58/// merging, and existing entries of that name are replaced in place, so
59/// calling `install` repeatedly is idempotent and picks up a new server
60/// address after the cluster is recreated.
61///
62/// `current-context` is only written when `set_current` is true; otherwise
63/// whatever the user had selected is preserved. Everything in `dst` that
64/// `install` does not own is round-tripped untouched, including comments
65/// and keys Caproni does not model.
66pub fn install(
67    dst: &[u8],
68    src: &[u8],
69    name: &str,
70    set_current: bool,
71) -> Result<Vec<u8>, MergeError> {
72    let entries = normalize_entries(src, name)?;
73
74    validate_mapping(dst)?;
75
76    let mut lines = to_lines(dst);
77
78    upsert_entry(&mut lines, KEY_CLUSTERS, name, entries.cluster);
79    upsert_entry(&mut lines, KEY_CONTEXTS, name, entries.context);
80    upsert_entry(&mut lines, KEY_USERS, name, entries.user);
81
82    if set_current {
83        set_scalar(&mut lines, KEY_CURRENT_CTX, name);
84    }
85
86    Ok(from_lines(&lines))
87}
88
89/// Port of `kubeconfig.Normalize`: returns `src` as a single-context
90/// kubeconfig whose context, cluster and user are all called `name`,
91/// selected as `current-context`.
92pub fn normalize(src: &[u8], name: &str) -> Result<Vec<u8>, MergeError> {
93    install(b"apiVersion: v1\nkind: Config\n", src, name, true)
94}
95
96/// Port of `kubeconfig.Uninstall`: removes the context, cluster and user
97/// called `name` from `dst`, and clears `current-context` when it pointed
98/// at `name`. Removing an entry that is not there, or from an empty
99/// kubeconfig, is a no-op — this returns `dst` untouched (byte-for-byte)
100/// whenever nothing changed, rather than re-rendering (which would
101/// reindent every list in a kubectl-formatted file and count as a change).
102pub fn uninstall(dst: &[u8], name: &str) -> Result<Vec<u8>, MergeError> {
103    if dst.is_empty() {
104        return Ok(Vec::new());
105    }
106
107    validate_mapping(dst)?;
108
109    let mut lines = to_lines(dst);
110    let mut changed = false;
111
112    for key in [KEY_CLUSTERS, KEY_CONTEXTS, KEY_USERS] {
113        changed |= remove_entry(&mut lines, key, name);
114    }
115
116    if get_scalar(&lines, KEY_CURRENT_CTX).as_deref() == Some(name) {
117        changed |= remove_scalar(&mut lines, KEY_CURRENT_CTX);
118    }
119
120    if !changed {
121        return Ok(dst.to_vec());
122    }
123
124    Ok(from_lines(&lines))
125}
126
127/// Port of `kubeconfig.normalize` (lowercase): extracts the active context
128/// from a cluster kubeconfig and renames it, together with the cluster and
129/// user it references, to `name`.
130fn normalize_entries(src: &[u8], name: &str) -> Result<InstallEntries, MergeError> {
131    let root: serde_yaml::Value =
132        serde_yaml::from_slice(src).map_err(|e| MergeError::NoContext(e.to_string()))?;
133
134    let mapping = as_mapping_or_empty(&root)?;
135
136    let contexts: Vec<serde_yaml::Value> = mapping
137        .get(KEY_CONTEXTS)
138        .and_then(serde_yaml::Value::as_sequence)
139        .cloned()
140        .unwrap_or_default();
141
142    let current = mapping
143        .get(KEY_CURRENT_CTX)
144        .and_then(serde_yaml::Value::as_str)
145        .unwrap_or("")
146        .to_string();
147
148    let context_value = active_context(&contexts, &current)?;
149    let mut context_entry = context_value
150        .as_mapping()
151        .cloned()
152        .ok_or_else(|| MergeError::NoContext(format!("context {name:?} is not a mapping")))?;
153
154    let inner = context_entry
155        .get(KEY_CONTEXT)
156        .and_then(serde_yaml::Value::as_mapping)
157        .cloned()
158        .ok_or_else(|| MergeError::NoContext(format!("context {name:?} has no context block")))?;
159
160    let cluster_ref = inner
161        .get(KEY_CONTEXT_CLUSTER)
162        .and_then(serde_yaml::Value::as_str)
163        .unwrap_or("")
164        .to_string();
165    let user_ref = inner
166        .get(KEY_CONTEXT_USER)
167        .and_then(serde_yaml::Value::as_str)
168        .unwrap_or("")
169        .to_string();
170
171    let clusters: Vec<serde_yaml::Value> = mapping
172        .get(KEY_CLUSTERS)
173        .and_then(serde_yaml::Value::as_sequence)
174        .cloned()
175        .unwrap_or_default();
176    let users: Vec<serde_yaml::Value> = mapping
177        .get(KEY_USERS)
178        .and_then(serde_yaml::Value::as_sequence)
179        .cloned()
180        .unwrap_or_default();
181
182    let mut cluster_entry = find_entry(&clusters, &cluster_ref)
183        .and_then(|v| v.as_mapping().cloned())
184        .ok_or_else(|| MergeError::NoContext(format!("no cluster named {cluster_ref:?}")))?;
185
186    // A user entry is legitimately absent for clusters authenticating some
187    // other way, so synthesise an empty one rather than failing.
188    let mut user_entry = find_entry(&users, &user_ref)
189        .and_then(|v| v.as_mapping().cloned())
190        .unwrap_or_default();
191
192    // Rename last, so the lookups above still resolve the original references.
193    let mut inner_renamed = inner;
194    inner_renamed.insert(
195        serde_yaml::Value::String(KEY_CONTEXT_CLUSTER.to_string()),
196        serde_yaml::Value::String(name.to_string()),
197    );
198    inner_renamed.insert(
199        serde_yaml::Value::String(KEY_CONTEXT_USER.to_string()),
200        serde_yaml::Value::String(name.to_string()),
201    );
202    context_entry.insert(
203        serde_yaml::Value::String(KEY_CONTEXT.to_string()),
204        serde_yaml::Value::Mapping(inner_renamed),
205    );
206    context_entry.insert(
207        serde_yaml::Value::String(KEY_NAME.to_string()),
208        serde_yaml::Value::String(name.to_string()),
209    );
210    cluster_entry.insert(
211        serde_yaml::Value::String(KEY_NAME.to_string()),
212        serde_yaml::Value::String(name.to_string()),
213    );
214    user_entry.insert(
215        serde_yaml::Value::String(KEY_NAME.to_string()),
216        serde_yaml::Value::String(name.to_string()),
217    );
218
219    Ok(InstallEntries {
220        cluster: render_entry_lines(&serde_yaml::Value::Mapping(cluster_entry)),
221        context: render_entry_lines(&serde_yaml::Value::Mapping(context_entry)),
222        user: render_entry_lines(&serde_yaml::Value::Mapping(user_entry)),
223    })
224}
225
226/// Port of `kubeconfig.activeContext`: picks the context a cluster
227/// kubeconfig is about: the one named by `current-context`, or the only one
228/// present.
229fn active_context(
230    contexts: &[serde_yaml::Value],
231    current: &str,
232) -> Result<serde_yaml::Value, MergeError> {
233    if contexts.is_empty() {
234        return Err(MergeError::NoContext(String::new()));
235    }
236
237    if !current.is_empty() {
238        return find_entry(contexts, current).ok_or_else(|| {
239            MergeError::NoContext(format!("current-context {current:?} is not defined"))
240        });
241    }
242
243    if contexts.len() > 1 {
244        return Err(MergeError::AmbiguousContext);
245    }
246
247    Ok(contexts[0].clone())
248}
249
250/// Port of `kubeconfig.findEntry`: returns the entry of a kubeconfig list
251/// whose `name` field equals `name`.
252fn find_entry(seq: &[serde_yaml::Value], name: &str) -> Option<serde_yaml::Value> {
253    if name.is_empty() {
254        return None;
255    }
256
257    seq.iter()
258        .find(|entry| {
259            entry
260                .as_mapping()
261                .and_then(|m| m.get(KEY_NAME))
262                .and_then(serde_yaml::Value::as_str)
263                == Some(name)
264        })
265        .cloned()
266}
267
268/// Returns `value`'s mapping, treating a null document (blank input) as an
269/// empty mapping and anything else as [`MergeError::NotMapping`].
270fn as_mapping_or_empty(value: &serde_yaml::Value) -> Result<serde_yaml::Mapping, MergeError> {
271    match value {
272        serde_yaml::Value::Mapping(m) => Ok(m.clone()),
273        serde_yaml::Value::Null => Ok(serde_yaml::Mapping::new()),
274        _ => Err(MergeError::NotMapping),
275    }
276}
277
278/// Port of `parseDestination`'s validation half: rejects a non-blank,
279/// non-mapping document. Blank input is valid (an implicit empty mapping).
280fn validate_mapping(data: &[u8]) -> Result<(), MergeError> {
281    if data.iter().all(u8::is_ascii_whitespace) {
282        return Ok(());
283    }
284
285    let value: serde_yaml::Value =
286        serde_yaml::from_slice(data).map_err(|_| MergeError::NotMapping)?;
287    as_mapping_or_empty(&value).map(|_| ())
288}
289
290/// Renders a single kubeconfig list entry (a `serde_yaml::Value::Mapping`)
291/// as the lines of a `- `-prefixed block-sequence item, matching kubectl's
292/// convention of aligning continuation lines two spaces past the dash.
293fn render_entry_lines(entry: &serde_yaml::Value) -> Vec<String> {
294    let text = serde_yaml::to_string(entry).expect("serializing a kubeconfig entry cannot fail");
295    let mut out = Vec::new();
296
297    for (i, line) in text.lines().enumerate() {
298        if line.is_empty() {
299            continue;
300        }
301
302        if i == 0 {
303            out.push(format!("- {line}"));
304        } else {
305            out.push(format!("  {line}"));
306        }
307    }
308
309    out
310}
311
312/// Splits `data` into lines with no trailing newline element, tolerating
313/// input that does not end in `\n`.
314fn to_lines(data: &[u8]) -> Vec<String> {
315    let text = String::from_utf8_lossy(data);
316    if text.is_empty() {
317        return Vec::new();
318    }
319
320    text.strip_suffix('\n')
321        .unwrap_or(&text)
322        .split('\n')
323        .map(std::string::ToString::to_string)
324        .collect()
325}
326
327/// Joins lines back into a trailing-newline-terminated document, matching
328/// the convention every fixture and rendered entry in this module follows.
329fn from_lines(lines: &[String]) -> Vec<u8> {
330    if lines.is_empty() {
331        return Vec::new();
332    }
333
334    let mut out = lines.join("\n");
335    out.push('\n');
336    out.into_bytes()
337}
338
339/// Returns the index of the top-level (column 0) `key:` line, in its bare
340/// block-sequence-introducing form (nothing after the colon).
341fn find_top_level_key(lines: &[String], key: &str) -> Option<usize> {
342    let target = format!("{key}:");
343    lines.iter().position(|line| line == &target)
344}
345
346/// Returns the exclusive end of the section starting at `body_start` (the
347/// line right after a `key:` header): the index of the next column-0,
348/// non-blank line, or `lines.len()` if the section runs to the end of the
349/// document.
350fn section_end(lines: &[String], body_start: usize) -> usize {
351    for (offset, line) in lines[body_start..].iter().enumerate() {
352        if line.is_empty() {
353            continue;
354        }
355
356        if !line.starts_with(' ') && !line.starts_with('-') {
357            return body_start + offset;
358        }
359    }
360
361    lines.len()
362}
363
364/// Returns the start indices of every `- `-prefixed block-sequence item in
365/// `lines[start..end]`.
366fn entry_starts(lines: &[String], start: usize, end: usize) -> Vec<usize> {
367    (start..end)
368        .filter(|&i| lines[i].starts_with("- "))
369        .collect()
370}
371
372/// Returns the exclusive end of the entry starting at `entry_start`: the
373/// next entry start in `starts` after it, or `section_end`.
374fn entry_end(starts: &[usize], entry_start: usize, section_end: usize) -> usize {
375    starts
376        .iter()
377        .copied()
378        .find(|&s| s > entry_start)
379        .unwrap_or(section_end)
380}
381
382/// Port of `kubeconfig.scalarValue` applied to a rendered entry block:
383/// extracts its `name:` field, whether that is the entry's first
384/// (`- name: ...`) line or a nested (`  name: ...`) one.
385fn entry_name(lines: &[String], entry_start: usize, entry_stop: usize) -> Option<String> {
386    for line in &lines[entry_start..entry_stop] {
387        if let Some(rest) = line.strip_prefix("- name: ") {
388            return Some(rest.trim().to_string());
389        }
390
391        if let Some(rest) = line.strip_prefix("  name: ") {
392            return Some(rest.trim().to_string());
393        }
394    }
395
396    None
397}
398
399/// Port of `kubeconfig.upsertEntry`: replaces the entry called `name` in
400/// the list under `key`, or appends it — creating the section when it does
401/// not exist yet, mirroring `sequenceFor`.
402fn upsert_entry(lines: &mut Vec<String>, key: &str, name: &str, entry: Vec<String>) {
403    let Some(key_idx) = find_top_level_key(lines, key) else {
404        lines.push(format!("{key}:"));
405        lines.extend(entry);
406        return;
407    };
408
409    let body_start = key_idx + 1;
410    let end = section_end(lines, body_start);
411    let starts = entry_starts(lines, body_start, end);
412
413    for &start in &starts {
414        let stop = entry_end(&starts, start, end);
415        if entry_name(lines, start, stop).as_deref() == Some(name) {
416            lines.splice(start..stop, entry);
417            return;
418        }
419    }
420
421    lines.splice(end..end, entry);
422}
423
424/// Port of `kubeconfig.removeEntry`: drops the entry called `name` from the
425/// list under `key`, reporting whether it was there.
426fn remove_entry(lines: &mut Vec<String>, key: &str, name: &str) -> bool {
427    let Some(key_idx) = find_top_level_key(lines, key) else {
428        return false;
429    };
430
431    let body_start = key_idx + 1;
432    let end = section_end(lines, body_start);
433    let starts = entry_starts(lines, body_start, end);
434
435    for &start in &starts {
436        let stop = entry_end(&starts, start, end);
437        if entry_name(lines, start, stop).as_deref() == Some(name) {
438            lines.drain(start..stop);
439            return true;
440        }
441    }
442
443    false
444}
445
446/// Returns the value of a top-level (column 0) `key: value` scalar line.
447fn get_scalar(lines: &[String], key: &str) -> Option<String> {
448    let prefix = format!("{key}: ");
449    lines.iter().find_map(|line| {
450        line.strip_prefix(&prefix)
451            .map(std::string::ToString::to_string)
452    })
453}
454
455/// Port of `kubeconfig.setMapValue` applied to a top-level scalar: replaces
456/// an existing `key: ...` line in place, or appends a new one.
457fn set_scalar(lines: &mut Vec<String>, key: &str, value: &str) {
458    let prefix = format!("{key}: ");
459    if let Some(idx) = lines.iter().position(|line| line.starts_with(&prefix)) {
460        lines[idx] = format!("{key}: {value}");
461        return;
462    }
463
464    lines.push(format!("{key}: {value}"));
465}
466
467/// Port of `kubeconfig.removeMapKey` applied to a top-level scalar: deletes
468/// the `key: ...` line, reporting whether it was there.
469fn remove_scalar(lines: &mut Vec<String>, key: &str) -> bool {
470    let prefix = format!("{key}: ");
471    if let Some(idx) = lines.iter().position(|line| line.starts_with(&prefix)) {
472        lines.remove(idx);
473        return true;
474    }
475
476    false
477}