1use std::sync::Arc;
10use axum::{
11 Router,
12 routing::{get, post, put, delete},
13 extract::{Path, Query, State, Json},
14 http::StatusCode,
15 response::IntoResponse,
16};
17use axum::response::sse::{Event, KeepAlive, Sse};
18use futures::stream::{self, Stream};
19use serde::{Deserialize, Serialize};
20use tracing::info;
21
22use super::watch::WatchEvent;
23use super::{ResourceKind, ResourceStore, StoreError};
24
25#[derive(Debug, Clone)]
27pub struct ApiServerConfig {
28 pub listen_addr: String,
30 pub tls_enabled: bool,
32 pub tls_cert: Option<String>,
34 pub tls_key: Option<String>,
36 pub auth_enabled: bool,
38 pub admission_enabled: bool,
40}
41
42impl Default for ApiServerConfig {
43 fn default() -> Self {
44 Self {
45 listen_addr: "0.0.0.0:6443".to_string(),
46 tls_enabled: false,
47 tls_cert: None,
48 tls_key: None,
49 auth_enabled: false,
50 admission_enabled: true,
51 }
52 }
53}
54
55#[derive(Clone)]
57pub struct ApiServerState {
58 pub store: Arc<ResourceStore>,
60}
61
62pub struct ApiServer {
64 config: ApiServerConfig,
65 state: ApiServerState,
66}
67
68impl ApiServer {
69 pub fn new(config: ApiServerConfig, store: Arc<ResourceStore>) -> Self {
71 Self {
72 config,
73 state: ApiServerState { store },
74 }
75 }
76
77 pub fn router(&self) -> Router {
79 Router::new()
80 .route("/healthz", get(health))
82 .route("/readyz", get(ready))
83 .route("/livez", get(live))
84
85 .route("/api", get(api_versions))
87 .route("/apis", get(api_groups))
88
89 .route("/api/v1/namespaces/:namespace/workloads",
91 get(list_workloads).post(create_workload))
92 .route("/api/v1/namespaces/:namespace/workloads/:name",
93 get(get_workload).put(update_workload).delete(delete_workload))
94 .route("/api/v1/namespaces/:namespace/workloads/:name/status",
95 get(get_workload_status).put(update_workload_status))
96
97 .route("/api/v1/nodes", get(list_nodes).post(create_node))
99 .route("/api/v1/nodes/:name", get(get_node).put(update_node).delete(delete_node))
100
101 .route("/api/v1/watch/namespaces/:namespace/workloads", get(watch_workloads))
103 .route("/api/v1/watch/nodes", get(watch_nodes))
104
105 .with_state(self.state.clone())
106 }
107
108 pub async fn serve(self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
110 let router = self.router();
111 let listener = tokio::net::TcpListener::bind(&self.config.listen_addr).await?;
112
113 info!(addr = %self.config.listen_addr, "API server starting");
114 axum::serve(listener, router).await?;
115
116 Ok(())
117 }
118}
119
120async fn health() -> impl IntoResponse {
122 StatusCode::OK
123}
124
125async fn ready() -> impl IntoResponse {
126 StatusCode::OK
127}
128
129async fn live() -> impl IntoResponse {
130 StatusCode::OK
131}
132
133async fn api_versions() -> impl IntoResponse {
135 Json(serde_json::json!({
136 "kind": "APIVersions",
137 "versions": ["v1"],
138 "serverAddressByClientCIDRs": []
139 }))
140}
141
142async fn api_groups() -> impl IntoResponse {
143 Json(serde_json::json!({
144 "kind": "APIGroupList",
145 "apiVersion": "v1",
146 "groups": [
147 {
148 "name": "forge.io",
149 "versions": [
150 {"groupVersion": "forge.io/v1", "version": "v1"}
151 ],
152 "preferredVersion": {"groupVersion": "forge.io/v1", "version": "v1"}
153 }
154 ]
155 }))
156}
157
158#[derive(Debug, Deserialize)]
160pub struct ListParams {
161 #[serde(rename = "labelSelector")]
163 pub label_selector: Option<String>,
164 #[serde(rename = "fieldSelector")]
166 pub field_selector: Option<String>,
167 pub limit: Option<u32>,
169 #[serde(rename = "continue")]
171 pub continue_token: Option<String>,
172 #[serde(rename = "resourceVersion")]
174 pub resource_version: Option<u64>,
175}
176
177#[derive(Debug, Serialize)]
179pub struct ApiResponse<T> {
180 #[serde(rename = "apiVersion")]
182 pub api_version: String,
183 pub kind: String,
185 pub metadata: ListMeta,
187 pub items: Vec<T>,
189}
190
191#[derive(Debug, Serialize)]
193pub struct ListMeta {
194 #[serde(rename = "resourceVersion")]
196 pub resource_version: String,
197 #[serde(rename = "continue", skip_serializing_if = "Option::is_none")]
199 pub continue_token: Option<String>,
200}
201
202#[derive(Debug, Serialize)]
204pub struct ApiError {
205 #[serde(rename = "apiVersion")]
207 pub api_version: String,
208 pub kind: String,
210 pub status: String,
212 pub message: String,
214 pub reason: String,
216 pub code: u16,
218}
219
220impl ApiError {
221 fn not_found(resource: &str, name: &str) -> Self {
222 Self {
223 api_version: "v1".to_string(),
224 kind: "Status".to_string(),
225 status: "Failure".to_string(),
226 message: format!("{} \"{}\" not found", resource, name),
227 reason: "NotFound".to_string(),
228 code: 404,
229 }
230 }
231
232 fn already_exists(resource: &str, name: &str) -> Self {
233 Self {
234 api_version: "v1".to_string(),
235 kind: "Status".to_string(),
236 status: "Failure".to_string(),
237 message: format!("{} \"{}\" already exists", resource, name),
238 reason: "AlreadyExists".to_string(),
239 code: 409,
240 }
241 }
242
243 fn conflict(message: &str) -> Self {
244 Self {
245 api_version: "v1".to_string(),
246 kind: "Status".to_string(),
247 status: "Failure".to_string(),
248 message: message.to_string(),
249 reason: "Conflict".to_string(),
250 code: 409,
251 }
252 }
253}
254
255impl IntoResponse for ApiError {
256 fn into_response(self) -> axum::response::Response {
257 let status = StatusCode::from_u16(self.code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
258 (status, Json(self)).into_response()
259 }
260}
261
262async fn list_workloads(
264 State(state): State<ApiServerState>,
265 Path(namespace): Path<String>,
266 Query(_params): Query<ListParams>,
267) -> impl IntoResponse {
268 let items = state.store.list(&ResourceKind::Workload, Some(&namespace));
269
270 Json(ApiResponse {
271 api_version: "forge.io/v1".to_string(),
272 kind: "WorkloadList".to_string(),
273 metadata: ListMeta {
274 resource_version: state.store.current_version().to_string(),
275 continue_token: None,
276 },
277 items,
278 })
279}
280
281async fn create_workload(
282 State(state): State<ApiServerState>,
283 Path(namespace): Path<String>,
284 Json(body): Json<serde_json::Value>,
285) -> Result<impl IntoResponse, ApiError> {
286 let name = body.get("metadata")
287 .and_then(|m| m.get("name"))
288 .and_then(|n| n.as_str())
289 .ok_or_else(|| ApiError {
290 api_version: "v1".to_string(),
291 kind: "Status".to_string(),
292 status: "Failure".to_string(),
293 message: "metadata.name is required".to_string(),
294 reason: "Invalid".to_string(),
295 code: 400,
296 })?;
297
298 let key = format!("{}/{}", namespace, name);
299
300 state.store.create(ResourceKind::Workload, &key, body.clone())
301 .map_err(|e| match e {
302 StoreError::AlreadyExists(_) => ApiError::already_exists("workload", name),
303 _ => ApiError {
304 api_version: "v1".to_string(),
305 kind: "Status".to_string(),
306 status: "Failure".to_string(),
307 message: e.to_string(),
308 reason: "InternalError".to_string(),
309 code: 500,
310 },
311 })?;
312
313 Ok((StatusCode::CREATED, Json(body)))
314}
315
316async fn get_workload(
317 State(state): State<ApiServerState>,
318 Path((namespace, name)): Path<(String, String)>,
319) -> Result<impl IntoResponse, ApiError> {
320 let key = format!("{}/{}", namespace, name);
321
322 state.store.get(&ResourceKind::Workload, &key)
323 .map(Json)
324 .ok_or_else(|| ApiError::not_found("workload", &name))
325}
326
327async fn update_workload(
328 State(state): State<ApiServerState>,
329 Path((namespace, name)): Path<(String, String)>,
330 Json(body): Json<serde_json::Value>,
331) -> Result<impl IntoResponse, ApiError> {
332 let key = format!("{}/{}", namespace, name);
333
334 let resource_version = body.get("metadata")
336 .and_then(|m| m.get("resourceVersion"))
337 .and_then(|v| v.as_str())
338 .and_then(|v| v.parse::<u64>().ok());
339
340 state.store.update(ResourceKind::Workload, &key, body.clone(), resource_version)
341 .map_err(|e| match e {
342 StoreError::NotFound(_) => ApiError::not_found("workload", &name),
343 StoreError::Conflict(expected, actual) => {
344 ApiError::conflict(&format!("resource version mismatch: expected {}, got {}", expected, actual))
345 }
346 _ => ApiError {
347 api_version: "v1".to_string(),
348 kind: "Status".to_string(),
349 status: "Failure".to_string(),
350 message: e.to_string(),
351 reason: "InternalError".to_string(),
352 code: 500,
353 },
354 })?;
355
356 Ok(Json(body))
357}
358
359async fn delete_workload(
360 State(state): State<ApiServerState>,
361 Path((namespace, name)): Path<(String, String)>,
362) -> Result<impl IntoResponse, ApiError> {
363 let key = format!("{}/{}", namespace, name);
364
365 state.store.delete(&ResourceKind::Workload, &key)
366 .map_err(|e| match e {
367 StoreError::NotFound(_) => ApiError::not_found("workload", &name),
368 _ => ApiError {
369 api_version: "v1".to_string(),
370 kind: "Status".to_string(),
371 status: "Failure".to_string(),
372 message: e.to_string(),
373 reason: "InternalError".to_string(),
374 code: 500,
375 },
376 })?;
377
378 Ok(StatusCode::OK)
379}
380
381async fn get_workload_status(
382 State(state): State<ApiServerState>,
383 Path((namespace, name)): Path<(String, String)>,
384) -> Result<impl IntoResponse, ApiError> {
385 let key = format!("{}/{}", namespace, name);
386
387 let workload = state.store.get(&ResourceKind::Workload, &key)
388 .ok_or_else(|| ApiError::not_found("workload", &name))?;
389
390 let status = workload.get("status").cloned().unwrap_or(serde_json::json!({}));
392 Ok(Json(status))
393}
394
395async fn update_workload_status(
396 State(state): State<ApiServerState>,
397 Path((namespace, name)): Path<(String, String)>,
398 Json(status): Json<serde_json::Value>,
399) -> Result<impl IntoResponse, ApiError> {
400 let key = format!("{}/{}", namespace, name);
401
402 let mut workload = state.store.get(&ResourceKind::Workload, &key)
403 .ok_or_else(|| ApiError::not_found("workload", &name))?;
404
405 if let Some(obj) = workload.as_object_mut() {
407 obj.insert("status".to_string(), status.clone());
408 }
409
410 state.store.update(ResourceKind::Workload, &key, workload.clone(), None)
411 .map_err(|e| ApiError {
412 api_version: "v1".to_string(),
413 kind: "Status".to_string(),
414 status: "Failure".to_string(),
415 message: e.to_string(),
416 reason: "InternalError".to_string(),
417 code: 500,
418 })?;
419
420 Ok(Json(status))
421}
422
423async fn list_nodes(
425 State(state): State<ApiServerState>,
426 Query(_params): Query<ListParams>,
427) -> impl IntoResponse {
428 let items = state.store.list(&ResourceKind::Node, None);
429
430 Json(ApiResponse {
431 api_version: "v1".to_string(),
432 kind: "NodeList".to_string(),
433 metadata: ListMeta {
434 resource_version: state.store.current_version().to_string(),
435 continue_token: None,
436 },
437 items,
438 })
439}
440
441async fn create_node(
442 State(state): State<ApiServerState>,
443 Json(body): Json<serde_json::Value>,
444) -> Result<impl IntoResponse, ApiError> {
445 let name = body.get("metadata")
446 .and_then(|m| m.get("name"))
447 .and_then(|n| n.as_str())
448 .ok_or_else(|| ApiError {
449 api_version: "v1".to_string(),
450 kind: "Status".to_string(),
451 status: "Failure".to_string(),
452 message: "metadata.name is required".to_string(),
453 reason: "Invalid".to_string(),
454 code: 400,
455 })?;
456
457 state.store.create(ResourceKind::Node, name, body.clone())
458 .map_err(|e| match e {
459 StoreError::AlreadyExists(_) => ApiError::already_exists("node", name),
460 _ => ApiError {
461 api_version: "v1".to_string(),
462 kind: "Status".to_string(),
463 status: "Failure".to_string(),
464 message: e.to_string(),
465 reason: "InternalError".to_string(),
466 code: 500,
467 },
468 })?;
469
470 Ok((StatusCode::CREATED, Json(body)))
471}
472
473async fn get_node(
474 State(state): State<ApiServerState>,
475 Path(name): Path<String>,
476) -> Result<impl IntoResponse, ApiError> {
477 state.store.get(&ResourceKind::Node, &name)
478 .map(Json)
479 .ok_or_else(|| ApiError::not_found("node", &name))
480}
481
482async fn update_node(
483 State(state): State<ApiServerState>,
484 Path(name): Path<String>,
485 Json(body): Json<serde_json::Value>,
486) -> Result<impl IntoResponse, ApiError> {
487 state.store.update(ResourceKind::Node, &name, body.clone(), None)
488 .map_err(|e| match e {
489 StoreError::NotFound(_) => ApiError::not_found("node", &name),
490 _ => ApiError {
491 api_version: "v1".to_string(),
492 kind: "Status".to_string(),
493 status: "Failure".to_string(),
494 message: e.to_string(),
495 reason: "InternalError".to_string(),
496 code: 500,
497 },
498 })?;
499
500 Ok(Json(body))
501}
502
503async fn delete_node(
504 State(state): State<ApiServerState>,
505 Path(name): Path<String>,
506) -> Result<impl IntoResponse, ApiError> {
507 state.store.delete(&ResourceKind::Node, &name)
508 .map_err(|e| match e {
509 StoreError::NotFound(_) => ApiError::not_found("node", &name),
510 _ => ApiError {
511 api_version: "v1".to_string(),
512 kind: "Status".to_string(),
513 status: "Failure".to_string(),
514 message: e.to_string(),
515 reason: "InternalError".to_string(),
516 code: 500,
517 },
518 })?;
519
520 Ok(StatusCode::OK)
521}
522
523fn event_namespace_matches(ev: &WatchEvent, namespace: &str) -> bool {
528 match ev {
529 WatchEvent::Added { key, .. }
530 | WatchEvent::Modified { key, .. }
531 | WatchEvent::Deleted { key, .. } => key.starts_with(&format!("{}/", namespace)),
532 WatchEvent::Bookmark { .. } | WatchEvent::Error { .. } => true,
533 }
534}
535
536fn to_sse_event(ev: &WatchEvent) -> Event {
538 let data = serde_json::to_string(ev).unwrap_or_else(|_| "{}".to_string());
539 Event::default().event(ev.event_type()).data(data)
540}
541
542async fn watch_workloads(
544 State(state): State<ApiServerState>,
545 Path(namespace): Path<String>,
546 Query(_params): Query<ListParams>,
547) -> Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>> {
548 let watch = state.store.watch(ResourceKind::Workload);
549 let body = stream::unfold((watch, namespace), |(mut watch, ns)| async move {
550 loop {
551 let ev = watch.recv().await?;
552 if event_namespace_matches(&ev, &ns) {
553 return Some((Ok::<_, std::convert::Infallible>(to_sse_event(&ev)), (watch, ns)));
554 }
555 }
556 });
557 Sse::new(body).keep_alive(KeepAlive::default())
558}
559
560async fn watch_nodes(
562 State(state): State<ApiServerState>,
563 Query(_params): Query<ListParams>,
564) -> Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>> {
565 let watch = state.store.watch(ResourceKind::Node);
566 let body = stream::unfold(watch, |mut watch| async move {
567 let ev = watch.recv().await?;
568 Some((Ok::<_, std::convert::Infallible>(to_sse_event(&ev)), watch))
569 });
570 Sse::new(body).keep_alive(KeepAlive::default())
571}
572
573#[cfg(test)]
574mod tests {
575 use super::*;
576
577 #[test]
578 fn router_builds_with_sse_watch_handlers() {
579 let server = ApiServer::new(ApiServerConfig::default(), Arc::new(ResourceStore::new()));
582 let _router = server.router();
583 }
584
585 #[test]
586 fn watch_event_namespace_filter_and_frame() {
587 let ev = WatchEvent::Added {
588 kind: ResourceKind::Workload,
589 key: "team-a/web".to_string(),
590 value: serde_json::json!({ "name": "web" }),
591 version: 7,
592 };
593 assert!(event_namespace_matches(&ev, "team-a"));
594 assert!(!event_namespace_matches(&ev, "team-b"));
595
596 let bm = WatchEvent::Bookmark { kind: ResourceKind::Workload, version: 9 };
598 assert!(event_namespace_matches(&bm, "anything"));
599
600 let _frame = to_sse_event(&ev);
602 }
603}