Skip to content

Commit

Permalink
Implement app.yaml job schema
Browse files Browse the repository at this point in the history
  • Loading branch information
Arshia001 committed Jan 10, 2025
1 parent 7f23611 commit 266bbc5
Show file tree
Hide file tree
Showing 4 changed files with 250 additions and 5 deletions.
13 changes: 13 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions lib/config/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
229 changes: 229 additions & 0 deletions lib/config/src/app/job.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
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<HttpRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
execute: Option<ExecutableJob>,
}

#[derive(
serde::Serialize, serde::Deserialize, schemars::JsonSchema, Clone, Debug, PartialEq, Eq,
)]
pub enum CronType {
Daily,
Hourly,
Weekly,
CronExpression(String),
}

#[derive(schemars::JsonSchema, 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<PackageSource>,

/// The command to run. Defaults to the package's entrypoint.
#[serde(skip_serializing_if = "Option::is_none")]
command: Option<String>,

/// CLI arguments passed to the runner.
/// Only applicable for runners that accept CLI arguments.
#[serde(skip_serializing_if = "Option::is_none")]
cli_args: Option<Vec<String>>,

/// Environment variables.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env: Option<HashMap<String, String>>,

#[serde(skip_serializing_if = "Option::is_none")]
pub capabilities: Option<ExecutableJobCompatibilityMapV1>,

#[serde(skip_serializing_if = "Option::is_none")]
pub volumes: Option<Vec<AppVolume>>,
}

#[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<AppConfigCapabilityMemoryV1>,

/// Additional unknown capabilities.
///
/// This provides a small bit of forwards compatibility for newly added
/// capabilities.
#[serde(flatten)]
pub other: HashMap<String, serde_json::Value>,
}

impl Serialize for JobTrigger {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
self.to_string().serialize(serializer)
}
}

impl<'de> Deserialize<'de> for JobTrigger {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<dyn std::error::Error + Send + Sync>;

fn from_str(s: &str) -> Result<Self, Self::Err> {
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()),
}
}
}
}

#[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::<JobTrigger>().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());
}
}
12 changes: 7 additions & 5 deletions lib/config/src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -95,6 +93,9 @@ pub struct AppConfigV1 {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub redirect: Option<Redirect>,

#[serde(skip_serializing_if = "Option::is_none")]
pub jobs: Option<Vec<Job>>,

/// Capture extra fields for forwards compatibility.
#[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>,
Expand Down Expand Up @@ -341,7 +342,8 @@ scheduled_tasks:
}),
locality: Some(Locality {
regions: vec!["eu-rome".to_string()]
})
}),
jobs: None,
}
);
}
Expand Down

0 comments on commit 266bbc5

Please sign in to comment.