greentic-start-dev 1.1.26283552880

Greentic lifecycle runner for start/restart/stop orchestration
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
//! Warm/ready health-gate consumer (B9b of `plans/next-gen-deployment.md`).
//!
//! B9a shipped the seam in `greentic-deployer`:
//! [`HealthCheckId`](greentic_deployer::environment::HealthCheckId),
//! [`HealthGateFailure`](greentic_deployer::environment::HealthGateFailure),
//! [`apply_revision_transition_with_health_gate`](greentic_deployer::environment::apply_revision_transition_with_health_gate),
//! and the [`warm_with_health_gate`](greentic_deployer::cli::revisions::warm_with_health_gate)
//! adapter that accepts a `FnOnce(&Environment, &Revision) -> Result<(),
//! HealthGateFailure>` closure.
//!
//! B9b is the consumer in `greentic-start`. The closure-friendly seam in
//! the deployer is wrapped here as a [`RevisionHealthGate`] trait so a
//! Phase-D operator HTTP handler can hold one `Arc<dyn RevisionHealthGate>`
//! and use it across many warm requests. Producer wiring (operator
//! `/deployments/warm` handler → `Arc<dyn RevisionHealthGate>` → closure
//! adapter passed to `warm_with_health_gate`) is Phase D — this module
//! ships the trait, the no-op default for tests, and a real
//! [`StartRevisionHealthGate`] that exercises the checks whose producers
//! land at or before B9 (runtime-config loader from B0; signature_sidecar
//! _ref existence as a placeholder for Phase-C2 DSSE verification).
//!
//! The remaining two checks — `RouteTable` and `ProviderHealth` — are
//! Phase-D `Ok(())` stubs in [`StartRevisionHealthGate`]. The
//! static-route validator (B8) currently lives behind `pub(crate)` in
//! `greentic-operator`; provider health probes need a real runner runtime.
//! Both will move out of stub status when their producers land.
//!
//! ## Scaffold-ahead-of-producer
//!
//! Same shape as B0/B1/B2/B3/B7a/B8: this module ships a typed seam +
//! tests + a real impl, but no caller in greentic-start invokes the gate
//! yet — the production call site lives behind the operator's Phase-D
//! HTTP wiring. The deployer's CLI `warm` (the path operators hit via
//! `gtc op revisions warm`) still funnels through a noop gate today,
//! preserving Phase-A/B behavior for non-HTTP callers until Phase-D
//! flips the operator handler to `warm_with_health_gate(..., gate)`.

use std::path::PathBuf;

use greentic_deploy_spec::{Environment, Revision};
use greentic_deployer::environment::{HealthCheckId, HealthGateFailure};

/// A gate that decides whether a revision is ready to transition to
/// `Ready` (or to be admitted to live traffic on apply-time gates).
///
/// Implementors run their checks against the post-chain `(env, revision)`
/// view passed by the lifecycle helper — see
/// [`apply_revision_transition_with_health_gate`](greentic_deployer::environment::apply_revision_transition_with_health_gate)
/// for the exact contract. Multiple check failures should be aggregated
/// into a single [`HealthGateFailure`] so the operator sees every reason
/// the warm was rejected, not just the first.
///
/// `Send + Sync` so a Phase-D operator handler can hold `Arc<dyn
/// RevisionHealthGate>` across `tokio::spawn_blocking` calls into the
/// deployer's blocking CLI verbs.
pub trait RevisionHealthGate: Send + Sync {
    fn check(&self, env: &Environment, revision: &Revision) -> Result<(), HealthGateFailure>;
}

/// Always-pass gate. The deployer's `warm` CLI (no `_with_health_gate`
/// variant) uses an inline `|_, _| Ok(())` closure equivalent to this
/// impl; this struct is the trait-shape analog, used by tests and as the
/// default when a producer has not wired a real gate.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopRevisionHealthGate;

impl RevisionHealthGate for NoopRevisionHealthGate {
    fn check(&self, _env: &Environment, _revision: &Revision) -> Result<(), HealthGateFailure> {
        Ok(())
    }
}

