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#[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
55struct 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#[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#[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}