Skip to main content

netget/server/kubernetes/
discovery.rs

1//! Kubernetes API discovery surface.
2//!
3//! `kubectl` issues its discovery requests *before* anything else and gives up with an
4//! unhelpful error if they are malformed, so these documents are built deterministically by
5//! NetGet rather than asked of the model on every request. They are protocol envelope — the
6//! shape of the API — not cluster content. What resources exist inside the cluster (pods,
7//! nodes, CRs) is still entirely the model's to invent, via the `k8s_*` actions.
8//!
9//! The advertised resource set itself *is* model-controlled: the `resources` startup parameter
10//! replaces the default table wholesale, which is how a caller advertises CRDs.
11
12use anyhow::{anyhow, Result};
13use serde_json::{json, Value};
14
15/// One entry in the API discovery table.
16#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct ApiResource {
18    /// API group; empty string for the core (legacy) group served under `/api`.
19    pub group: String,
20    /// Group version, e.g. `v1`.
21    pub version: String,
22    /// Plural, lowercase resource name as it appears in the URL path, e.g. `pods`.
23    pub name: String,
24    /// Object kind, e.g. `Pod`.
25    pub kind: String,
26    /// Whether the resource lives under `/namespaces/{ns}/`.
27    pub namespaced: bool,
28    /// Short names accepted by kubectl, e.g. `po`.
29    pub short_names: Vec<String>,
30    /// Verbs advertised for this resource.
31    pub verbs: Vec<String>,
32}
33
34impl ApiResource {
35    fn new(
36        group: &str,
37        version: &str,
38        name: &str,
39        kind: &str,
40        namespaced: bool,
41        short_names: &[&str],
42    ) -> Self {
43        Self {
44            group: group.to_string(),
45            version: version.to_string(),
46            name: name.to_string(),
47            kind: kind.to_string(),
48            namespaced,
49            short_names: short_names.iter().map(|s| s.to_string()).collect(),
50            verbs: default_verbs(),
51        }
52    }
53
54    /// `v1` for the core group, `apps/v1` otherwise — the value that goes in `apiVersion`.
55    pub fn group_version(&self) -> String {
56        if self.group.is_empty() {
57            self.version.clone()
58        } else {
59            format!("{}/{}", self.group, self.version)
60        }
61    }
62
63    fn to_json(&self) -> Value {
64        let mut entry = json!({
65            "name": self.name,
66            "singularName": "",
67            "namespaced": self.namespaced,
68            "kind": self.kind,
69            "verbs": self.verbs,
70        });
71        if !self.short_names.is_empty() {
72            entry["shortNames"] = json!(self.short_names);
73        }
74        entry
75    }
76}
77
78fn default_verbs() -> Vec<String> {
79    [
80        "create",
81        "delete",
82        "deletecollection",
83        "get",
84        "list",
85        "patch",
86        "update",
87        "watch",
88    ]
89    .iter()
90    .map(|s| s.to_string())
91    .collect()
92}
93
94/// The full advertised API surface of one server instance.
95#[derive(Clone, Debug)]
96pub struct ApiSurface {
97    resources: Vec<ApiResource>,
98}
99
100impl Default for ApiSurface {
101    fn default() -> Self {
102        Self::builtin()
103    }
104}
105
106impl ApiSurface {
107    /// The default surface: core/v1 plus the handful of groups kubectl users reach for first.
108    pub fn builtin() -> Self {
109        let resources = vec![
110            ApiResource::new("", "v1", "pods", "Pod", true, &["po"]),
111            ApiResource::new("", "v1", "services", "Service", true, &["svc"]),
112            ApiResource::new("", "v1", "nodes", "Node", false, &["no"]),
113            ApiResource::new("", "v1", "namespaces", "Namespace", false, &["ns"]),
114            ApiResource::new("", "v1", "configmaps", "ConfigMap", true, &["cm"]),
115            ApiResource::new("", "v1", "secrets", "Secret", true, &[]),
116            ApiResource::new("", "v1", "events", "Event", true, &["ev"]),
117            ApiResource::new("", "v1", "endpoints", "Endpoints", true, &["ep"]),
118            ApiResource::new("", "v1", "serviceaccounts", "ServiceAccount", true, &["sa"]),
119            ApiResource::new(
120                "",
121                "v1",
122                "persistentvolumeclaims",
123                "PersistentVolumeClaim",
124                true,
125                &["pvc"],
126            ),
127            ApiResource::new(
128                "",
129                "v1",
130                "persistentvolumes",
131                "PersistentVolume",
132                false,
133                &["pv"],
134            ),
135            ApiResource::new(
136                "",
137                "v1",
138                "replicationcontrollers",
139                "ReplicationController",
140                true,
141                &["rc"],
142            ),
143            ApiResource::new("apps", "v1", "deployments", "Deployment", true, &["deploy"]),
144            ApiResource::new("apps", "v1", "replicasets", "ReplicaSet", true, &["rs"]),
145            ApiResource::new("apps", "v1", "statefulsets", "StatefulSet", true, &["sts"]),
146            ApiResource::new("apps", "v1", "daemonsets", "DaemonSet", true, &["ds"]),
147            ApiResource::new("batch", "v1", "jobs", "Job", true, &[]),
148            ApiResource::new("batch", "v1", "cronjobs", "CronJob", true, &["cj"]),
149            ApiResource::new(
150                "apiextensions.k8s.io",
151                "v1",
152                "customresourcedefinitions",
153                "CustomResourceDefinition",
154                false,
155                &["crd", "crds"],
156            ),
157        ];
158        Self { resources }
159    }
160
161    /// Build a surface from the `resources` startup parameter.
162    ///
163    /// Every entry must carry `name` and `kind`; everything else has a sane default. A
164    /// malformed entry is an error rather than a silent skip, so a caller that mistypes a CRD
165    /// gets `ServerStatus::Error` instead of a cluster that is quietly missing it.
166    pub fn from_startup_value(values: &[Value]) -> Result<Self> {
167        if values.is_empty() {
168            return Err(anyhow!(
169                "startup parameter 'resources' was an empty array; omit it to use the built-in \
170                 Kubernetes API surface"
171            ));
172        }
173        let mut resources = Vec::with_capacity(values.len());
174        for (idx, value) in values.iter().enumerate() {
175            let obj = value
176                .as_object()
177                .ok_or_else(|| anyhow!("startup parameter 'resources'[{idx}] must be an object"))?;
178            let name = obj
179                .get("name")
180                .and_then(Value::as_str)
181                .ok_or_else(|| anyhow!("'resources'[{idx}] is missing the required string 'name' (the plural URL segment, e.g. \"pods\")"))?;
182            let kind = obj.get("kind").and_then(Value::as_str).ok_or_else(|| {
183                anyhow!("'resources'[{idx}] is missing the required string 'kind' (e.g. \"Pod\")")
184            })?;
185            let group = obj.get("group").and_then(Value::as_str).unwrap_or("");
186            let version = obj.get("version").and_then(Value::as_str).unwrap_or("v1");
187            let namespaced = obj
188                .get("namespaced")
189                .and_then(Value::as_bool)
190                .unwrap_or(true);
191            let short_names = obj
192                .get("shortNames")
193                .or_else(|| obj.get("short_names"))
194                .and_then(Value::as_array)
195                .map(|a| {
196                    a.iter()
197                        .filter_map(Value::as_str)
198                        .map(|s| s.to_string())
199                        .collect()
200                })
201                .unwrap_or_default();
202            let verbs = obj
203                .get("verbs")
204                .and_then(Value::as_array)
205                .map(|a| {
206                    a.iter()
207                        .filter_map(Value::as_str)
208                        .map(|s| s.to_string())
209                        .collect::<Vec<_>>()
210                })
211                .filter(|v: &Vec<String>| !v.is_empty())
212                .unwrap_or_else(default_verbs);
213
214            resources.push(ApiResource {
215                group: group.to_string(),
216                version: version.to_string(),
217                name: name.to_string(),
218                kind: kind.to_string(),
219                namespaced,
220                short_names,
221                verbs,
222            });
223        }
224        Ok(Self { resources })
225    }
226
227    /// Look a resource up by its route coordinates.
228    pub fn find(&self, group: &str, version: &str, name: &str) -> Option<&ApiResource> {
229        self.resources
230            .iter()
231            .find(|r| r.group == group && r.version == version && r.name == name)
232    }
233
234    /// Core-group versions, in first-seen order. Serves `GET /api`.
235    fn core_versions(&self) -> Vec<String> {
236        let mut out: Vec<String> = Vec::new();
237        for r in self.resources.iter().filter(|r| r.group.is_empty()) {
238            if !out.contains(&r.version) {
239                out.push(r.version.clone());
240            }
241        }
242        if out.is_empty() {
243            out.push("v1".to_string());
244        }
245        out
246    }
247
248    /// Non-core (group, versions) pairs, in first-seen order. Serves `GET /apis`.
249    fn named_groups(&self) -> Vec<(String, Vec<String>)> {
250        let mut out: Vec<(String, Vec<String>)> = Vec::new();
251        for r in self.resources.iter().filter(|r| !r.group.is_empty()) {
252            match out.iter_mut().find(|(g, _)| *g == r.group) {
253                Some((_, versions)) => {
254                    if !versions.contains(&r.version) {
255                        versions.push(r.version.clone());
256                    }
257                }
258                None => out.push((r.group.clone(), vec![r.version.clone()])),
259            }
260        }
261        out
262    }
263
264    /// `GET /api` → `APIVersions`.
265    pub fn api_versions(&self, server_address: &str) -> Value {
266        json!({
267            "kind": "APIVersions",
268            "versions": self.core_versions(),
269            "serverAddressByClientCIDRs": [{
270                "clientCIDR": "0.0.0.0/0",
271                "serverAddress": server_address,
272            }],
273        })
274    }
275
276    /// `GET /apis` → `APIGroupList`.
277    pub fn api_group_list(&self) -> Value {
278        let groups: Vec<Value> = self
279            .named_groups()
280            .into_iter()
281            .map(|(group, versions)| group_json(&group, &versions))
282            .collect();
283        json!({
284            "kind": "APIGroupList",
285            "apiVersion": "v1",
286            "groups": groups,
287        })
288    }
289
290    /// `GET /apis/{group}` → `APIGroup`, or `None` when the group is not advertised.
291    pub fn api_group(&self, group: &str) -> Option<Value> {
292        self.named_groups()
293            .into_iter()
294            .find(|(g, _)| g == group)
295            .map(|(g, versions)| {
296                let mut value = group_json(&g, &versions);
297                value["kind"] = json!("APIGroup");
298                value["apiVersion"] = json!("v1");
299                value
300            })
301    }
302
303    /// `GET /api/v1` or `GET /apis/{group}/{version}` → `APIResourceList`.
304    ///
305    /// Returns `None` when nothing is advertised at that group version, so the caller can
306    /// answer with a proper 404 `Status` instead of an empty list.
307    pub fn api_resource_list(&self, group: &str, version: &str) -> Option<Value> {
308        let resources: Vec<Value> = self
309            .resources
310            .iter()
311            .filter(|r| r.group == group && r.version == version)
312            .map(ApiResource::to_json)
313            .collect();
314        if resources.is_empty() {
315            return None;
316        }
317        let group_version = if group.is_empty() {
318            version.to_string()
319        } else {
320            format!("{group}/{version}")
321        };
322        Some(json!({
323            "kind": "APIResourceList",
324            "apiVersion": "v1",
325            "groupVersion": group_version,
326            "resources": resources,
327        }))
328    }
329}
330
331fn group_json(group: &str, versions: &[String]) -> Value {
332    let version_entries: Vec<Value> = versions
333        .iter()
334        .map(|v| {
335            json!({
336                "groupVersion": format!("{group}/{v}"),
337                "version": v,
338            })
339        })
340        .collect();
341    let preferred = version_entries
342        .first()
343        .cloned()
344        .unwrap_or_else(|| json!({"groupVersion": format!("{group}/v1"), "version": "v1"}));
345    json!({
346        "name": group,
347        "versions": version_entries,
348        "preferredVersion": preferred,
349    })
350}
351
352/// `GET /version` → the `version.Info` struct `kubectl version` prints.
353///
354/// `version` is the `kubernetes_version` startup parameter, e.g. `v1.29.4`; major/minor are
355/// derived from it so the three fields can never disagree.
356pub fn version_info(version: &str) -> Value {
357    let trimmed = version.trim_start_matches('v');
358    let mut parts = trimmed.split('.');
359    let major = parts.next().unwrap_or("1");
360    let minor = parts.next().unwrap_or("29");
361    let git_version = if version.starts_with('v') {
362        version.to_string()
363    } else {
364        format!("v{version}")
365    };
366    json!({
367        "major": major,
368        "minor": minor,
369        "gitVersion": git_version,
370        "gitCommit": "0000000000000000000000000000000000000000",
371        "gitTreeState": "clean",
372        "buildDate": "1970-01-01T00:00:00Z",
373        "goVersion": "go1.21.0",
374        "compiler": "gc",
375        "platform": "linux/amd64",
376    })
377}
378
379/// Build a Kubernetes `Status` object — the error envelope every real client understands.
380pub fn status_object(code: u16, reason: &str, message: &str, details: Option<Value>) -> Value {
381    let mut value = json!({
382        "kind": "Status",
383        "apiVersion": "v1",
384        "metadata": {},
385        "status": if (200..300).contains(&code) { "Success" } else { "Failure" },
386        "message": message,
387        "reason": reason,
388        "code": code,
389    });
390    if let Some(details) = details {
391        value["details"] = details;
392    }
393    value
394}