/// Default greentic-start health gate.
///
/// Runs the four B9 checks; today only two have producers:
/// - [`HealthCheckId::RuntimeConfig`] — if `<env_root>/<env_id>/runtime-
///   config.json` exists, it must load and validate through the B0 loader
///   ([`crate::runtime_config::load_in`]). Absent runtime-config is OK
///   (the common case until Phase-D producers materialize the file).
/// - [`HealthCheckId::SignatureStatus`] — `<env_root>/<env_id>/<revision.
///   signature_sidecar_ref>` must exist on disk. Phase-C2 will replace
///   this existence check with real DSSE verification.
/// - [`HealthCheckId::RouteTable`] — Phase-D stub (always Ok). The
///   static-route validator (B8) lives behind `pub(crate)` in
///   `greentic-operator`; lifting it across the crate boundary belongs
///   to the producer gate.
/// - [`HealthCheckId::ProviderHealth`] — Phase-D stub (always Ok). Real
///   probes need a provider runner + network IO.
///
/// All failures are aggregated into one `HealthGateFailure` so the
/// operator sees every reason a warm was rejected, not just the first.
#[derive(Debug, Clone)]
pub struct StartRevisionHealthGate {
    env_root: PathBuf,
}

impl StartRevisionHealthGate {
    /// Construct a gate rooted at `env_root`. Each `env_id` resolves to
    /// `<env_root>/<env_id>` for runtime-config + signature lookups.
    pub fn new(env_root: PathBuf) -> Self {
        Self { env_root }
    }

    /// Construct a gate rooted at the operator's default store root
    /// (`~/.greentic/environments/` on POSIX). Returns an error if the
    /// process has no resolvable home directory.
    pub fn default_root() -> anyhow::Result<Self> {
        let env_root =
            greentic_deployer::environment::LocalFsStore::default_root().ok_or_else(|| {
                anyhow::anyhow!(
                    "cannot determine the default environment store root (no home directory)"
                )
            })?;
        Ok(Self { env_root })
    }
}

