Skip to main content

forge_orchestration/controlplane/
api.rs

1//! REST API Server for the Control Plane
2//!
3//! Provides a Kubernetes-style API with:
4//! - RESTful CRUD operations
5//! - Watch endpoints for real-time updates
6//! - Subresource endpoints (status, scale)
7//! - OpenAPI schema generation
8
9use 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/// API Server configuration
26#[derive(Debug, Clone)]
27pub struct ApiServerConfig {
28    /// Listen address
29    pub listen_addr: String,
30    /// Enable TLS
31    pub tls_enabled: bool,
32    /// TLS cert path
33    pub tls_cert: Option<String>,
34    /// TLS key path
35    pub tls_key: Option<String>,
36    /// Enable authentication
37    pub auth_enabled: bool,
38    /// Enable admission controllers
39    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/// API Server state
56#[derive(Clone)]
57pub struct ApiServerState {
58    /// Resource store
59    pub store: Arc<ResourceStore>,
60}
61
62/// API Server
63pub struct ApiServer {
64    config: ApiServerConfig,
65    state: ApiServerState,
66}
67
68impl ApiServer {
69    /// Create new API server
70    pub fn new(config: ApiServerConfig, store: Arc<ResourceStore>) -> Self {
71        Self {
72            config,
73            state: ApiServerState { store },
74        }
75    }
76
77    /// Build the router
78    pub fn router(&self) -> Router {
79        Router::new()
80            // Health endpoints
81            .route("/healthz", get(health))
82            .route("/readyz", get(ready))
83            .route("/livez", get(live))
84            
85            // API discovery
86            .route("/api", get(api_versions))
87            .route("/apis", get(api_groups))
88            
89            // Core API v1
90            .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            // Nodes (cluster-scoped)
98            .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            // Watch endpoints
102            .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    /// Start the API server
109    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
120// Health endpoints
121async 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
133// API discovery
134async 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/// Query parameters for list operations
159#[derive(Debug, Deserialize)]
160pub struct ListParams {
161    /// Label selector
162    #[serde(rename = "labelSelector")]
163    pub label_selector: Option<String>,
164    /// Field selector
165    #[serde(rename = "fieldSelector")]
166    pub field_selector: Option<String>,
167    /// Limit results
168    pub limit: Option<u32>,
169    /// Continue token
170    #[serde(rename = "continue")]
171    pub continue_token: Option<String>,
172    /// Resource version for watch
173    #[serde(rename = "resourceVersion")]
174    pub resource_version: Option<u64>,
175}
176
177/// API response wrapper
178#[derive(Debug, Serialize)]
179pub struct ApiResponse<T> {
180    /// API version of the response (e.g. `"v1"`).
181    #[serde(rename = "apiVersion")]
182    pub api_version: String,
183    /// Resource kind (e.g. `"WorkloadList"`).
184    pub kind: String,
185    /// List metadata (resource version, continue token).
186    pub metadata: ListMeta,
187    /// The returned items.
188    pub items: Vec<T>,
189}
190
191/// List metadata
192#[derive(Debug, Serialize)]
193pub struct ListMeta {
194    /// Resource version the list reflects (for watch/resume).
195    #[serde(rename = "resourceVersion")]
196    pub resource_version: String,
197    /// Opaque token to continue a paginated list, if any.
198    #[serde(rename = "continue", skip_serializing_if = "Option::is_none")]
199    pub continue_token: Option<String>,
200}
201
202/// API error response
203#[derive(Debug, Serialize)]
204pub struct ApiError {
205    /// API version of the error envelope.
206    #[serde(rename = "apiVersion")]
207    pub api_version: String,
208    /// Resource kind (always `"Status"` for errors).
209    pub kind: String,
210    /// Status string (e.g. `"Failure"`).
211    pub status: String,
212    /// Human-readable error message.
213    pub message: String,
214    /// Machine-readable reason (e.g. `"NotFound"`).
215    pub reason: String,
216    /// HTTP status code.
217    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
262// Workload handlers
263async 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    // Extract resource version for optimistic concurrency
335    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    // Return just the status subresource
391    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    // Update only the status field
406    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
423// Node handlers
424async 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
523// Watch handlers — real Server-Sent Events streamed from the WatchRegistry.
524
525/// Whether a watch event belongs to `namespace` (data events are namespaced by
526/// a `"{namespace}/..."` key; bookmarks and errors are always forwarded).
527fn 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
536/// Serialize a watch event into an SSE frame (`event:` type + JSON `data:`).
537fn 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
542/// Stream workload changes in `namespace` as Server-Sent Events.
543async 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
560/// Stream node changes as Server-Sent Events.
561async 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        // Type-checks that watch_workloads / watch_nodes are valid axum handlers
580        // returning a real SSE stream (replaces the former static-JSON stubs).
581        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        // Bookmarks/errors are cluster-scoped and always forwarded.
597        let bm = WatchEvent::Bookmark { kind: ResourceKind::Workload, version: 9 };
598        assert!(event_namespace_matches(&bm, "anything"));
599
600        // SSE frame builds without panicking.
601        let _frame = to_sse_event(&ev);
602    }
603}