1use axum::extract::{Path, Query, State};
2use axum::http::StatusCode;
3use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
4use axum::routing::{get, post};
5use axum::{Json, Router};
6use serde::Deserialize;
7use std::convert::Infallible;
8use std::sync::Arc;
9use std::time::Duration;
10use tokio_stream::StreamExt;
11use uuid::Uuid;
12
13use tatara_core::catalog::{ServiceEntry, ServiceQuery};
14use tatara_core::cluster::types::NodeMeta;
15use tatara_core::domain::allocation::Allocation;
16use tatara_core::domain::event::EventKind;
17use tatara_core::domain::job::{Job, JobSpec, JobStatus};
18use tatara_core::domain::release::{CreateReleaseRequest, Release, ReleaseStatus};
19use tatara_core::domain::source::{CreateSourceRequest, Source, SourceStatus};
20use tatara_engine::catalog::registry::CatalogRegistry;
21use tatara_engine::client::executor::Executor;
22use tatara_engine::client::log_collector::LogCollector;
23use tatara_engine::cluster::store::ClusterStore;
24use tatara_engine::drivers::LogEntry;
25use tatara_engine::metrics::TataraMetrics;
26
27#[derive(Clone)]
28pub struct AppState {
29 pub cluster_store: Arc<ClusterStore>,
30 pub executor: Arc<Executor>,
31 pub log_collector: Arc<LogCollector>,
32 pub catalog_registry: Arc<CatalogRegistry>,
33 pub metrics: Arc<TataraMetrics>,
34}
35
36pub fn router(state: AppState) -> Router {
37 Router::new()
38 .route("/health", get(health))
39 .route("/api/v1/jobs", get(list_jobs).post(submit_job))
41 .route("/api/v1/jobs/{job_id}", get(get_job))
42 .route("/api/v1/jobs/{job_id}/stop", post(stop_job))
43 .route("/api/v1/jobs/{job_id}/history", get(get_job_history))
44 .route(
45 "/api/v1/jobs/{job_id}/rollback/{version}",
46 post(rollback_job),
47 )
48 .route("/api/v1/allocations", get(list_allocations))
50 .route("/api/v1/allocations/{alloc_id}", get(get_allocation))
51 .route(
52 "/api/v1/allocations/{alloc_id}/logs",
53 get(get_allocation_logs),
54 )
55 .route("/api/v1/nodes", get(list_nodes))
57 .route("/api/v1/nodes/{node_id}/drain", post(drain_node))
58 .route(
59 "/api/v1/nodes/{node_id}/eligibility",
60 post(set_node_eligibility),
61 )
62 .route("/api/v1/events", get(list_events))
64 .route("/api/v1/events/stream", get(stream_events))
65 .route("/api/v1/releases", get(list_releases).post(create_release))
67 .route("/api/v1/releases/{release_id}", get(get_release))
68 .route(
69 "/api/v1/releases/{release_id}/promote",
70 post(promote_release),
71 )
72 .route(
73 "/api/v1/releases/{release_id}/rollback",
74 post(rollback_release),
75 )
76 .route("/api/v1/sources", get(list_sources).post(create_source))
78 .route(
79 "/api/v1/sources/{source_id}",
80 get(get_source).delete(delete_source),
81 )
82 .route("/api/v1/sources/{source_id}/sync", post(sync_source))
83 .route("/api/v1/sources/{source_id}/suspend", post(suspend_source))
84 .route("/api/v1/sources/{source_id}/resume", post(resume_source))
85 .route("/v1/catalog/services", get(catalog_list_services))
87 .route("/v1/catalog/service/{name}", get(catalog_get_service))
88 .route("/v1/health/service/{name}", get(catalog_health_service))
89 .route("/api/v1/convergence/graph", get(convergence_graph))
91 .route("/api/v1/convergence/distance", get(convergence_distance))
92 .route("/api/v1/convergence/rate", get(convergence_rate))
93 .route("/api/v1/convergence/plan", get(convergence_plan))
94 .route(
95 "/api/v1/convergence/attestation/{point_id}",
96 get(convergence_attestation),
97 )
98 .route(
99 "/api/v1/convergence/compliance/{point_id}",
100 get(convergence_compliance),
101 )
102 .route("/api/v1/convergence/emissions", get(convergence_emissions))
103 .route(
104 "/api/v1/convergence/substrates",
105 get(convergence_substrates),
106 )
107 .route("/metrics", get(prometheus_metrics))
109 .with_state(state)
110}
111
112async fn health() -> &'static str {
113 "ok"
114}
115
116async fn submit_job(
119 State(state): State<AppState>,
120 Json(spec): Json<JobSpec>,
121) -> Result<Json<Job>, (StatusCode, String)> {
122 let job = spec.into_job();
123 let result = state
124 .cluster_store
125 .put_job(job)
126 .await
127 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
128
129 tracing::info!(
130 job_id = %result.value.id,
131 propagated = result.fully_propagated,
132 "Job submitted via REST"
133 );
134 Ok(Json(result.value))
135}
136
137async fn list_jobs(State(state): State<AppState>) -> Json<Vec<Job>> {
138 Json(state.cluster_store.list_jobs().await)
139}
140
141async fn get_job(
142 State(state): State<AppState>,
143 Path(job_id): Path<String>,
144) -> Result<Json<JobDetail>, (StatusCode, String)> {
145 let job = state
146 .cluster_store
147 .get_job(&job_id)
148 .await
149 .ok_or((StatusCode::NOT_FOUND, "Job not found".to_string()))?;
150
151 let allocations = state.cluster_store.list_allocations_for_job(&job_id).await;
152
153 Ok(Json(JobDetail { job, allocations }))
154}
155
156async fn stop_job(
157 State(state): State<AppState>,
158 Path(job_id): Path<String>,
159) -> Result<Json<Job>, (StatusCode, String)> {
160 let allocations = state.cluster_store.list_allocations_for_job(&job_id).await;
161
162 for alloc in &allocations {
163 if !alloc.is_terminal() {
164 if let Err(e) = state
165 .executor
166 .stop_allocation(&alloc.id, Duration::from_secs(10))
167 .await
168 {
169 tracing::warn!(
170 alloc_id = %alloc.id,
171 error = %e,
172 "Failed to stop allocation"
173 );
174 }
175 }
176 }
177
178 let result = state
179 .cluster_store
180 .update_job_status(&job_id, JobStatus::Dead)
181 .await
182 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
183
184 tracing::info!(job_id = %job_id, "Job stopped via REST");
185 Ok(Json(result.value))
186}
187
188async fn get_job_history(
189 State(state): State<AppState>,
190 Path(job_id): Path<String>,
191) -> Result<Json<Vec<tatara_core::cluster::types::JobVersionEntry>>, (StatusCode, String)> {
192 let history = state.cluster_store.get_job_history(&job_id).await;
193 if history.is_empty() {
194 if state.cluster_store.get_job(&job_id).await.is_none() {
196 return Err((StatusCode::NOT_FOUND, "Job not found".to_string()));
197 }
198 }
199 Ok(Json(history))
200}
201
202async fn rollback_job(
203 State(state): State<AppState>,
204 Path((job_id, version)): Path<(String, u64)>,
205) -> Result<Json<Job>, (StatusCode, String)> {
206 let result = state
207 .cluster_store
208 .rollback_job(&job_id, version)
209 .await
210 .map_err(|e| {
211 let msg = e.to_string();
212 if msg.contains("not found") {
213 (StatusCode::NOT_FOUND, msg)
214 } else {
215 (StatusCode::INTERNAL_SERVER_ERROR, msg)
216 }
217 })?;
218
219 tracing::info!(
220 job_id = %job_id,
221 version = version,
222 "Job rolled back via REST"
223 );
224 Ok(Json(result.value))
225}
226
227async fn list_allocations(State(state): State<AppState>) -> Json<Vec<Allocation>> {
230 Json(state.cluster_store.list_allocations().await)
231}
232
233async fn get_allocation(
234 State(state): State<AppState>,
235 Path(alloc_id): Path<String>,
236) -> Result<Json<Allocation>, (StatusCode, String)> {
237 let id: Uuid = alloc_id
238 .parse()
239 .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid allocation ID".to_string()))?;
240
241 state
242 .cluster_store
243 .get_allocation(&id)
244 .await
245 .map(Json)
246 .ok_or((StatusCode::NOT_FOUND, "Allocation not found".to_string()))
247}
248
249async fn get_allocation_logs(
250 State(state): State<AppState>,
251 Path(alloc_id): Path<String>,
252 params: Query<LogQuery>,
253) -> Result<Json<Vec<LogEntry>>, (StatusCode, String)> {
254 let id: Uuid = alloc_id
255 .parse()
256 .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid allocation ID".to_string()))?;
257
258 let alloc = state
259 .cluster_store
260 .get_allocation(&id)
261 .await
262 .ok_or((StatusCode::NOT_FOUND, "Allocation not found".to_string()))?;
263
264 let task_name = params
265 .task
266 .clone()
267 .unwrap_or_else(|| alloc.task_states.keys().next().cloned().unwrap_or_default());
268
269 state
270 .log_collector
271 .read_logs(&alloc_id, &task_name)
272 .await
273 .map(Json)
274 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))
275}
276
277async fn list_nodes(State(state): State<AppState>) -> Json<Vec<NodeMeta>> {
280 Json(state.cluster_store.list_nodes().await)
281}
282
283async fn drain_node(
284 State(state): State<AppState>,
285 Path(node_id): Path<String>,
286 Json(body): Json<DrainRequest>,
287) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
288 let id: u64 = node_id
289 .parse()
290 .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid node ID".to_string()))?;
291
292 state
293 .cluster_store
294 .drain_node(id)
295 .await
296 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
297
298 tracing::info!(node_id = id, "Node drain initiated via REST");
299
300 Ok(Json(serde_json::json!({
301 "node_id": id,
302 "status": "draining",
303 "deadline_secs": body.deadline_secs,
304 })))
305}
306
307async fn set_node_eligibility(
308 State(state): State<AppState>,
309 Path(node_id): Path<String>,
310 Json(body): Json<EligibilityRequest>,
311) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
312 let id: u64 = node_id
313 .parse()
314 .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid node ID".to_string()))?;
315
316 state
317 .cluster_store
318 .set_node_eligibility(id, body.eligible)
319 .await
320 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
321
322 Ok(Json(serde_json::json!({
323 "node_id": id,
324 "eligible": body.eligible,
325 })))
326}
327
328async fn list_events(
331 State(state): State<AppState>,
332 params: Query<EventQuery>,
333) -> Json<Vec<tatara_core::domain::event::Event>> {
334 let kind = params.kind.as_deref().and_then(EventKind::from_str_opt);
335
336 let since = params
337 .since
338 .as_deref()
339 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
340 .map(|dt| dt.with_timezone(&chrono::Utc));
341
342 Json(state.cluster_store.list_events(kind.as_ref(), since).await)
343}
344
345async fn stream_events(
346 State(state): State<AppState>,
347 params: Query<EventStreamQuery>,
348) -> Sse<impl tokio_stream::Stream<Item = Result<SseEvent, Infallible>>> {
349 let kind_filter = params.kind.as_deref().and_then(EventKind::from_str_opt);
350
351 let store = state.cluster_store.clone();
352
353 let stream = async_stream::stream! {
354 let mut last_count = 0usize;
355 loop {
356 let events = store.list_events(kind_filter.as_ref(), None).await;
357
358 if events.len() > last_count {
360 for event in &events[last_count..] {
361 let data = serde_json::to_string(event).unwrap_or_default();
362 yield Ok(SseEvent::default().data(data));
363 }
364 last_count = events.len();
365 }
366
367 tokio::time::sleep(Duration::from_millis(500)).await;
368 }
369 };
370
371 Sse::new(stream).keep_alive(KeepAlive::default())
372}
373
374async fn list_releases(State(state): State<AppState>) -> Json<Vec<Release>> {
377 Json(state.cluster_store.list_releases().await)
378}
379
380async fn create_release(
381 State(state): State<AppState>,
382 Json(req): Json<CreateReleaseRequest>,
383) -> Result<Json<Release>, (StatusCode, String)> {
384 let mut release = Release::new(req.name, req.flake_ref, req.job_id);
385 release.flake_rev = req.flake_rev;
386 release.status = ReleaseStatus::Active;
387
388 let result = state
389 .cluster_store
390 .put_release(release)
391 .await
392 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
393
394 Ok(Json(result.value))
395}
396
397async fn get_release(
398 State(state): State<AppState>,
399 Path(release_id): Path<String>,
400) -> Result<Json<Release>, (StatusCode, String)> {
401 let id: Uuid = release_id
402 .parse()
403 .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid release ID".to_string()))?;
404
405 state
406 .cluster_store
407 .get_release(&id)
408 .await
409 .map(Json)
410 .ok_or((StatusCode::NOT_FOUND, "Release not found".to_string()))
411}
412
413async fn promote_release(
414 State(state): State<AppState>,
415 Path(release_id): Path<String>,
416) -> Result<Json<Release>, (StatusCode, String)> {
417 let id: Uuid = release_id
418 .parse()
419 .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid release ID".to_string()))?;
420
421 let releases = state.cluster_store.list_releases().await;
423 for rel in &releases {
424 if rel.id != id && rel.status == ReleaseStatus::Active {
425 let _ = state
426 .cluster_store
427 .update_release_status(rel.id, ReleaseStatus::Superseded)
428 .await;
429 }
430 }
431
432 let result = state
433 .cluster_store
434 .update_release_status(id, ReleaseStatus::Active)
435 .await
436 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
437
438 Ok(Json(result.value))
439}
440
441async fn rollback_release(
442 State(state): State<AppState>,
443 Path(release_id): Path<String>,
444) -> Result<Json<Release>, (StatusCode, String)> {
445 let id: Uuid = release_id
446 .parse()
447 .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid release ID".to_string()))?;
448
449 let result = state
450 .cluster_store
451 .update_release_status(id, ReleaseStatus::RolledBack)
452 .await
453 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
454
455 Ok(Json(result.value))
456}
457
458async fn list_sources(State(state): State<AppState>) -> Json<Vec<Source>> {
461 Json(state.cluster_store.list_sources().await)
462}
463
464async fn create_source(
465 State(state): State<AppState>,
466 Json(req): Json<CreateSourceRequest>,
467) -> Result<Json<Source>, (StatusCode, String)> {
468 let source = Source::new(req.name, req.kind, req.flake_ref);
469
470 let result = state
471 .cluster_store
472 .put_source(source)
473 .await
474 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
475
476 tracing::info!(
477 source_id = %result.value.id,
478 name = %result.value.name,
479 "Source created via REST"
480 );
481 Ok(Json(result.value))
482}
483
484async fn get_source(
485 State(state): State<AppState>,
486 Path(source_id): Path<String>,
487) -> Result<Json<Source>, (StatusCode, String)> {
488 let id: Uuid = source_id
489 .parse()
490 .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid source ID".to_string()))?;
491
492 state
493 .cluster_store
494 .get_source(&id)
495 .await
496 .map(Json)
497 .ok_or((StatusCode::NOT_FOUND, "Source not found".to_string()))
498}
499
500async fn delete_source(
501 State(state): State<AppState>,
502 Path(source_id): Path<String>,
503) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
504 let id: Uuid = source_id
505 .parse()
506 .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid source ID".to_string()))?;
507
508 let source = state
510 .cluster_store
511 .get_source(&id)
512 .await
513 .ok_or((StatusCode::NOT_FOUND, "Source not found".to_string()))?;
514
515 for job_name in source.managed_jobs.keys() {
517 let _ = state
518 .cluster_store
519 .update_job_status(job_name, JobStatus::Dead)
520 .await;
521 }
522
523 state
524 .cluster_store
525 .delete_source(id)
526 .await
527 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
528
529 tracing::info!(source_id = %source_id, "Source deleted via REST");
530 Ok(Json(serde_json::json!({ "deleted": source_id })))
531}
532
533async fn sync_source(
534 State(state): State<AppState>,
535 Path(source_id): Path<String>,
536) -> Result<Json<Source>, (StatusCode, String)> {
537 let id: Uuid = source_id
538 .parse()
539 .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid source ID".to_string()))?;
540
541 let result = state
543 .cluster_store
544 .update_source(id, SourceStatus::Pending, None, None, None)
545 .await
546 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
547
548 tracing::info!(source_id = %source_id, "Source sync triggered via REST");
549 Ok(Json(result.value))
550}
551
552async fn suspend_source(
553 State(state): State<AppState>,
554 Path(source_id): Path<String>,
555) -> Result<Json<Source>, (StatusCode, String)> {
556 let id: Uuid = source_id
557 .parse()
558 .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid source ID".to_string()))?;
559
560 let result = state
561 .cluster_store
562 .update_source(id, SourceStatus::Suspended, None, None, None)
563 .await
564 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
565
566 tracing::info!(source_id = %source_id, "Source suspended via REST");
567 Ok(Json(result.value))
568}
569
570async fn resume_source(
571 State(state): State<AppState>,
572 Path(source_id): Path<String>,
573) -> Result<Json<Source>, (StatusCode, String)> {
574 let id: Uuid = source_id
575 .parse()
576 .map_err(|_| (StatusCode::BAD_REQUEST, "Invalid source ID".to_string()))?;
577
578 let result = state
579 .cluster_store
580 .update_source(id, SourceStatus::Pending, None, None, None)
581 .await
582 .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
583
584 tracing::info!(source_id = %source_id, "Source resumed via REST");
585 Ok(Json(result.value))
586}
587
588#[derive(serde::Serialize)]
591struct JobDetail {
592 job: Job,
593 allocations: Vec<Allocation>,
594}
595
596#[derive(Deserialize)]
597struct LogQuery {
598 task: Option<String>,
599}
600
601#[derive(Deserialize)]
602struct DrainRequest {
603 #[serde(default)]
604 deadline_secs: Option<u64>,
605}
606
607#[derive(Deserialize)]
608struct EligibilityRequest {
609 eligible: bool,
610}
611
612#[derive(Deserialize)]
613struct EventQuery {
614 kind: Option<String>,
615 since: Option<String>,
616}
617
618#[derive(Deserialize)]
619struct EventStreamQuery {
620 kind: Option<String>,
621}
622
623async fn catalog_list_services(State(state): State<AppState>) -> Json<Vec<String>> {
626 Json(state.catalog_registry.list_services().await)
627}
628
629async fn catalog_get_service(
630 State(state): State<AppState>,
631 Path(name): Path<String>,
632) -> Json<Vec<ServiceEntry>> {
633 Json(state.catalog_registry.get_service(&name).await)
634}
635
636#[derive(Deserialize)]
637struct HealthQuery {
638 #[serde(default)]
639 passing: Option<bool>,
640}
641
642async fn catalog_health_service(
643 State(state): State<AppState>,
644 Path(name): Path<String>,
645 params: Query<HealthQuery>,
646) -> Json<Vec<ServiceEntry>> {
647 let query = ServiceQuery {
648 service: name,
649 healthy_only: params.passing.unwrap_or(false),
650 ..Default::default()
651 };
652 Json(state.catalog_registry.query(&query).await)
653}
654
655async fn convergence_graph(State(_state): State<AppState>) -> Json<serde_json::Value> {
658 Json(serde_json::json!({
661 "points": {},
662 "edges": [],
663 "substrates": [],
664 "status": "no active convergence graph"
665 }))
666}
667
668async fn convergence_distance(State(_state): State<AppState>) -> Json<serde_json::Value> {
669 Json(serde_json::json!({
671 "distances": {},
672 "overall": 0.0,
673 "is_converged": true,
674 "substrate_count": 0
675 }))
676}
677
678async fn convergence_rate(State(_state): State<AppState>) -> Json<serde_json::Value> {
679 Json(serde_json::json!({
681 "rates": {},
682 "overall_rate": 0.0,
683 "oscillating_count": 0
684 }))
685}
686
687async fn convergence_plan(State(_state): State<AppState>) -> Json<serde_json::Value> {
688 Json(serde_json::json!({
690 "execution_order": [],
691 "critical_path": [],
692 "cache_hits": 0,
693 "compliance_bindings": 0
694 }))
695}
696
697async fn convergence_attestation(
698 State(_state): State<AppState>,
699 Path(point_id): Path<String>,
700) -> Json<serde_json::Value> {
701 Json(serde_json::json!({
703 "point_id": point_id,
704 "attestation": null,
705 "generation": 0
706 }))
707}
708
709async fn convergence_compliance(
710 State(_state): State<AppState>,
711 Path(point_id): Path<String>,
712) -> Json<serde_json::Value> {
713 Json(serde_json::json!({
715 "point_id": point_id,
716 "bindings": [],
717 "all_satisfied": true
718 }))
719}
720
721async fn convergence_emissions(State(_state): State<AppState>) -> Json<serde_json::Value> {
722 Json(serde_json::json!({
724 "schemas": [],
725 "recent_instantiations": []
726 }))
727}
728
729async fn convergence_substrates(State(_state): State<AppState>) -> Json<serde_json::Value> {
730 Json(serde_json::json!({
732 "substrates": []
733 }))
734}
735
736async fn prometheus_metrics(
739 State(state): State<AppState>,
740) -> ([(axum::http::header::HeaderName, &'static str); 1], String) {
741 (
742 [(
743 axum::http::header::CONTENT_TYPE,
744 "text/plain; version=0.0.4",
745 )],
746 state.metrics.render_prometheus(),
747 )
748}