diff --git a/Cargo.lock b/Cargo.lock index b8b7ac537..5e61a4ed5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1096,6 +1096,18 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +[[package]] +name = "cron" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbe58904b68d95ad2c69a2e9607ffc70ce196c1910f83a340029c1448e06ed65" +dependencies = [ + "chrono", + "once_cell", + "serde", + "winnow 0.6.22", +] + [[package]] name = "crossbeam-channel" version = "0.5.14" @@ -6919,6 +6931,7 @@ dependencies = [ "anyhow", "bytesize", "ciborium", + "cron", "derive_builder", "hex", "indexmap 2.7.0", diff --git a/docs/schema/generated/jsonschema/types/AppConfigV1.schema.json b/docs/schema/generated/jsonschema/types/AppConfigV1.schema.json index 69b59dec7..32d7c3918 100644 --- a/docs/schema/generated/jsonschema/types/AppConfigV1.schema.json +++ b/docs/schema/generated/jsonschema/types/AppConfigV1.schema.json @@ -67,6 +67,15 @@ "$ref": "#/definitions/HealthCheckV1" } }, + "jobs": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Job" + } + }, "locality": { "description": "Location-related configuration for the app.", "anyOf": [ @@ -246,6 +255,85 @@ } } }, + "ExecutableJob": { + "type": "object", + "properties": { + "capabilities": { + "anyOf": [ + { + "$ref": "#/definitions/ExecutableJobCompatibilityMapV1" + }, + { + "type": "null" + } + ] + }, + "cli_args": { + "description": "CLI arguments passed to the runner. Only applicable for runners that accept CLI arguments.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "command": { + "description": "The command to run. Defaults to the package's entrypoint.", + "type": [ + "string", + "null" + ] + }, + "env": { + "description": "Environment variables.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + } + }, + "package": { + "description": "The package that contains the command to run. Defaults to the app config's package.", + "anyOf": [ + { + "$ref": "#/definitions/PackageSource" + }, + { + "type": "null" + } + ] + }, + "volumes": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/AppVolume" + } + } + } + }, + "ExecutableJobCompatibilityMapV1": { + "type": "object", + "properties": { + "memory": { + "description": "Instance memory settings.", + "anyOf": [ + { + "$ref": "#/definitions/AppConfigCapabilityMemoryV1" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + }, "HealthCheckHttpV1": { "description": "Health check configuration for http endpoints.", "type": "object", @@ -443,6 +531,45 @@ } } }, + "Job": { + "description": "Job configuration.", + "type": "object", + "required": [ + "name", + "trigger" + ], + "properties": { + "execute": { + "anyOf": [ + { + "$ref": "#/definitions/ExecutableJob" + }, + { + "type": "null" + } + ] + }, + "fetch": { + "anyOf": [ + { + "$ref": "#/definitions/HttpRequest" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "trigger": { + "$ref": "#/definitions/JobTrigger" + } + } + }, + "JobTrigger": { + "type": "string" + }, "Locality": { "type": "object", "required": [ diff --git a/lib/config/Cargo.toml b/lib/config/Cargo.toml index e9fdcd82d..01574e43f 100644 --- a/lib/config/Cargo.toml +++ b/lib/config/Cargo.toml @@ -27,6 +27,7 @@ schemars = { version = "0.8.16", features = ["url"] } url = { version = "2.5.0", features = ["serde"] } hex = "0.4.3" ciborium = "0.2.2" +cron = { version = "0.14.0", features = ["serde"] } [dev-dependencies] pretty_assertions.workspace = true diff --git a/lib/config/src/app/job.rs b/lib/config/src/app/job.rs new file mode 100644 index 000000000..cd56d7d1c --- /dev/null +++ b/lib/config/src/app/job.rs @@ -0,0 +1,239 @@ +use std::{borrow::Cow, collections::HashMap, fmt::Display, str::FromStr}; + +use serde::{de::Error, Deserialize, Serialize}; + +use crate::package::PackageSource; + +use super::{AppConfigCapabilityMemoryV1, AppVolume, HttpRequest}; + +/// Job configuration. +#[derive( + serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq, +)] +pub struct Job { + name: String, + trigger: JobTrigger, + #[serde(skip_serializing_if = "Option::is_none")] + fetch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + execute: Option, +} + +#[derive( + serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq, +)] +pub enum CronType { + Daily, + Hourly, + Weekly, + CronExpression(String), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum JobTrigger { + PreDeployment, + PostDeployment, + Cron(CronType), +} + +#[derive( + serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq, +)] +pub struct ExecutableJob { + /// The package that contains the command to run. Defaults to the app config's package. + #[serde(skip_serializing_if = "Option::is_none")] + package: Option, + + /// The command to run. Defaults to the package's entrypoint. + #[serde(skip_serializing_if = "Option::is_none")] + command: Option, + + /// CLI arguments passed to the runner. + /// Only applicable for runners that accept CLI arguments. + #[serde(skip_serializing_if = "Option::is_none")] + cli_args: Option>, + + /// Environment variables. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub env: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub capabilities: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub volumes: Option>, +} + +#[derive( + serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq, +)] +pub struct ExecutableJobCompatibilityMapV1 { + /// Instance memory settings. + #[serde(skip_serializing_if = "Option::is_none")] + pub memory: Option, + + /// Additional unknown capabilities. + /// + /// This provides a small bit of forwards compatibility for newly added + /// capabilities. + #[serde(flatten)] + pub other: HashMap, +} + +impl Serialize for JobTrigger { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + self.to_string().serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for JobTrigger { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let repr: Cow<'de, str> = Cow::deserialize(deserializer)?; + repr.parse().map_err(D::Error::custom) + } +} + +impl Display for JobTrigger { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::PreDeployment => write!(f, "pre-deployment"), + Self::PostDeployment => write!(f, "post-deployment"), + Self::Cron(CronType::Hourly) => write!(f, "@hourly"), + Self::Cron(CronType::Daily) => write!(f, "@daily"), + Self::Cron(CronType::Weekly) => write!(f, "@weekly"), + Self::Cron(CronType::CronExpression(sched)) => write!(f, "{}", sched), + } + } +} + +impl FromStr for JobTrigger { + type Err = Box; + + fn from_str(s: &str) -> Result { + if s == "pre-deployment" { + Ok(Self::PreDeployment) + } else if s == "post-deployment" { + Ok(Self::PostDeployment) + } else if let Some(predefined_sched) = s.strip_prefix('@') { + match predefined_sched { + "hourly" => Ok(Self::Cron(CronType::Hourly)), + "daily" => Ok(Self::Cron(CronType::Daily)), + "weekly" => Ok(Self::Cron(CronType::Weekly)), + _ => Err(format!("Invalid cron expression {s}").into()), + } + } else { + // Let's make sure the input string is valid... + match cron::Schedule::from_str(s) { + Ok(sched) => Ok(Self::Cron(CronType::CronExpression( + sched.source().to_owned(), + ))), + Err(_) => Err(format!("Invalid cron expression {s}").into()), + } + } + } +} + +impl schemars::JsonSchema for JobTrigger { + fn schema_name() -> String { + "JobTrigger".to_owned() + } + + fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + String::json_schema(gen) + } +} + +#[cfg(test)] +mod tests { + use crate::app::JobTrigger; + + use super::Job; + + #[test] + pub fn job_trigger_serialization_roundtrip() { + fn assert_roundtrip(serialized: &str, value: JobTrigger) { + assert_eq!(&value.to_string(), serialized); + assert_eq!(serialized.parse::().unwrap(), value); + } + + assert_roundtrip("pre-deployment", JobTrigger::PreDeployment); + assert_roundtrip("post-deployment", JobTrigger::PostDeployment); + + assert_roundtrip("@hourly", JobTrigger::Cron(crate::app::CronType::Hourly)); + assert_roundtrip("@daily", JobTrigger::Cron(crate::app::CronType::Daily)); + assert_roundtrip("@weekly", JobTrigger::Cron(crate::app::CronType::Weekly)); + + // Note: the parsing code should keep the formatting of the source string. + // This is tested in assert_roundtrip. + assert_roundtrip( + "0 0/2 12 ? JAN-APR 2", + JobTrigger::Cron(crate::app::CronType::CronExpression( + "0 0/2 12 ? JAN-APR 2".to_owned(), + )), + ); + } + + #[test] + pub fn job_serialization_roundtrip() { + let job = Job { + name: "my-job".to_owned(), + trigger: JobTrigger::Cron(super::CronType::CronExpression( + "0 0/2 12 ? JAN-APR 2".to_owned(), + )), + fetch: None, + execute: Some(super::ExecutableJob { + package: Some(crate::package::PackageSource::Ident( + crate::package::PackageIdent::Named(crate::package::NamedPackageIdent { + registry: None, + namespace: Some("ns".to_owned()), + name: "pkg".to_owned(), + tag: None, + }), + )), + command: Some("cmd".to_owned()), + cli_args: Some(vec!["arg-1".to_owned(), "arg-2".to_owned()]), + env: Some([("VAR1".to_owned(), "Value".to_owned())].into()), + capabilities: Some(super::ExecutableJobCompatibilityMapV1 { + memory: Some(crate::app::AppConfigCapabilityMemoryV1 { + limit: Some(bytesize::ByteSize::gb(1)), + }), + other: Default::default(), + }), + volumes: Some(vec![crate::app::AppVolume { + name: "vol".to_owned(), + mount: "/path/to/volume".to_owned(), + }]), + }), + }; + + let serialized = r#" +name: my-job +trigger: '0 0/2 12 ? JAN-APR 2' +execute: + package: ns/pkg + command: cmd + cli_args: + - arg-1 + - arg-2 + env: + VAR1: Value + capabilities: + memory: + limit: '1000.0 MB' + volumes: + - name: vol + mount: /path/to/volume"#; + + assert_eq!( + serialized.trim(), + serde_yaml::to_string(&job).unwrap().trim() + ); + assert_eq!(job, serde_yaml::from_str(serialized).unwrap()); + } +} diff --git a/lib/config/src/app/mod.rs b/lib/config/src/app/mod.rs index 6bcd55acd..7095fc736 100644 --- a/lib/config/src/app/mod.rs +++ b/lib/config/src/app/mod.rs @@ -2,11 +2,9 @@ mod healthcheck; mod http; +mod job; -pub use self::{ - healthcheck::{HealthCheckHttpV1, HealthCheckV1}, - http::HttpRequest, -}; +pub use self::{healthcheck::*, http::*, job::*}; use std::collections::HashMap; @@ -95,6 +93,9 @@ pub struct AppConfigV1 { #[serde(default, skip_serializing_if = "Option::is_none")] pub redirect: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub jobs: Option>, + /// Capture extra fields for forwards compatibility. #[serde(flatten)] pub extra: HashMap, @@ -341,7 +342,8 @@ scheduled_tasks: }), locality: Some(Locality { regions: vec!["eu-rome".to_string()] - }) + }), + jobs: None, } ); }