impl RevisionHealthGate for StartRevisionHealthGate {
    fn check(&self, env: &Environment, revision: &Revision) -> Result<(), HealthGateFailure> {
        let mut failed_checks: Vec<HealthCheckId> = Vec::new();
        let mut messages: Vec<String> = Vec::new();

        let env_id = env.environment_id.as_str();

        // 1. Runtime config (B0): if the file is present, it must load.
        match crate::runtime_config::load_in(&self.env_root, env_id) {
            Ok(_) => {}
            Err(e) => {
                failed_checks.push(HealthCheckId::RuntimeConfig);
                messages.push(format!("runtime-config load failed: {e:#}"));
            }
        }

        // 2. Signature status (placeholder for Phase-C2 DSSE verify):
        // `signature_sidecar_ref` is an env-relative path that MUST resolve
        // to a regular file CONTAINED in the environment directory. The
        // ref is a plain `PathBuf` copied verbatim from the stage payload
        // (deploy-spec only schema-validates it), so an absolute ref, a
        // `..` escape, or a symlink escape could otherwise satisfy this
        // check with a file outside the store. `normalize_under_root`
        // rejects absolute paths and canonicalizes both sides before a
        // `starts_with` containment test — canonicalization resolves
        // symlinks, so escape-via-symlink is caught too. Future DSSE
        // verification slots in after this resolution.
        //
        // The containment root is resolved through `env_dir_in` (NOT a raw
        // `self.env_root.join(env_id)`) so it shares the same `.`/`..`
        // reject + charset validation the RuntimeConfig check gets via
        // `load_in`. `validate_identifier` permits all-dot ids, so a `..`
        // env id would otherwise widen the containment root to the parent
        // of `env_root` for this check alone.
        match crate::runtime_config::env_dir_in(&self.env_root, env_id) {
            Ok(env_dir) => match greentic_deployer::path_safety::normalize_under_root(
                &env_dir,
                &revision.signature_sidecar_ref,
            ) {
                // `normalize_under_root` returns Ok only when the path
                // canonicalizes (i.e. exists) and stays under `env_dir`;
                // the residual `is_file` guard rejects a contained directory.
                Ok(sig_path) if sig_path.is_file() => {}
                Ok(sig_path) => {
                    failed_checks.push(HealthCheckId::SignatureStatus);
                    messages.push(format!(
                        "signature_sidecar_ref resolves to `{}`, which is not a regular file",
                        sig_path.display()
                    ));
                }
                Err(e) => {
                    failed_checks.push(HealthCheckId::SignatureStatus);
                    messages.push(format!(
                        "signature_sidecar_ref `{}` is not a contained, existing sidecar: {e:#}",
                        revision.signature_sidecar_ref.display()
                    ));
                }
            },
            Err(e) => {
                failed_checks.push(HealthCheckId::SignatureStatus);
                messages.push(format!(
                    "environment id `{env_id}` is not a safe directory segment: {e:#}"
                ));
            }
        }

        // 3. Route table — Phase-D stub.
        // 4. Provider health — Phase-D stub.

        if failed_checks.is_empty() {
            Ok(())
        } else {
            Err(HealthGateFailure {
                failed_checks,
                message: messages.join("; "),
            })
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::{TimeZone, Utc};
    use greentic_deploy_spec::{
        BundleId, DeploymentId, EnvId, EnvironmentHostConfig, PackId, PackListEntry, Revision,
        RevisionId, RevisionLifecycle, SchemaVersion, SemVer,
    };
    use tempfile::TempDir;

    const ENV_ID: &str = "local";

    fn fixed_now() -> chrono::DateTime<Utc> {
        Utc.with_ymd_and_hms(2026, 5, 22, 12, 0, 0).unwrap()
    }

    fn env_id() -> EnvId {
        EnvId::try_from(ENV_ID).unwrap()
    }

    fn make_env() -> Environment {
        Environment {
            schema: SchemaVersion::new(SchemaVersion::ENVIRONMENT_V1),
            environment_id: env_id(),
            name: ENV_ID.to_string(),
            host_config: EnvironmentHostConfig {
                env_id: env_id(),
                region: None,
                tenant_org_id: None,
            },
            packs: Vec::new(),
            credentials_ref: None,
            bundles: Vec::new(),
            revisions: Vec::new(),
            traffic_splits: Vec::new(),
            revocation: Default::default(),
            retention: Default::default(),
            health: Default::default(),
        }
    }

    fn make_revision(sig_ref: PathBuf) -> Revision {
        Revision {
            schema: SchemaVersion::new(SchemaVersion::REVISION_V1),
            revision_id: RevisionId::new(),
            env_id: env_id(),
            bundle_id: BundleId::new("fast2flow"),
            deployment_id: DeploymentId::new(),
            sequence: 1,
            created_at: fixed_now(),
            bundle_digest: "sha256:00".to_string(),
            pack_list: vec![PackListEntry {
                pack_id: PackId::new("greentic.test.pack"),
                version: SemVer::new(1, 0, 0),
                digest: "sha256:00".to_string(),
                source_uri: None,
            }],
            pack_list_lock_ref: PathBuf::from("pack-list.lock"),
            config_digest: "sha256:00".to_string(),
            signature_sidecar_ref: sig_ref,
            lifecycle: RevisionLifecycle::Warming,
            staged_at: Some(fixed_now()),
            warmed_at: None,
            drain_seconds: 30,
            abort_metrics: Vec::new(),
        }
    }

    /// Seeds `<tmp>/local/` with the directory layout the gate inspects.
    /// `sig_present == true` materializes the signature sidecar file at
    /// the relative path `rev.sig` (matches `seed_one_revision`'s default
    /// in the deployer's lifecycle tests).
    fn seed_env(sig_present: bool) -> (TempDir, PathBuf, Revision) {
        let tmp = tempfile::tempdir().unwrap();
        let env_dir = tmp.path().join(ENV_ID);
        std::fs::create_dir_all(&env_dir).unwrap();
        let sig_ref = PathBuf::from("rev.sig");
        if sig_present {
            std::fs::write(env_dir.join(&sig_ref), b"placeholder").unwrap();
        }
        let revision = make_revision(sig_ref);
        let env_root = tmp.path().to_path_buf();
        (tmp, env_root, revision)
    }

    #[test]
    fn noop_always_passes() {
        let (_tmp, _env_root, revision) = seed_env(false);
        let env = make_env();
        let gate = NoopRevisionHealthGate;
        assert!(gate.check(&env, &revision).is_ok());
    }

    #[test]
    fn start_passes_when_signature_present_and_no_runtime_config() {
        let (_tmp, env_root, revision) = seed_env(true);
        let env = make_env();
        let gate = StartRevisionHealthGate::new(env_root);
        let result = gate.check(&env, &revision);
        assert!(result.is_ok(), "expected pass, got `{:?}`", result.err());
    }

    #[test]
    fn start_fails_signature_status_when_sidecar_missing() {
        let (_tmp, env_root, revision) = seed_env(false);
        let env = make_env();
        let gate = StartRevisionHealthGate::new(env_root);
        let err = gate.check(&env, &revision).unwrap_err();
        assert_eq!(err.failed_checks, vec![HealthCheckId::SignatureStatus]);
        assert!(
            err.message.contains("signature_sidecar_ref"),
            "msg: {}",
            err.message
        );
    }

    #[test]
    fn start_fails_runtime_config_when_file_malformed() {
        let (_tmp, env_root, revision) = seed_env(true);
        // Drop a malformed runtime-config.json into the env dir.
        let env_dir = env_root.join(ENV_ID);
        std::fs::write(
            env_dir.join("runtime-config.json"),
            b"{not even close to json",
        )
        .unwrap();
        let env = make_env();
        let gate = StartRevisionHealthGate::new(env_root);
        let err = gate.check(&env, &revision).unwrap_err();
        assert_eq!(err.failed_checks, vec![HealthCheckId::RuntimeConfig]);
        assert!(
            err.message.contains("runtime-config load failed"),
            "msg: {}",
            err.message
        );
    }

    #[test]
    fn start_aggregates_multiple_failures() {
        let (_tmp, env_root, revision) = seed_env(false);
        let env_dir = env_root.join(ENV_ID);
        std::fs::write(env_dir.join("runtime-config.json"), b"{bad").unwrap();
        let env = make_env();
        let gate = StartRevisionHealthGate::new(env_root);
        let err = gate.check(&env, &revision).unwrap_err();
        // Order: RuntimeConfig check runs first, then SignatureStatus.
        assert_eq!(
            err.failed_checks,
            vec![HealthCheckId::RuntimeConfig, HealthCheckId::SignatureStatus]
        );
        assert!(err.message.contains("runtime-config load failed"));
        assert!(err.message.contains("signature_sidecar_ref"));
        // Joiner is "; " so the message reads as two clauses.
        assert!(err.message.contains("; "));
    }

    /// Codex adversarial finding (high): an ABSOLUTE `signature_sidecar_ref`
    /// must not satisfy the check — `join` would otherwise discard `env_dir`
    /// and resolve the absolute path directly, accepting any existing file
    /// outside the store.
    #[test]
    fn start_fails_signature_status_on_absolute_sidecar_ref() {
        let (_tmp, env_root, mut revision) = seed_env(true);
        // Point at a real, existing absolute file outside the env dir.
        let outside = tempfile::NamedTempFile::new().unwrap();
        assert!(outside.path().is_absolute());
        revision.signature_sidecar_ref = outside.path().to_path_buf();
        let env = make_env();
        let gate = StartRevisionHealthGate::new(env_root);
        let err = gate.check(&env, &revision).unwrap_err();
        assert!(
            err.failed_checks.contains(&HealthCheckId::SignatureStatus),
            "expected SignatureStatus failure, got {:?}",
            err.failed_checks
        );
    }

    /// Codex adversarial finding (high): a `..` parent-traversal
    /// `signature_sidecar_ref` must not satisfy the check even though the
    /// target file exists — it lives outside the environment directory.
    #[test]
    fn start_fails_signature_status_on_parent_traversal_sidecar_ref() {
        let (_tmp, env_root, mut revision) = seed_env(true);
        // A real file in env_root (the PARENT of env_dir = env_root/local),
        // reachable from env_dir only via `..`.
        std::fs::write(env_root.join("outside.sig"), b"escaped").unwrap();
        revision.signature_sidecar_ref = PathBuf::from("../outside.sig");
        let env = make_env();
        let gate = StartRevisionHealthGate::new(env_root);
        let err = gate.check(&env, &revision).unwrap_err();
        assert!(
            err.failed_checks.contains(&HealthCheckId::SignatureStatus),
            "expected SignatureStatus failure, got {:?}",
            err.failed_checks
        );
    }

    /// Codex adversarial finding (high): a SYMLINK inside the env dir that
    /// points OUTSIDE the env root must not satisfy the check.
    /// `normalize_under_root` canonicalizes (resolving the symlink) before
    /// the containment test, so the escape is caught.
    #[cfg(unix)]
    #[test]
    fn start_fails_signature_status_on_symlink_escape() {
        let (_tmp, env_root, mut revision) = seed_env(false);
        // Real sidecar-shaped file in a directory outside the env root.
        let outside_dir = tempfile::tempdir().unwrap();
        let outside_file = outside_dir.path().join("real.sig");
        std::fs::write(&outside_file, b"escaped").unwrap();
        // Symlink inside env_dir pointing at the outside file.
        let env_dir = env_root.join(ENV_ID);
        std::os::unix::fs::symlink(&outside_file, env_dir.join("rev.sig")).unwrap();
        revision.signature_sidecar_ref = PathBuf::from("rev.sig");
        let env = make_env();
        let gate = StartRevisionHealthGate::new(env_root);
        let err = gate.check(&env, &revision).unwrap_err();
        assert!(
            err.failed_checks.contains(&HealthCheckId::SignatureStatus),
            "expected SignatureStatus failure, got {:?}",
            err.failed_checks
        );
    }

    /// `/code-review` finding: a `..` (or `.`) `environment_id` must not let
    /// the signature check widen its containment root to the parent of
    /// `env_root`. `validate_identifier` permits all-dot ids, so `EnvId("..")`
    /// is constructible; the gate must reject it on BOTH env-dir-dependent
    /// checks (RuntimeConfig via `load_in`, SignatureStatus via `env_dir_in`),
    /// not lean on aggregation from a single check.
    #[test]
    fn start_rejects_dotdot_env_id_on_both_dependent_checks() {
        let (_tmp, env_root, revision) = seed_env(true);
        let mut env = make_env();
        env.environment_id = EnvId::try_from("..").unwrap();
        let gate = StartRevisionHealthGate::new(env_root);
        let err = gate.check(&env, &revision).unwrap_err();
        assert!(
            err.failed_checks.contains(&HealthCheckId::SignatureStatus),
            "signature check must reject `..` env_id, got {:?}",
            err.failed_checks
        );
        assert!(
            err.failed_checks.contains(&HealthCheckId::RuntimeConfig),
            "runtime-config check must reject `..` env_id, got {:?}",
            err.failed_checks
        );
        assert!(
            err.message.contains("not a safe directory segment"),
            "msg: {}",
            err.message
        );
    }

    /// Containment is about ESCAPE, not symlinks per se: a symlink that
    /// stays INSIDE the env dir resolves cleanly and still passes. Guards
    /// against an over-broad fix that rejects all symlinks.
    #[cfg(unix)]
    #[test]
    fn start_passes_on_contained_symlink_sidecar() {
        let (_tmp, env_root, mut revision) = seed_env(false);
        let env_dir = env_root.join(ENV_ID);
        // Real file inside the env dir + a symlink (also inside) to it.
        std::fs::write(env_dir.join("actual.sig"), b"ok").unwrap();
        std::os::unix::fs::symlink(env_dir.join("actual.sig"), env_dir.join("rev.sig")).unwrap();
        revision.signature_sidecar_ref = PathBuf::from("rev.sig");
        let env = make_env();
        let gate = StartRevisionHealthGate::new(env_root);
        assert!(
            gate.check(&env, &revision).is_ok(),
            "a symlink contained within the env dir should pass"
        );
    }

    #[test]
    fn start_passes_with_absent_runtime_config_and_present_signature() {
        // The common case today: nothing materializes runtime-config.json
        // until Phase-D producers land, but the revision's signature
        // sidecar must exist. The gate must NOT flag a missing
        // runtime-config as a failure.
        let (_tmp, env_root, revision) = seed_env(true);
        // Confirm runtime-config.json is absent in our fixture.
        assert!(!env_root.join(ENV_ID).join("runtime-config.json").exists());
        let env = make_env();
        let gate = StartRevisionHealthGate::new(env_root);
        assert!(gate.check(&env, &revision).is_ok());
    }

    /// Trait-object usage: a Phase-D operator handler holds an `Arc<dyn
    /// RevisionHealthGate>` and dispatches via dynamic call. Verify both
    /// concrete impls work behind a trait object.
    #[test]
    fn dyn_trait_object_dispatch_works_for_both_impls() {
        let (_tmp, env_root, revision) = seed_env(true);
        let env = make_env();

        let gates: Vec<std::sync::Arc<dyn RevisionHealthGate>> = vec![
            std::sync::Arc::new(NoopRevisionHealthGate),
            std::sync::Arc::new(StartRevisionHealthGate::new(env_root)),
        ];
        for g in &gates {
            assert!(g.check(&env, &revision).is_ok());
        }
    }

    /// The trait method signature matches what the deployer's
    /// `warm_with_health_gate` closure expects, so an `Arc<dyn
    /// RevisionHealthGate>` adapts to a `FnOnce(&Environment, &Revision)
    /// -> Result<(), HealthGateFailure>` via a thin closure. Compile-time
    /// smoke test confirms the wiring shape Phase-D consumers will use.
    #[test]
    fn adapts_to_warm_with_health_gate_closure_shape() {
        let (_tmp, env_root, revision) = seed_env(true);
        let env = make_env();
        let gate: std::sync::Arc<dyn RevisionHealthGate> =
            std::sync::Arc::new(StartRevisionHealthGate::new(env_root));
        // The closure Phase-D will pass to warm_with_health_gate.
        let closure = |e: &Environment, r: &Revision| gate.check(e, r);
        assert!(closure(&env, &revision).is_ok());
    }
}