agentd/identity.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2//! Instance identity from the Kubernetes downward API.
3//!
4//! agentd surfaces pod identity by reading **operator-injected environment
5//! variables** (set from `valueFrom.fieldRef`) — never by calling the kube API.
6//! There is no kube client, no in-cluster config, no service-account read; that
7//! coupling belongs in agentctl, not here (the minimalism moat). Env in,
8//! identity out.
9//!
10//! Every k8s field is optional and descriptive, never load-bearing: outside
11//! Kubernetes the vars are simply unset and the fields are `None`. Their absence
12//! is never a config error. `run_id` is always present (minted by config when
13//! unset), because every log line and durable record correlates on it.
14
15/// The instance's correlation identity. `run_id` is always present; the k8s
16/// fields are populated from the downward-API env when injected, else `None`.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct Identity {
19 /// `AGENTD_RUN_ID`, or the run id minted at startup. Always present.
20 pub run_id: String,
21 /// `metadata.name` via `AGENTD_POD_NAME`.
22 pub instance: Option<String>,
23 /// `metadata.uid` via `AGENTD_POD_UID`.
24 pub uid: Option<String>,
25 /// `metadata.namespace` via `AGENTD_POD_NAMESPACE`.
26 pub namespace: Option<String>,
27 /// `spec.nodeName` via `AGENTD_NODE_NAME`.
28 pub node: Option<String>,
29}
30
31impl Identity {
32 /// Read identity from the environment (getenv only — no syscalls beyond
33 /// that, no validation side effects). `run_id` comes from the already
34 /// resolved config; the k8s fields each read their downward-API var and
35 /// resolve to `None` when absent.
36 pub fn from_env(run_id: &str) -> Identity {
37 // The neutral `AGENT_*` downward-API vars are read FIRST, with the
38 // branded `AGENTD_*` spelling as a fallback, so a manifest may inject
39 // either. `var()` coerces empty to unset, so an empty neutral var also
40 // falls through to the branded one rather than masking it.
41 Identity {
42 run_id: run_id.to_string(),
43 instance: var("AGENT_POD_NAME").or_else(|| var("AGENTD_POD_NAME")),
44 uid: var("AGENT_POD_UID").or_else(|| var("AGENTD_POD_UID")),
45 namespace: var("AGENT_POD_NAMESPACE").or_else(|| var("AGENTD_POD_NAMESPACE")),
46 node: var("AGENT_NODE_NAME").or_else(|| var("AGENTD_NODE_NAME")),
47 }
48 }
49}
50
51/// A non-empty environment variable, or `None`. An empty value is treated as
52/// unset (an operator clearing a fieldRef should not surface as `""`).
53fn var(key: &str) -> Option<String> {
54 std::env::var(key).ok().filter(|v| !v.is_empty())
55}
56
57#[cfg(test)]
58mod tests {
59 use super::*;
60
61 // The downward-API vars are process-global, so the env-present and
62 // env-absent cases share one test to avoid cross-test races.
63 #[test]
64 fn from_env_populates_when_set_and_none_when_absent() {
65 // The downward-API vars (branded + neutral) are process-global, so the
66 // present/absent/branded/neutral cases share one test to avoid races.
67 let all = [
68 "AGENT_POD_NAME",
69 "AGENT_POD_UID",
70 "AGENT_POD_NAMESPACE",
71 "AGENT_NODE_NAME",
72 "AGENTD_POD_NAME",
73 "AGENTD_POD_UID",
74 "AGENTD_POD_NAMESPACE",
75 "AGENTD_NODE_NAME",
76 ];
77 // Absent: with every spelling unset, each k8s field is None; run_id holds.
78 for k in all {
79 unsafe { std::env::remove_var(k) };
80 }
81 let id = Identity::from_env("run-abc");
82 assert_eq!(id.run_id, "run-abc");
83 assert_eq!(id.instance, None);
84 assert_eq!(id.uid, None);
85 assert_eq!(id.namespace, None);
86 assert_eq!(id.node, None);
87
88 // Branded present: each `AGENTD_*` var maps to its field via the
89 // branded fallback (no neutral var set).
90 unsafe {
91 std::env::set_var("AGENTD_POD_NAME", "agent-pod-abc");
92 std::env::set_var("AGENTD_POD_UID", "f3c1-uid");
93 std::env::set_var("AGENTD_POD_NAMESPACE", "agents");
94 std::env::set_var("AGENTD_NODE_NAME", "node-3");
95 }
96 let id = Identity::from_env("run-xyz");
97 assert_eq!(id.run_id, "run-xyz");
98 assert_eq!(id.instance.as_deref(), Some("agent-pod-abc"));
99 assert_eq!(id.uid.as_deref(), Some("f3c1-uid"));
100 assert_eq!(id.namespace.as_deref(), Some("agents"));
101 assert_eq!(id.node.as_deref(), Some("node-3"));
102
103 // Neutral present, branded cleared: the `AGENT_*` spelling is accepted
104 // on its own.
105 for k in [
106 "AGENTD_POD_NAME",
107 "AGENTD_POD_UID",
108 "AGENTD_POD_NAMESPACE",
109 "AGENTD_NODE_NAME",
110 ] {
111 unsafe { std::env::remove_var(k) };
112 }
113 unsafe {
114 std::env::set_var("AGENT_POD_NAME", "neutral-pod");
115 std::env::set_var("AGENT_POD_UID", "neutral-uid");
116 std::env::set_var("AGENT_POD_NAMESPACE", "neutral-ns");
117 std::env::set_var("AGENT_NODE_NAME", "neutral-node");
118 }
119 let id = Identity::from_env("r");
120 assert_eq!(id.instance.as_deref(), Some("neutral-pod"));
121 assert_eq!(id.uid.as_deref(), Some("neutral-uid"));
122 assert_eq!(id.namespace.as_deref(), Some("neutral-ns"));
123 assert_eq!(id.node.as_deref(), Some("neutral-node"));
124
125 // Both present ⇒ neutral-first wins (the read order is AGENT_* then AGENTD_*).
126 unsafe { std::env::set_var("AGENTD_POD_NAME", "branded-pod") };
127 assert_eq!(
128 Identity::from_env("r").instance.as_deref(),
129 Some("neutral-pod")
130 );
131
132 // An empty neutral var reads as unset and falls through to the branded one.
133 unsafe { std::env::set_var("AGENT_POD_NAME", "") };
134 assert_eq!(
135 Identity::from_env("r").instance.as_deref(),
136 Some("branded-pod")
137 );
138 // …and with BOTH empty/unset, the field is None.
139 unsafe { std::env::remove_var("AGENTD_POD_NAME") };
140 assert_eq!(Identity::from_env("r").instance, None);
141
142 for k in all {
143 unsafe { std::env::remove_var(k) };
144 }
145 }
146
147 #[test]
148 fn run_id_is_always_present() {
149 let id = Identity::from_env("only-run-id");
150 assert_eq!(id.run_id, "only-run-id");
151 }
152}