Skip to main content

orca_core/config/
service.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::types::{
6    DeployStrategy, PlacementConstraint, PullPolicy, Replicas, ResourceLimits, RuntimeKind,
7    VolumeSpec,
8};
9
10/// Probe configuration for readiness/liveness checks.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ProbeConfig {
13    /// HTTP path to probe (e.g., "/healthz").
14    pub path: String,
15    /// Port to probe (defaults to service port).
16    pub port: Option<u16>,
17    /// Seconds between probes (default: 10).
18    #[serde(default = "default_probe_interval")]
19    pub interval_secs: u64,
20    /// Seconds to wait for response (default: 3).
21    #[serde(default = "default_probe_timeout")]
22    pub timeout_secs: u64,
23    /// Failures before action (default: 3).
24    #[serde(default = "default_probe_failures")]
25    pub failure_threshold: u32,
26    /// Seconds to wait before first probe (default: 5).
27    #[serde(default = "default_initial_delay")]
28    pub initial_delay_secs: u64,
29}
30
31fn default_probe_interval() -> u64 {
32    10
33}
34fn default_probe_timeout() -> u64 {
35    3
36}
37fn default_probe_failures() -> u32 {
38    3
39}
40fn default_initial_delay() -> u64 {
41    5
42}
43
44/// Build-from-source configuration for a service.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct BuildConfig {
47    /// Git repository URL (SSH or HTTPS).
48    pub repo: String,
49    /// Branch to build from (default: "main").
50    pub branch: Option<String>,
51    /// Dockerfile path relative to repo root (default: "Dockerfile").
52    pub dockerfile: Option<String>,
53    /// Build context relative to repo root (default: ".").
54    pub context: Option<String>,
55}
56
57impl BuildConfig {
58    /// Branch to use, defaulting to "main".
59    pub fn branch_or_default(&self) -> &str {
60        self.branch.as_deref().unwrap_or("main")
61    }
62
63    /// Dockerfile path, defaulting to "Dockerfile".
64    pub fn dockerfile_or_default(&self) -> &str {
65        self.dockerfile.as_deref().unwrap_or("Dockerfile")
66    }
67
68    /// Build context, defaulting to ".".
69    pub fn context_or_default(&self) -> &str {
70        self.context.as_deref().unwrap_or(".")
71    }
72}
73
74/// Services configuration (`services.toml`).
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ServicesConfig {
77    pub service: Vec<ServiceConfig>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct ServiceConfig {
82    pub name: String,
83    /// Project name (set automatically from directory name by load_dir).
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub project: Option<String>,
86    #[serde(default)]
87    pub runtime: RuntimeKind,
88    /// Container image (for container runtime).
89    pub image: Option<String>,
90    /// Wasm module path or OCI reference (for wasm runtime).
91    pub module: Option<String>,
92    #[serde(default)]
93    pub replicas: Replicas,
94    /// Container port (internal).
95    pub port: Option<u16>,
96    /// Host port to bind (e.g., 443 for edge proxies). If omitted, ephemeral.
97    pub host_port: Option<u16>,
98    /// Domain for reverse proxy routing (orca proxy handles TLS).
99    pub domain: Option<String>,
100    /// Path routes under the domain (e.g., ["/api/*", "/admin/*"]).
101    /// Default: ["/*"] (catch-all).
102    #[serde(default)]
103    pub routes: Vec<String>,
104    /// Health check path (e.g., "/healthz"). Legacy shorthand for liveness probe.
105    pub health: Option<String>,
106    /// Readiness probe: container must pass before receiving traffic.
107    pub readiness: Option<ProbeConfig>,
108    /// Liveness probe: container is restarted if this fails.
109    pub liveness: Option<ProbeConfig>,
110    #[serde(default)]
111    pub env: HashMap<String, String>,
112    pub resources: Option<ResourceLimits>,
113    pub volume: Option<VolumeSpec>,
114    pub deploy: Option<DeployStrategy>,
115    pub placement: Option<PlacementConstraint>,
116    /// Docker network name. Services with the same network can reach each other.
117    /// Auto-prefixed with "orca-". If omitted, derived from service name prefix.
118    pub network: Option<String>,
119    /// Network aliases (resolvable names within the Docker network).
120    #[serde(default)]
121    pub aliases: Vec<String>,
122    /// Host bind mounts (e.g., ["/host/path:/container/path:ro"]).
123    #[serde(default)]
124    pub mounts: Vec<String>,
125    /// Wasm triggers: "http:/path", "cron:expr", "queue:topic", "event:pattern"
126    #[serde(default)]
127    pub triggers: Vec<String>,
128    /// Static assets directory (for builtin:static-server Wasm module).
129    pub assets: Option<String>,
130    /// Build configuration: clone a repo and build a Docker image from a Dockerfile.
131    /// When set, `image` is not required — the built image is used instead.
132    pub build: Option<BuildConfig>,
133    /// Path to PEM certificate file for BYO TLS (skips ACME provisioning).
134    pub tls_cert: Option<String>,
135    /// Path to PEM private key file for BYO TLS.
136    pub tls_key: Option<String>,
137    /// Join the shared `orca-internal` network for cross-service communication.
138    #[serde(default)]
139    pub internal: bool,
140    /// Services that must be running before this service starts.
141    #[serde(default)]
142    pub depends_on: Vec<String>,
143    /// Command to run in the container (overrides image CMD).
144    #[serde(default)]
145    pub cmd: Vec<String>,
146    /// Extra fixed host:container port bindings (e.g. ["22222:22"]).
147    #[serde(default)]
148    pub extra_ports: Vec<String>,
149    /// Prefix to strip from incoming request paths before forwarding to
150    /// this service. Used with `routes` to mount a backend under a
151    /// subpath without exposing that subpath to the backend itself —
152    /// e.g. `routes = ["/admin/*"]`, `strip_prefix = "/admin"` sends
153    /// `/admin/users` upstream as `/users`.
154    #[serde(default)]
155    pub strip_prefix: Option<String>,
156    /// Image pull policy: auto (default), always, never, if-not-present.
157    #[serde(default)]
158    pub pull_policy: PullPolicy,
159}
160
161impl ServiceConfig {
162    /// Returns `true` if the deployment-relevant fields of two configs match.
163    ///
164    /// Used by the reconciler to decide whether a running service needs to
165    /// be recreated. Fields like `name`, `project`, `replicas` are handled
166    /// separately by the reconciler and are NOT compared here.
167    pub fn spec_matches(&self, other: &Self) -> bool {
168        self.image == other.image
169            && self.module == other.module
170            && self.env == other.env
171            && self.cmd == other.cmd
172            && self.port == other.port
173            && self.host_port == other.host_port
174            && self.domain == other.domain
175            && self.routes == other.routes
176            && self.volume == other.volume
177            && self.mounts == other.mounts
178            && self.aliases == other.aliases
179            && self.extra_ports == other.extra_ports
180            && self.strip_prefix == other.strip_prefix
181            && self.network == other.network
182            && self.internal == other.internal
183            && self.health == other.health
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    fn base_config() -> ServiceConfig {
192        ServiceConfig {
193            name: "test-svc".into(),
194            project: None,
195            runtime: RuntimeKind::Container,
196            image: Some("nginx:latest".into()),
197            module: None,
198            replicas: Replicas::Fixed(1),
199            port: Some(80),
200            host_port: None,
201            domain: Some("test.example.com".into()),
202            routes: vec!["/*".into()],
203            health: Some("/healthz".into()),
204            readiness: None,
205            liveness: None,
206            env: HashMap::from([("KEY".into(), "val".into())]),
207            resources: None,
208            volume: None,
209            deploy: None,
210            placement: None,
211            network: Some("web".into()),
212            aliases: vec!["test".into()],
213            mounts: vec!["/host:/container".into()],
214            triggers: vec![],
215            assets: None,
216            build: None,
217            tls_cert: None,
218            tls_key: None,
219            internal: false,
220            depends_on: vec![],
221            cmd: vec![],
222            extra_ports: vec!["8080:80".into()],
223            strip_prefix: Some("/api".into()),
224            pull_policy: Default::default(),
225        }
226    }
227
228    #[test]
229    fn identical_configs_match() {
230        let a = base_config();
231        let b = base_config();
232        assert!(a.spec_matches(&b));
233    }
234
235    #[test]
236    fn image_change_detected() {
237        let a = base_config();
238        let mut b = base_config();
239        b.image = Some("nginx:1.27".into());
240        assert!(!a.spec_matches(&b));
241    }
242
243    #[test]
244    fn env_change_detected() {
245        let a = base_config();
246        let mut b = base_config();
247        b.env.insert("NEW_KEY".into(), "new_val".into());
248        assert!(!a.spec_matches(&b));
249    }
250
251    #[test]
252    fn extra_ports_change_detected() {
253        let a = base_config();
254        let mut b = base_config();
255        b.extra_ports = vec!["9090:90".into()];
256        assert!(!a.spec_matches(&b));
257    }
258
259    #[test]
260    fn mounts_change_detected() {
261        let a = base_config();
262        let mut b = base_config();
263        b.mounts.push("/extra:/path".into());
264        assert!(!a.spec_matches(&b));
265    }
266
267    #[test]
268    fn volume_change_detected() {
269        let a = base_config();
270        let mut b = base_config();
271        b.volume = Some(crate::types::VolumeSpec {
272            path: "/data".into(),
273            size: None,
274        });
275        assert!(!a.spec_matches(&b));
276    }
277
278    #[test]
279    fn domain_change_detected() {
280        let a = base_config();
281        let mut b = base_config();
282        b.domain = Some("new.example.com".into());
283        assert!(!a.spec_matches(&b));
284    }
285
286    #[test]
287    fn aliases_change_detected() {
288        let a = base_config();
289        let mut b = base_config();
290        b.aliases.push("new-alias".into());
291        assert!(!a.spec_matches(&b));
292    }
293
294    #[test]
295    fn strip_prefix_change_detected() {
296        let a = base_config();
297        let mut b = base_config();
298        b.strip_prefix = None;
299        assert!(!a.spec_matches(&b));
300    }
301
302    #[test]
303    fn network_change_detected() {
304        let a = base_config();
305        let mut b = base_config();
306        b.network = Some("internal".into());
307        assert!(!a.spec_matches(&b));
308    }
309
310    #[test]
311    fn internal_flag_change_detected() {
312        let a = base_config();
313        let mut b = base_config();
314        b.internal = true;
315        assert!(!a.spec_matches(&b));
316    }
317
318    #[test]
319    fn port_change_detected() {
320        let a = base_config();
321        let mut b = base_config();
322        b.port = Some(8080);
323        assert!(!a.spec_matches(&b));
324    }
325
326    #[test]
327    fn cmd_change_detected() {
328        let a = base_config();
329        let mut b = base_config();
330        b.cmd = vec!["--debug".into()];
331        assert!(!a.spec_matches(&b));
332    }
333
334    #[test]
335    fn non_spec_fields_ignored() {
336        let a = base_config();
337        let mut b = base_config();
338        // These changes should NOT trigger a recreate
339        b.name = "different-name".into();
340        b.project = Some("other-project".into());
341        b.replicas = Replicas::Fixed(5);
342        assert!(a.spec_matches(&b));
343    }
344
345    #[test]
346    fn unresolved_secret_templates_match() {
347        let mut a = base_config();
348        let mut b = base_config();
349        // Both have the same unresolved template — should match
350        a.env.insert("TOKEN".into(), "${secrets.MY_TOKEN}".into());
351        b.env.insert("TOKEN".into(), "${secrets.MY_TOKEN}".into());
352        assert!(a.spec_matches(&b));
353    }
354
355    #[test]
356    fn resolved_vs_unresolved_differs() {
357        let mut a = base_config();
358        let mut b = base_config();
359        // a has the template, b has a resolved value — should NOT match
360        a.env.insert("TOKEN".into(), "${secrets.MY_TOKEN}".into());
361        b.env
362            .insert("TOKEN".into(), "actual-secret-value-123".into());
363        assert!(!a.spec_matches(&b));
364    }
365}