feat(wfe-core): add ServiceDefinition types and ServiceProvider trait
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
pub mod condition;
|
pub mod condition;
|
||||||
pub mod error_behavior;
|
pub mod error_behavior;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
|
pub mod service;
|
||||||
pub mod execution_error;
|
pub mod execution_error;
|
||||||
pub mod execution_pointer;
|
pub mod execution_pointer;
|
||||||
pub mod execution_result;
|
pub mod execution_result;
|
||||||
@@ -26,6 +27,7 @@ pub use scheduled_command::{CommandName, ScheduledCommand};
|
|||||||
pub use schema::{SchemaType, WorkflowSchema};
|
pub use schema::{SchemaType, WorkflowSchema};
|
||||||
pub use status::{PointerStatus, WorkflowStatus};
|
pub use status::{PointerStatus, WorkflowStatus};
|
||||||
pub use workflow_definition::{StepOutcome, WorkflowDefinition, WorkflowStep};
|
pub use workflow_definition::{StepOutcome, WorkflowDefinition, WorkflowStep};
|
||||||
|
pub use service::{ReadinessCheck, ReadinessProbe, ServiceDefinition, ServiceEndpoint, ServicePort};
|
||||||
pub use workflow_instance::WorkflowInstance;
|
pub use workflow_instance::WorkflowInstance;
|
||||||
|
|
||||||
/// Serde helper for `Option<Duration>` as milliseconds.
|
/// Serde helper for `Option<Duration>` as milliseconds.
|
||||||
|
|||||||
283
wfe-core/src/models/service.rs
Normal file
283
wfe-core/src/models/service.rs
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// An infrastructure service that runs alongside workflow steps.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ServiceDefinition {
|
||||||
|
/// Service name -- used as DNS hostname (K8s) or env prefix (containerd).
|
||||||
|
pub name: String,
|
||||||
|
/// Container image to run.
|
||||||
|
pub image: String,
|
||||||
|
/// Ports exposed by the service.
|
||||||
|
#[serde(default)]
|
||||||
|
pub ports: Vec<ServicePort>,
|
||||||
|
/// Environment variables for the service container.
|
||||||
|
#[serde(default)]
|
||||||
|
pub env: HashMap<String, String>,
|
||||||
|
/// How to check if the service is ready to accept connections.
|
||||||
|
#[serde(default)]
|
||||||
|
pub readiness: Option<ReadinessProbe>,
|
||||||
|
/// Override the container entrypoint.
|
||||||
|
#[serde(default)]
|
||||||
|
pub command: Vec<String>,
|
||||||
|
/// Override the container command/args.
|
||||||
|
#[serde(default)]
|
||||||
|
pub args: Vec<String>,
|
||||||
|
/// Memory limit (e.g., "512Mi").
|
||||||
|
#[serde(default)]
|
||||||
|
pub memory: Option<String>,
|
||||||
|
/// CPU limit (e.g., "500m").
|
||||||
|
#[serde(default)]
|
||||||
|
pub cpu: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A port exposed by a service.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ServicePort {
|
||||||
|
pub container_port: u16,
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Protocol: "TCP" (default) or "UDP".
|
||||||
|
#[serde(default = "default_protocol")]
|
||||||
|
pub protocol: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServicePort {
|
||||||
|
pub fn tcp(port: u16) -> Self {
|
||||||
|
Self {
|
||||||
|
container_port: port,
|
||||||
|
name: None,
|
||||||
|
protocol: "TCP".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_protocol() -> String {
|
||||||
|
"TCP".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How to determine if a service is ready.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ReadinessProbe {
|
||||||
|
pub check: ReadinessCheck,
|
||||||
|
/// Poll interval in milliseconds.
|
||||||
|
#[serde(default = "default_5000")]
|
||||||
|
pub interval_ms: u64,
|
||||||
|
/// Total timeout in milliseconds.
|
||||||
|
#[serde(default = "default_60000")]
|
||||||
|
pub timeout_ms: u64,
|
||||||
|
/// Maximum number of retries before giving up.
|
||||||
|
#[serde(default = "default_12")]
|
||||||
|
pub retries: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The type of readiness check to perform.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ReadinessCheck {
|
||||||
|
/// Run a command inside the container.
|
||||||
|
Exec(Vec<String>),
|
||||||
|
/// Check if a TCP port is accepting connections.
|
||||||
|
TcpSocket(u16),
|
||||||
|
/// Make an HTTP GET request.
|
||||||
|
HttpGet { port: u16, path: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runtime endpoint info for a provisioned service.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ServiceEndpoint {
|
||||||
|
pub name: String,
|
||||||
|
pub host: String,
|
||||||
|
pub ports: Vec<ServicePort>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_5000() -> u64 {
|
||||||
|
5000
|
||||||
|
}
|
||||||
|
fn default_60000() -> u64 {
|
||||||
|
60000
|
||||||
|
}
|
||||||
|
fn default_12() -> u32 {
|
||||||
|
12
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use pretty_assertions::assert_eq;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn service_definition_minimal_serde() {
|
||||||
|
let json = r#"{"name":"postgres","image":"postgres:15"}"#;
|
||||||
|
let svc: ServiceDefinition = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(svc.name, "postgres");
|
||||||
|
assert_eq!(svc.image, "postgres:15");
|
||||||
|
assert!(svc.ports.is_empty());
|
||||||
|
assert!(svc.env.is_empty());
|
||||||
|
assert!(svc.readiness.is_none());
|
||||||
|
assert!(svc.command.is_empty());
|
||||||
|
assert!(svc.memory.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn service_definition_full_round_trip() {
|
||||||
|
let svc = ServiceDefinition {
|
||||||
|
name: "redis".into(),
|
||||||
|
image: "redis:7-alpine".into(),
|
||||||
|
ports: vec![ServicePort::tcp(6379)],
|
||||||
|
env: [("REDIS_PASSWORD".into(), "secret".into())].into(),
|
||||||
|
readiness: Some(ReadinessProbe {
|
||||||
|
check: ReadinessCheck::TcpSocket(6379),
|
||||||
|
interval_ms: 2000,
|
||||||
|
timeout_ms: 30000,
|
||||||
|
retries: 15,
|
||||||
|
}),
|
||||||
|
command: vec![],
|
||||||
|
args: vec!["--requirepass".into(), "secret".into()],
|
||||||
|
memory: Some("256Mi".into()),
|
||||||
|
cpu: Some("250m".into()),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&svc).unwrap();
|
||||||
|
let parsed: ServiceDefinition = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.name, "redis");
|
||||||
|
assert_eq!(parsed.ports.len(), 1);
|
||||||
|
assert_eq!(parsed.ports[0].container_port, 6379);
|
||||||
|
assert_eq!(parsed.args, vec!["--requirepass", "secret"]);
|
||||||
|
assert_eq!(parsed.memory, Some("256Mi".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn service_port_tcp_helper() {
|
||||||
|
let port = ServicePort::tcp(5432);
|
||||||
|
assert_eq!(port.container_port, 5432);
|
||||||
|
assert_eq!(port.protocol, "TCP");
|
||||||
|
assert!(port.name.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn service_port_default_protocol() {
|
||||||
|
let json = r#"{"container_port": 8080}"#;
|
||||||
|
let port: ServicePort = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(port.protocol, "TCP");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn readiness_probe_exec() {
|
||||||
|
let probe = ReadinessProbe {
|
||||||
|
check: ReadinessCheck::Exec(vec!["pg_isready".into(), "-U".into(), "postgres".into()]),
|
||||||
|
interval_ms: 5000,
|
||||||
|
timeout_ms: 60000,
|
||||||
|
retries: 12,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&probe).unwrap();
|
||||||
|
let parsed: ReadinessProbe = serde_json::from_str(&json).unwrap();
|
||||||
|
match parsed.check {
|
||||||
|
ReadinessCheck::Exec(cmd) => assert_eq!(cmd, vec!["pg_isready", "-U", "postgres"]),
|
||||||
|
_ => panic!("expected Exec"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn readiness_probe_tcp_socket() {
|
||||||
|
let probe = ReadinessProbe {
|
||||||
|
check: ReadinessCheck::TcpSocket(6379),
|
||||||
|
interval_ms: 2000,
|
||||||
|
timeout_ms: 30000,
|
||||||
|
retries: 15,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&probe).unwrap();
|
||||||
|
let parsed: ReadinessProbe = serde_json::from_str(&json).unwrap();
|
||||||
|
match parsed.check {
|
||||||
|
ReadinessCheck::TcpSocket(port) => assert_eq!(port, 6379),
|
||||||
|
_ => panic!("expected TcpSocket"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn readiness_probe_http_get() {
|
||||||
|
let probe = ReadinessProbe {
|
||||||
|
check: ReadinessCheck::HttpGet {
|
||||||
|
port: 8080,
|
||||||
|
path: "/health".into(),
|
||||||
|
},
|
||||||
|
interval_ms: 5000,
|
||||||
|
timeout_ms: 60000,
|
||||||
|
retries: 12,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&probe).unwrap();
|
||||||
|
let parsed: ReadinessProbe = serde_json::from_str(&json).unwrap();
|
||||||
|
match parsed.check {
|
||||||
|
ReadinessCheck::HttpGet { port, path } => {
|
||||||
|
assert_eq!(port, 8080);
|
||||||
|
assert_eq!(path, "/health");
|
||||||
|
}
|
||||||
|
_ => panic!("expected HttpGet"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn readiness_probe_defaults() {
|
||||||
|
let json = r#"{"check": {"tcp_socket": 5432}}"#;
|
||||||
|
let probe: ReadinessProbe = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(probe.interval_ms, 5000);
|
||||||
|
assert_eq!(probe.timeout_ms, 60000);
|
||||||
|
assert_eq!(probe.retries, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn service_endpoint_serde() {
|
||||||
|
let ep = ServiceEndpoint {
|
||||||
|
name: "postgres".into(),
|
||||||
|
host: "postgres.wfe-abc.svc.cluster.local".into(),
|
||||||
|
ports: vec![ServicePort::tcp(5432)],
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&ep).unwrap();
|
||||||
|
let parsed: ServiceEndpoint = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.name, "postgres");
|
||||||
|
assert_eq!(parsed.host, "postgres.wfe-abc.svc.cluster.local");
|
||||||
|
assert_eq!(parsed.ports.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn service_definition_with_env() {
|
||||||
|
let svc = ServiceDefinition {
|
||||||
|
name: "postgres".into(),
|
||||||
|
image: "postgres:15".into(),
|
||||||
|
ports: vec![ServicePort::tcp(5432)],
|
||||||
|
env: [
|
||||||
|
("POSTGRES_PASSWORD".into(), "test".into()),
|
||||||
|
("POSTGRES_DB".into(), "myapp".into()),
|
||||||
|
]
|
||||||
|
.into(),
|
||||||
|
readiness: None,
|
||||||
|
command: vec![],
|
||||||
|
args: vec![],
|
||||||
|
memory: None,
|
||||||
|
cpu: None,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&svc).unwrap();
|
||||||
|
let parsed: ServiceDefinition = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.env.get("POSTGRES_PASSWORD"), Some(&"test".into()));
|
||||||
|
assert_eq!(parsed.env.get("POSTGRES_DB"), Some(&"myapp".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn service_definition_with_command() {
|
||||||
|
let svc = ServiceDefinition {
|
||||||
|
name: "custom".into(),
|
||||||
|
image: "myimage:latest".into(),
|
||||||
|
ports: vec![],
|
||||||
|
env: HashMap::new(),
|
||||||
|
readiness: None,
|
||||||
|
command: vec!["/usr/bin/myserver".into()],
|
||||||
|
args: vec!["--config".into(), "/etc/config.yaml".into()],
|
||||||
|
memory: None,
|
||||||
|
cpu: None,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&svc).unwrap();
|
||||||
|
let parsed: ServiceDefinition = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(parsed.command, vec!["/usr/bin/myserver"]);
|
||||||
|
assert_eq!(parsed.args, vec!["--config", "/etc/config.yaml"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use super::condition::StepCondition;
|
use super::condition::StepCondition;
|
||||||
use super::error_behavior::ErrorBehavior;
|
use super::error_behavior::ErrorBehavior;
|
||||||
|
use super::service::ServiceDefinition;
|
||||||
|
|
||||||
/// A compiled workflow definition ready for execution.
|
/// A compiled workflow definition ready for execution.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -15,6 +16,9 @@ pub struct WorkflowDefinition {
|
|||||||
pub default_error_behavior: ErrorBehavior,
|
pub default_error_behavior: ErrorBehavior,
|
||||||
#[serde(default, with = "super::option_duration_millis")]
|
#[serde(default, with = "super::option_duration_millis")]
|
||||||
pub default_error_retry_interval: Option<Duration>,
|
pub default_error_retry_interval: Option<Duration>,
|
||||||
|
/// Infrastructure services required by this workflow (databases, caches, etc.).
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub services: Vec<ServiceDefinition>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkflowDefinition {
|
impl WorkflowDefinition {
|
||||||
@@ -26,6 +30,7 @@ impl WorkflowDefinition {
|
|||||||
steps: Vec::new(),
|
steps: Vec::new(),
|
||||||
default_error_behavior: ErrorBehavior::default(),
|
default_error_behavior: ErrorBehavior::default(),
|
||||||
default_error_retry_interval: None,
|
default_error_retry_interval: None,
|
||||||
|
services: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod lifecycle;
|
pub mod lifecycle;
|
||||||
pub mod lock;
|
pub mod lock;
|
||||||
|
pub mod service;
|
||||||
pub mod log_sink;
|
pub mod log_sink;
|
||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
pub mod persistence;
|
pub mod persistence;
|
||||||
@@ -19,4 +20,5 @@ pub use persistence::{
|
|||||||
pub use queue::QueueProvider;
|
pub use queue::QueueProvider;
|
||||||
pub use registry::WorkflowRegistry;
|
pub use registry::WorkflowRegistry;
|
||||||
pub use search::{Page, SearchFilter, SearchIndex, WorkflowSearchResult};
|
pub use search::{Page, SearchFilter, SearchIndex, WorkflowSearchResult};
|
||||||
|
pub use service::ServiceProvider;
|
||||||
pub use step::{HostContext, StepBody, StepExecutionContext, WorkflowData};
|
pub use step::{HostContext, StepBody, StepExecutionContext, WorkflowData};
|
||||||
|
|||||||
31
wfe-core/src/traits/service.rs
Normal file
31
wfe-core/src/traits/service.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use async_trait::async_trait;
|
||||||
|
|
||||||
|
use crate::models::service::{ServiceDefinition, ServiceEndpoint};
|
||||||
|
|
||||||
|
/// Provides infrastructure services (databases, caches, etc.) for workflows.
|
||||||
|
///
|
||||||
|
/// Implementations handle provisioning, readiness checking, and teardown
|
||||||
|
/// of services on a specific platform (Kubernetes, containerd, etc.).
|
||||||
|
#[async_trait]
|
||||||
|
pub trait ServiceProvider: Send + Sync {
|
||||||
|
/// Check if this provider can provision the given services.
|
||||||
|
///
|
||||||
|
/// Returns false if any service cannot be handled by this provider.
|
||||||
|
fn can_provision(&self, services: &[ServiceDefinition]) -> bool;
|
||||||
|
|
||||||
|
/// Provision all services for a workflow.
|
||||||
|
///
|
||||||
|
/// Creates the service containers/pods, waits for them to be ready,
|
||||||
|
/// and returns endpoint information for each service.
|
||||||
|
async fn provision(
|
||||||
|
&self,
|
||||||
|
workflow_id: &str,
|
||||||
|
services: &[ServiceDefinition],
|
||||||
|
) -> crate::Result<Vec<ServiceEndpoint>>;
|
||||||
|
|
||||||
|
/// Tear down all services for a workflow.
|
||||||
|
///
|
||||||
|
/// Called on workflow completion, failure, or termination.
|
||||||
|
/// Should be idempotent -- safe to call multiple times.
|
||||||
|
async fn teardown(&self, workflow_id: &str) -> crate::Result<()>;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user