Skip to main content

tatara_api/
graphql.rs

1use async_graphql::*;
2use std::sync::Arc;
3use uuid::Uuid;
4
5use tatara_core::cluster::types::NodeMeta as DomainNodeMeta;
6use tatara_core::domain::allocation::{
7    Allocation as DomainAllocation, AllocationState as DomainAllocState,
8    TaskRunState as DomainTaskRunState, TaskState as DomainTaskState,
9};
10use tatara_core::domain::job::{
11    Job as DomainJob, JobSpec, JobStatus as DomainJobStatus, JobType as DomainJobType,
12};
13use tatara_engine::client::executor::Executor;
14use tatara_engine::client::log_collector::LogCollector;
15use tatara_engine::cluster::store::ClusterStore;
16use tatara_engine::drivers::LogEntry as DomainLogEntry;
17
18pub type TataraSchema = Schema<QueryRoot, MutationRoot, EmptySubscription>;
19
20pub struct QueryRoot;
21pub struct MutationRoot;
22
23// ── GraphQL Enums ──
24
25#[derive(Enum, Copy, Clone, Eq, PartialEq)]
26enum JobType {
27    Service,
28    Batch,
29    System,
30}
31
32#[derive(Enum, Copy, Clone, Eq, PartialEq)]
33enum JobStatus {
34    Pending,
35    Running,
36    Dead,
37}
38
39#[derive(Enum, Copy, Clone, Eq, PartialEq)]
40enum AllocState {
41    Pending,
42    Running,
43    Complete,
44    Failed,
45    Lost,
46}
47
48#[derive(Enum, Copy, Clone, Eq, PartialEq)]
49enum TaskState {
50    Pending,
51    Running,
52    Dead,
53}
54
55// ── GraphQL Object Wrappers ──
56
57struct GqlJob(DomainJob);
58struct GqlAllocation(DomainAllocation);
59struct GqlNode(DomainNodeMeta);
60struct GqlTaskState {
61    name: String,
62    state: DomainTaskState,
63}
64struct GqlLogEntry(DomainLogEntry);
65
66#[Object]
67impl GqlJob {
68    async fn id(&self) -> &str {
69        &self.0.id
70    }
71    async fn version(&self) -> u64 {
72        self.0.version
73    }
74    async fn job_type(&self) -> JobType {
75        match self.0.job_type {
76            DomainJobType::Service => JobType::Service,
77            DomainJobType::Batch => JobType::Batch,
78            DomainJobType::System => JobType::System,
79        }
80    }
81    async fn status(&self) -> JobStatus {
82        match self.0.status {
83            DomainJobStatus::Pending => JobStatus::Pending,
84            DomainJobStatus::Running => JobStatus::Running,
85            DomainJobStatus::Dead => JobStatus::Dead,
86        }
87    }
88    async fn submitted_at(&self) -> String {
89        self.0.submitted_at.to_rfc3339()
90    }
91    async fn group_count(&self) -> usize {
92        self.0.groups.len()
93    }
94}
95
96#[Object]
97impl GqlAllocation {
98    async fn id(&self) -> String {
99        self.0.id.to_string()
100    }
101    async fn job_id(&self) -> &str {
102        &self.0.job_id
103    }
104    async fn group_name(&self) -> &str {
105        &self.0.group_name
106    }
107    async fn node_id(&self) -> &str {
108        &self.0.node_id
109    }
110    async fn state(&self) -> AllocState {
111        match self.0.state {
112            DomainAllocState::Pending => AllocState::Pending,
113            DomainAllocState::Running => AllocState::Running,
114            DomainAllocState::Complete => AllocState::Complete,
115            DomainAllocState::Failed => AllocState::Failed,
116            DomainAllocState::Lost => AllocState::Lost,
117        }
118    }
119    async fn created_at(&self) -> String {
120        self.0.created_at.to_rfc3339()
121    }
122    async fn task_states(&self) -> Vec<GqlTaskState> {
123        self.0
124            .task_states
125            .iter()
126            .map(|(name, state)| GqlTaskState {
127                name: name.clone(),
128                state: state.clone(),
129            })
130            .collect()
131    }
132}
133
134#[Object]
135impl GqlTaskState {
136    async fn name(&self) -> &str {
137        &self.name
138    }
139    async fn state(&self) -> TaskState {
140        match self.state.state {
141            DomainTaskRunState::Pending => TaskState::Pending,
142            DomainTaskRunState::Running => TaskState::Running,
143            DomainTaskRunState::Dead => TaskState::Dead,
144        }
145    }
146    async fn pid(&self) -> Option<u32> {
147        self.state.pid
148    }
149    async fn exit_code(&self) -> Option<i32> {
150        self.state.exit_code
151    }
152    async fn restarts(&self) -> u32 {
153        self.state.restarts
154    }
155}
156
157#[Object]
158impl GqlNode {
159    async fn node_id(&self) -> u64 {
160        self.0.node_id
161    }
162    async fn hostname(&self) -> &str {
163        &self.0.hostname
164    }
165    async fn http_addr(&self) -> &str {
166        &self.0.http_addr
167    }
168    async fn os(&self) -> &str {
169        &self.0.os
170    }
171    async fn arch(&self) -> &str {
172        &self.0.arch
173    }
174    async fn voter(&self) -> bool {
175        self.0.roles.voter
176    }
177    async fn worker(&self) -> bool {
178        self.0.roles.worker
179    }
180    async fn cpu_total(&self) -> u64 {
181        self.0.total_resources.cpu_mhz
182    }
183    async fn memory_total(&self) -> u64 {
184        self.0.total_resources.memory_mb
185    }
186    async fn cpu_available(&self) -> u64 {
187        self.0.available_resources.cpu_mhz
188    }
189    async fn memory_available(&self) -> u64 {
190        self.0.available_resources.memory_mb
191    }
192    async fn allocations_running(&self) -> u32 {
193        self.0.allocations_running
194    }
195    async fn version(&self) -> &str {
196        &self.0.version
197    }
198    async fn joined_at(&self) -> String {
199        self.0.joined_at.to_rfc3339()
200    }
201}
202
203#[Object]
204impl GqlLogEntry {
205    async fn task_name(&self) -> &str {
206        &self.0.task_name
207    }
208    async fn message(&self) -> &str {
209        &self.0.message
210    }
211    async fn stream(&self) -> &str {
212        &self.0.stream
213    }
214    async fn timestamp(&self) -> String {
215        self.0.timestamp.to_rfc3339()
216    }
217}
218
219// ── Queries ──
220
221#[Object]
222impl QueryRoot {
223    async fn jobs(&self, ctx: &Context<'_>) -> Result<Vec<GqlJob>> {
224        let store = ctx.data::<Arc<ClusterStore>>()?;
225        Ok(store.list_jobs().await.into_iter().map(GqlJob).collect())
226    }
227
228    async fn job(&self, ctx: &Context<'_>, id: String) -> Result<Option<GqlJob>> {
229        let store = ctx.data::<Arc<ClusterStore>>()?;
230        Ok(store.get_job(&id).await.map(GqlJob))
231    }
232
233    async fn allocations(
234        &self,
235        ctx: &Context<'_>,
236        job_id: Option<String>,
237    ) -> Result<Vec<GqlAllocation>> {
238        let store = ctx.data::<Arc<ClusterStore>>()?;
239        let allocs = match job_id {
240            Some(id) => store.list_allocations_for_job(&id).await,
241            None => store.list_allocations().await,
242        };
243        Ok(allocs.into_iter().map(GqlAllocation).collect())
244    }
245
246    async fn allocation(&self, ctx: &Context<'_>, id: String) -> Result<Option<GqlAllocation>> {
247        let store = ctx.data::<Arc<ClusterStore>>()?;
248        let uuid: Uuid = id.parse()?;
249        Ok(store.get_allocation(&uuid).await.map(GqlAllocation))
250    }
251
252    async fn nodes(&self, ctx: &Context<'_>) -> Result<Vec<GqlNode>> {
253        let store = ctx.data::<Arc<ClusterStore>>()?;
254        Ok(store.list_nodes().await.into_iter().map(GqlNode).collect())
255    }
256
257    async fn logs(
258        &self,
259        ctx: &Context<'_>,
260        alloc_id: String,
261        task_name: Option<String>,
262    ) -> Result<Vec<GqlLogEntry>> {
263        let store = ctx.data::<Arc<ClusterStore>>()?;
264        let collector = ctx.data::<Arc<LogCollector>>()?;
265
266        let uuid: Uuid = alloc_id.parse()?;
267        let alloc = store
268            .get_allocation(&uuid)
269            .await
270            .ok_or_else(|| Error::new("Allocation not found"))?;
271
272        let task = task_name
273            .unwrap_or_else(|| alloc.task_states.keys().next().cloned().unwrap_or_default());
274
275        let entries = collector
276            .read_logs(&alloc_id, &task)
277            .await
278            .map_err(|e| Error::new(e.to_string()))?;
279
280        Ok(entries.into_iter().map(GqlLogEntry).collect())
281    }
282}
283
284// ── Mutations ──
285
286#[Object]
287impl MutationRoot {
288    async fn submit_job(&self, ctx: &Context<'_>, spec: String) -> Result<GqlJob> {
289        let store = ctx.data::<Arc<ClusterStore>>()?;
290        let job_spec: JobSpec = serde_json::from_str(&spec)
291            .map_err(|e| Error::new(format!("Invalid job spec: {}", e)))?;
292        let job = job_spec.into_job();
293        let result = store
294            .put_job(job)
295            .await
296            .map_err(|e| Error::new(e.to_string()))?;
297        tracing::info!(
298            job_id = %result.value.id,
299            propagated = result.fully_propagated,
300            "Job submitted via GraphQL"
301        );
302        Ok(GqlJob(result.value))
303    }
304
305    async fn stop_job(&self, ctx: &Context<'_>, job_id: String) -> Result<GqlJob> {
306        let store = ctx.data::<Arc<ClusterStore>>()?;
307        let executor = ctx.data::<Arc<Executor>>()?;
308
309        let allocations = store.list_allocations_for_job(&job_id).await;
310        for alloc in &allocations {
311            if !alloc.is_terminal() {
312                let _ = executor
313                    .stop_allocation(&alloc.id, std::time::Duration::from_secs(10))
314                    .await;
315            }
316        }
317
318        let result = store
319            .update_job_status(&job_id, DomainJobStatus::Dead)
320            .await
321            .map_err(|e| Error::new(e.to_string()))?;
322
323        tracing::info!(job_id = %job_id, "Job stopped via GraphQL");
324        Ok(GqlJob(result.value))
325    }
326}
327
328pub fn build_schema(
329    cluster_store: Arc<ClusterStore>,
330    executor: Arc<Executor>,
331    log_collector: Arc<LogCollector>,
332) -> TataraSchema {
333    Schema::build(QueryRoot, MutationRoot, EmptySubscription)
334        .data(cluster_store)
335        .data(executor)
336        .data(log_collector)
337        .finish()
338}