1use anyhow::{anyhow, Result};
13use serde_json::{json, Value};
14
15#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct ApiResource {
18 pub group: String,
20 pub version: String,
22 pub name: String,
24 pub kind: String,
26 pub namespaced: bool,
28 pub short_names: Vec<String>,
30 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 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#[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 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 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 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 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 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 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 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 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 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
352pub 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
379pub 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}