Skip to main content

tatara_api/
rest.rs

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        // Jobs
40        .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        // Allocations
49        .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        // Nodes
56        .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        // Events
63        .route("/api/v1/events", get(list_events))
64        .route("/api/v1/events/stream", get(stream_events))
65        // Releases
66        .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        // Sources
77        .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        // Catalog (consul-compatible subset)
86        .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        // Convergence
90        .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        // Metrics
108        .route("/metrics", get(prometheus_metrics))
109        .with_state(state)
110}
111
112async fn health() -> &'static str {
113    "ok"
114}
115
116// ── Jobs ──
117
118async 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        // Check if job exists at all
195        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
227// ── Allocations ──
228
229async 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
277// ── Nodes ──
278
279async 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
328// ── Events ──
329
330async 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            // Only send new events since last poll
359            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
374// ── Releases ──
375
376async 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    // Supersede all other active releases
422    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
458// ── Sources ──
459
460async 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    // Get source to find managed jobs
509    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    // Stop all managed jobs
516    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    // Force re-evaluation by clearing last_rev
542    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// ── Types ──
589
590#[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
623// ── Catalog ──
624
625async 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
655// ── Convergence ──
656
657async fn convergence_graph(State(_state): State<AppState>) -> Json<serde_json::Value> {
658    // Returns the current convergence graph across all substrates.
659    // Full implementation reads from the convergence engine's SubstrateManager.
660    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    // Returns per-substrate convergence distance vector.
670    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    // Returns convergence rate per point.
680    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    // Returns the current convergence plan.
689    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    // Returns attestation for a specific convergence point.
702    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    // Returns compliance status for a specific convergence point.
714    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    // Returns emission schemas and recent instantiations.
723    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    // Returns per-substrate DAG status.
731    Json(serde_json::json!({
732        "substrates": []
733    }))
734}
735
736// ── Metrics ──
737
738async 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}