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 invocation: String,
281
282 #[clap(long, default_value = "1000")]
284 max: i32,
285}
286
287#[async_trait::async_trait]
288impl AsyncCliCommand for CmdCronLogs {
289 type Output = ();
290
291 async fn run_async(self) -> Result<(), anyhow::Error> {
292 if self.max < 1 {
293 bail!("--max must be greater than 0");
294 }
295
296 let client = self.env.client()?;
297 let logs = wasmer_backend_api::query::get_cron_job_invocation_logs_by_invocation_id(
298 &client,
299 &self.invocation,
300 Some(self.max),
301 )
302 .await?;
303
304 if logs.is_empty() {
305 eprintln!("Cron job invocation {} has no logs!", self.invocation);
306 } else {
307 println!("{}", self.fmt.format.render(logs.as_slice()));
308 }
309
310 Ok(())
311 }
312}
313
314#[derive(clap::Parser, Debug)]
316pub struct CmdCronEnable {
317 #[clap(flatten)]
318 env: WasmerEnv,
319
320 #[clap(flatten)]
321 ident: AppIdentArgOpts,
322
323 cron_job: String,
325}
326
327#[async_trait::async_trait]
328impl AsyncCliCommand for CmdCronEnable {
329 type Output = ();
330
331 async fn run_async(self) -> Result<(), anyhow::Error> {
332 toggle_cron_job(self.env, self.ident, self.cron_job, true).await
333 }
334}
335
336#[derive(clap::Parser, Debug)]
338pub struct CmdCronDisable {
339 #[clap(flatten)]
340 env: WasmerEnv,
341
342 #[clap(flatten)]
343 ident: AppIdentArgOpts,
344
345 cron_job: String,
347}
348
349#[async_trait::async_trait]
350impl AsyncCliCommand for CmdCronDisable {
351 type Output = ();
352
353 async fn run_async(self) -> Result<(), anyhow::Error> {
354 toggle_cron_job(self.env, self.ident, self.cron_job, false).await
355 }
356}
357
358async fn toggle_cron_job(
359 env: WasmerEnv,
360 ident: AppIdentArgOpts,
361 cron_job: String,
362 enabled: bool,
363) -> Result<(), anyhow::Error> {
364 let client = env.client()?;
365 let cron_job = resolve_cron_job(&client, ident, &cron_job).await?;
366
367 let cron_job =
368 wasmer_backend_api::query::toggle_cron_job(&client, cron_job.id.inner(), enabled).await?;
369 let state = if cron_job.enabled {
370 "enabled"
371 } else {
372 "disabled"
373 };
374
375 eprintln!("Cron job {} is now {}.", cron_job.name, state);
376 Ok(())
377}
378
379async fn resolve_cron_job(
380 client: &wasmer_backend_api::WasmerClient,
381 ident: AppIdentArgOpts,
382 cron_job: &str,
383) -> Result<wasmer_backend_api::types::CronJob, anyhow::Error> {
384 if should_resolve_cron_job_by_id(&ident, cron_job) {
385 return wasmer_backend_api::query::get_cron_job_by_id(client, cron_job).await;
386 }
387
388 let (_ident, app) = ident.to_opts().load_app(client).await?;
389 find_app_cron_job(client, &app, cron_job).await
390}
391
392fn should_resolve_cron_job_by_id(ident: &AppIdentArgOpts, cron_job: &str) -> bool {
393 ident.app.is_none() && cron_job.starts_with("cron_")
394}
395
396async fn find_app_cron_job(
397 client: &wasmer_backend_api::WasmerClient,
398 app: &wasmer_backend_api::types::DeployApp,
399 cron_job: &str,
400) -> Result<wasmer_backend_api::types::CronJob, anyhow::Error> {
401 wasmer_backend_api::query::get_app_cron_jobs(client, &app.owner.global_name, &app.name)
402 .await?
403 .into_iter()
404 .find(|job| job.id.inner() == cron_job || job.name == cron_job)
405 .with_context(|| format!("cron job '{cron_job}' not found"))
406}
407
408fn invocation_page_size(page_size: NonZeroU32) -> Result<i32, anyhow::Error> {
409 i32::try_from(page_size.get()).context("--page-size must be less than or equal to 2147483647")
410}