wasmer_cli/commands/
cron.rs

1//! Cron job commands.
2
3use 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/// Manage cron jobs for Wasmer Edge apps.
25#[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/// List cron jobs for an app.
52#[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/// Get one cron job.
89#[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 id or name.
101    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/// List invocations for one cron job.
118#[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 id or name.
130    cron_job: String,
131
132    /// The earliest invocation timestamp to include.
133    ///
134    /// Defaults to 31 days before --end, or 31 days before now.
135    ///
136    /// Accepts RFC 3339, RFC 2822, date, Unix timestamp, or relative time.
137    #[clap(long = "start", alias = "from", value_parser = parse_timestamp_or_relative_time_negative_offset)]
138    start: Option<OffsetDateTime>,
139
140    /// The latest invocation timestamp to include.
141    ///
142    /// Defaults to now.
143    ///
144    /// Accepts RFC 3339, RFC 2822, date, Unix timestamp, or relative time.
145    #[clap(long = "end", alias = "until", value_parser = parse_timestamp_or_relative_time_negative_offset)]
146    end: Option<OffsetDateTime>,
147
148    /// Number of invocations to fetch per page.
149    #[clap(long, default_value_t = DEFAULT_CRON_JOB_PAGE_SIZE)]
150    page_size: NonZeroU32,
151
152    /// Fetch all invocation pages without prompting.
153    #[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/// Show logs for one cron job invocation.
271#[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 id or name.
283    cron_job: String,
284
285    /// Cron job invocation id or edge job id.
286    invocation: String,
287
288    /// The earliest invocation timestamp to include.
289    ///
290    /// Defaults to 31 days before --end, or 31 days before now.
291    ///
292    /// Accepts RFC 3339, RFC 2822, date, Unix timestamp, or relative time.
293    #[clap(long = "start", alias = "from", value_parser = parse_timestamp_or_relative_time_negative_offset)]
294    start: Option<OffsetDateTime>,
295
296    /// The latest invocation timestamp to include.
297    ///
298    /// Defaults to now.
299    ///
300    /// Accepts RFC 3339, RFC 2822, date, Unix timestamp, or relative time.
301    #[clap(long = "end", alias = "until", value_parser = parse_timestamp_or_relative_time_negative_offset)]
302    end: Option<OffsetDateTime>,
303
304    /// Maximum log lines to fetch.
305    #[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/// Enable one cron job.
360#[derive(clap::Parser, Debug)]
361pub struct CmdCronEnable {
362    #[clap(flatten)]
363    env: WasmerEnv,
364
365    #[clap(flatten)]
366    ident: AppIdentArgOpts,
367
368    /// Cron job id or name.
369    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/// Disable one cron job.
382#[derive(clap::Parser, Debug)]
383pub struct CmdCronDisable {
384    #[clap(flatten)]
385    env: WasmerEnv,
386
387    #[clap(flatten)]
388    ident: AppIdentArgOpts,
389
390    /// Cron job id or name.
391    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}