1use std::{
4 io::{self, IsTerminal, Write},
5 num::NonZeroU32,
6};
7
8use anyhow::{Context, bail};
9use time::OffsetDateTime;
10
11use crate::{
12 commands::{AsyncCliCommand, app::AppIdentArgOpts},
13 config::WasmerEnv,
14 opts::{ItemFormatOpts, ListFormatOpts},
15 utils::{render::ListFormat, timestamp::parse_timestamp_or_relative_time_negative_offset},
16};
17
18const DEFAULT_CRON_JOB_PAGE_SIZE: NonZeroU32 =
19 match NonZeroU32::new(wasmer_backend_api::query::CRON_JOB_PAGE_SIZE as u32) {
20 Some(value) => value,
21 None => panic!("cron job page size must be non-zero"),
22 };
23
24#[derive(clap::Subcommand, Debug)]
26pub enum CmdCron {
27 List(CmdCronList),
28 Get(CmdCronGet),
29 Invocations(CmdCronInvocations),
30 Logs(CmdCronLogs),
31 Enable(CmdCronEnable),
32 Disable(CmdCronDisable),
33}
34
35#[async_trait::async_trait]
36impl AsyncCliCommand for CmdCron {
37 type Output = ();
38
39 async fn run_async(self) -> Result<Self::Output, anyhow::Error> {
40 match self {
41 Self::List(cmd) => cmd.run_async().await,
42 Self::Get(cmd) => cmd.run_async().await,
43 Self::Invocations(cmd) => cmd.run_async().await,
44 Self::Logs(cmd) => cmd.run_async().await,
45 Self::Enable(cmd) => cmd.run_async().await,
46 Self::Disable(cmd) => cmd.run_async().await,
47 }
48 }
49}
50
51#[derive(clap::Parser, Debug)]
53pub struct CmdCronList {
54 #[clap(flatten)]
55 fmt: ListFormatOpts,
56
57 #[clap(flatten)]
58 env: WasmerEnv,
59
60 #[clap(flatten)]
61 ident: AppIdentArgOpts,
62}
63
64#[async_trait::async_trait]
65impl AsyncCliCommand for CmdCronList {
66 type Output = ();
67
68 async fn run_async(self) -> Result<(), anyhow::Error> {
69 let client = self.env.client()?;
70 let (_ident, app) = self.ident.to_opts().load_app(&client).await?;
71 let cron_jobs = wasmer_backend_api::query::get_app_cron_jobs(
72 &client,
73 &app.owner.global_name,
74 &app.name,
75 )
76 .await?;
77
78 if cron_jobs.is_empty() {
79 eprintln!("App {} has no cron jobs!", app.name);
80 } else {
81 println!("{}", self.fmt.format.render(cron_jobs.as_slice()));
82 }
83
84 Ok(())
85 }
86}
87
88#[derive(clap::Parser, Debug)]
90pub struct CmdCronGet {
91 #[clap(flatten)]
92 fmt: ItemFormatOpts,
93
94 #[clap(flatten)]
95 env: WasmerEnv,
96
97 #[clap(flatten)]
98 ident: AppIdentArgOpts,
99
100 cron_job: String,
102}
103
104#[async_trait::async_trait]
105impl AsyncCliCommand for CmdCronGet {
106 type Output = ();
107
108 async fn run_async(self) -> Result<(), anyhow::Error> {
109 let client = self.env.client()?;
110 let cron_job = resolve_cron_job(&client, self.ident, &self.cron_job).await?;
111
112 println!("{}", self.fmt.get().render(&cron_job));
113 Ok(())
114 }
115}
116
117#[derive(clap::Parser, Debug)]
119pub struct CmdCronInvocations {
120 #[clap(flatten)]
121 fmt: ListFormatOpts,
122
123 #[clap(flatten)]
124 env: WasmerEnv,
125
126 #[clap(flatten)]
127 ident: AppIdentArgOpts,
128
129 cron_job: String,
131
132 #[clap(long = "start", alias = "from", value_parser = parse_timestamp_or_relative_time_negative_offset)]
138 start: Option<OffsetDateTime>,
139
140 #[clap(long = "end", alias = "until", value_parser = parse_timestamp_or_relative_time_negative_offset)]
146 end: Option<OffsetDateTime>,
147
148 #[clap(long, default_value_t = DEFAULT_CRON_JOB_PAGE_SIZE)]
150 page_size: NonZeroU32,
151
152 #[clap(long)]
154 all: bool,
155}
156
157#[async_trait::async_trait]
158impl AsyncCliCommand for CmdCronInvocations {
159 type Output = ();
160
161 async fn run_async(self) -> Result<(), anyhow::Error> {
162 let invocation_first = invocation_page_size(self.page_size)?;
163
164 if let (Some(start), Some(end)) = (self.start, self.end)
165 && start > end
166 {
167 bail!("--start must be before or equal to --end");
168 }
169
170 let client = self.env.client()?;
171 let format = self.fmt.format;
172 let resolve_by_id = should_resolve_cron_job_by_id(&self.ident, &self.cron_job);
173 let app = if resolve_by_id {
174 None
175 } else {
176 Some(self.ident.to_opts().load_app(&client).await?.1)
177 };
178 let interactive = io::stdin().is_terminal()
179 && matches!(format, ListFormat::Table | ListFormat::ItemTable)
180 && !self.all;
181 let render_after_fetching =
182 self.all || !matches!(format, ListFormat::Table | ListFormat::ItemTable);
183 let mut invocation_after = None;
184 let mut all_invocations = Vec::new();
185 let mut saw_invocations = false;
186
187 loop {
188 let page = if resolve_by_id {
189 wasmer_backend_api::query::get_cron_job_invocations_page_by_id(
190 &client,
191 &self.cron_job,
192 invocation_after,
193 Some(invocation_first),
194 self.start,
195 self.end,
196 )
197 .await?
198 .1
199 } else {
200 let app = app.as_ref().expect("app is loaded for name-based lookup");
201 wasmer_backend_api::query::get_cron_job_invocations_page(
202 &client,
203 &app.owner.global_name,
204 &app.name,
205 &self.cron_job,
206 invocation_after,
207 Some(invocation_first),
208 self.start,
209 self.end,
210 )
211 .await?
212 .1
213 };
214
215 saw_invocations |= !page.items.is_empty();
216 invocation_after = page.next_cursor;
217
218 if render_after_fetching {
219 all_invocations.extend(page.items);
220 } else if !page.items.is_empty() {
221 println!("{}", format.render(page.items.as_slice()));
222 }
223
224 if invocation_after.is_none() {
225 break;
226 }
227
228 if self.all {
229 continue;
230 }
231
232 if !interactive {
233 eprintln!("More invocations are available. Re-run with --all to fetch every page.");
234 break;
235 }
236
237 if !prompt_next_invocation_page()? {
238 break;
239 }
240 }
241
242 if !saw_invocations {
243 eprintln!("Cron job {} has no invocations!", self.cron_job);
244 } else if render_after_fetching {
245 println!("{}", format.render(all_invocations.as_slice()));
246 }
247
248 Ok(())
249 }
250}
251
252fn prompt_next_invocation_page() -> Result<bool, anyhow::Error> {
253 eprint!("Press Enter for the next page, or q to quit: ");
254 io::stderr().flush()?;
255
256 let mut input = String::new();
257 io::stdin().read_line(&mut input)?;
258
259 let input = input.trim();
260 if input.is_empty() {
261 Ok(true)
262 } else if input.eq_ignore_ascii_case("q") {
263 Ok(false)
264 } else {
265 eprintln!("Unrecognized input; stopping.");
266 Ok(false)
267 }
268}
269
270#[derive(clap::Parser, Debug)]
272pub struct CmdCronLogs {
273 #[clap(flatten)]
274 fmt: ListFormatOpts,
275
276 #[clap(flatten)]
277 env: WasmerEnv,
278
279 #[clap(flatten)]
280 ident: AppIdentArgOpts,
281
282 cron_job: String,
284
285 invocation: String,
287
288 #[clap(long = "start", alias = "from", value_parser = parse_timestamp_or_relative_time_negative_offset)]
294 start: Option<OffsetDateTime>,
295
296 #[clap(long = "end", alias = "until", value_parser = parse_timestamp_or_relative_time_negative_offset)]
302 end: Option<OffsetDateTime>,
303
304 #[clap(long, default_value = "1000")]
306 max: i32,
307}
308
309#[async_trait::async_trait]
310impl AsyncCliCommand for CmdCronLogs {
311 type Output = ();
312
313 async fn run_async(self) -> Result<(), anyhow::Error> {
314 if self.max < 1 {
315 bail!("--max must be greater than 0");
316 }
317 if let (Some(start), Some(end)) = (self.start, self.end)
318 && start > end
319 {
320 bail!("--start must be before or equal to --end");
321 }
322
323 let client = self.env.client()?;
324 let logs = if should_resolve_cron_job_by_id(&self.ident, &self.cron_job) {
325 wasmer_backend_api::query::get_cron_job_invocation_logs_by_id(
326 &client,
327 &self.cron_job,
328 &self.invocation,
329 Some(self.max),
330 self.start,
331 self.end,
332 )
333 .await?
334 } else {
335 let (_ident, app) = self.ident.to_opts().load_app(&client).await?;
336 wasmer_backend_api::query::get_cron_job_invocation_logs(
337 &client,
338 &app.owner.global_name,
339 &app.name,
340 &self.cron_job,
341 &self.invocation,
342 Some(self.max),
343 self.start,
344 self.end,
345 )
346 .await?
347 };
348
349 if logs.is_empty() {
350 eprintln!("Cron job invocation {} has no logs!", self.invocation);
351 } else {
352 println!("{}", self.fmt.format.render(logs.as_slice()));
353 }
354
355 Ok(())
356 }
357}
358
359#[derive(clap::Parser, Debug)]
361pub struct CmdCronEnable {
362 #[clap(flatten)]
363 env: WasmerEnv,
364
365 #[clap(flatten)]
366 ident: AppIdentArgOpts,
367
368 cron_job: String,
370}
371
372#[async_trait::async_trait]
373impl AsyncCliCommand for CmdCronEnable {
374 type Output = ();
375
376 async fn run_async(self) -> Result<(), anyhow::Error> {
377 toggle_cron_job(self.env, self.ident, self.cron_job, true).await
378 }
379}
380
381#[derive(clap::Parser, Debug)]
383pub struct CmdCronDisable {
384 #[clap(flatten)]
385 env: WasmerEnv,
386
387 #[clap(flatten)]
388 ident: AppIdentArgOpts,
389
390 cron_job: String,
392}
393
394#[async_trait::async_trait]
395impl AsyncCliCommand for CmdCronDisable {
396 type Output = ();
397
398 async fn run_async(self) -> Result<(), anyhow::Error> {
399 toggle_cron_job(self.env, self.ident, self.cron_job, false).await
400 }
401}
402
403async fn toggle_cron_job(
404 env: WasmerEnv,
405 ident: AppIdentArgOpts,
406 cron_job: String,
407 enabled: bool,
408) -> Result<(), anyhow::Error> {
409 let client = env.client()?;
410 let cron_job = resolve_cron_job(&client, ident, &cron_job).await?;
411
412 let cron_job =
413 wasmer_backend_api::query::toggle_cron_job(&client, cron_job.id.inner(), enabled).await?;
414 let state = if cron_job.enabled {
415 "enabled"
416 } else {
417 "disabled"
418 };
419
420 eprintln!("Cron job {} is now {}.", cron_job.name, state);
421 Ok(())
422}
423
424async fn resolve_cron_job(
425 client: &wasmer_backend_api::WasmerClient,
426 ident: AppIdentArgOpts,
427 cron_job: &str,
428) -> Result<wasmer_backend_api::types::CronJob, anyhow::Error> {
429 if should_resolve_cron_job_by_id(&ident, cron_job) {
430 return wasmer_backend_api::query::get_cron_job_by_id(client, cron_job).await;
431 }
432
433 let (_ident, app) = ident.to_opts().load_app(client).await?;
434 find_app_cron_job(client, &app, cron_job).await
435}
436
437fn should_resolve_cron_job_by_id(ident: &AppIdentArgOpts, cron_job: &str) -> bool {
438 ident.app.is_none() && cron_job.starts_with("cron_")
439}
440
441async fn find_app_cron_job(
442 client: &wasmer_backend_api::WasmerClient,
443 app: &wasmer_backend_api::types::DeployApp,
444 cron_job: &str,
445) -> Result<wasmer_backend_api::types::CronJob, anyhow::Error> {
446 wasmer_backend_api::query::get_app_cron_jobs(client, &app.owner.global_name, &app.name)
447 .await?
448 .into_iter()
449 .find(|job| job.id.inner() == cron_job || job.name == cron_job)
450 .with_context(|| format!("cron job '{cron_job}' not found"))
451}
452
453fn invocation_page_size(page_size: NonZeroU32) -> Result<i32, anyhow::Error> {
454 i32::try_from(page_size.get()).context("--page-size must be less than or equal to 2147483647")
455}