Implement app.yaml job schema

This commit is contained in:
Arshia Ghafoori
2025-01-11 01:04:16 +04:00
parent 7f23611243
commit a01ead1834
5 changed files with 387 additions and 5 deletions

13
Cargo.lock generated
View File

@@ -1096,6 +1096,18 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" 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]] [[package]]
name = "crossbeam-channel" name = "crossbeam-channel"
version = "0.5.14" version = "0.5.14"
@@ -6919,6 +6931,7 @@ dependencies = [
"anyhow", "anyhow",
"bytesize", "bytesize",
"ciborium", "ciborium",
"cron",
"derive_builder", "derive_builder",
"hex", "hex",
"indexmap 2.7.0", "indexmap 2.7.0",

View File

@@ -67,6 +67,15 @@
"$ref": "#/definitions/HealthCheckV1" "$ref": "#/definitions/HealthCheckV1"
} }
}, },
"jobs": {
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/Job"
}
},
"locality": { "locality": {
"description": "Location-related configuration for the app.", "description": "Location-related configuration for the app.",
"anyOf": [ "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": { "HealthCheckHttpV1": {
"description": "Health check configuration for http endpoints.", "description": "Health check configuration for http endpoints.",
"type": "object", "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": { "Locality": {
"type": "object", "type": "object",
"required": [ "required": [

View File

@@ -27,6 +27,7 @@ schemars = { version = "0.8.16", features = ["url"] }
url = { version = "2.5.0", features = ["serde"] } url = { version = "2.5.0", features = ["serde"] }
hex = "0.4.3" hex = "0.4.3"
ciborium = "0.2.2" ciborium = "0.2.2"
cron = { version = "0.14.0", features = ["serde"] }
[dev-dependencies] [dev-dependencies]
pretty_assertions.workspace = true pretty_assertions.workspace = true

239
lib/config/src/app/job.rs Normal file
View File

@@ -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<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(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()),
}
}
}
}
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::<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());
}
}

View File

@@ -2,11 +2,9 @@
mod healthcheck; mod healthcheck;
mod http; mod http;
mod job;
pub use self::{ pub use self::{healthcheck::*, http::*, job::*};
healthcheck::{HealthCheckHttpV1, HealthCheckV1},
http::HttpRequest,
};
use std::collections::HashMap; use std::collections::HashMap;
@@ -95,6 +93,9 @@ pub struct AppConfigV1 {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub redirect: Option<Redirect>, pub redirect: Option<Redirect>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jobs: Option<Vec<Job>>,
/// Capture extra fields for forwards compatibility. /// Capture extra fields for forwards compatibility.
#[serde(flatten)] #[serde(flatten)]
pub extra: HashMap<String, serde_json::Value>, pub extra: HashMap<String, serde_json::Value>,
@@ -341,7 +342,8 @@ scheduled_tasks:
}), }),
locality: Some(Locality { locality: Some(Locality {
regions: vec!["eu-rome".to_string()] regions: vec!["eu-rome".to_string()]
}) }),
jobs: None,
} }
); );
} }