use std::path::PathBuf;
pub mod bootstrap;
pub mod bundles;
pub mod config;
pub mod credentials;
pub mod dispatch;
pub mod env;
pub mod env_packs;
pub mod migrate;
pub mod migrate_state;
pub mod revisions;
pub mod secrets;
pub mod traffic;
#[cfg(test)]
mod tests_common;
use serde::Serialize;
use serde_json::Value;
use thiserror::Error;
use crate::environment::{
AuditDecision, AuditEvent, AuditLog, AuditResult, LifecycleError, LocalFsStore, StoreError,
authorize_local_only, current_local_actor,
};
use greentic_deploy_spec::{EnvId, SpecError};
#[derive(Debug, Error)]
pub enum OpError {
#[error("storage error: {0}")]
Store(#[from] StoreError),
#[error("spec validation failed: {0}")]
Spec(#[from] SpecError),
#[error("io error on {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("invalid json/yaml in {path}: {message}")]
AnswersParse { path: PathBuf, message: String },
#[error("schema generation failed: {0}")]
SchemaGeneration(String),
#[error("invalid argument: {0}")]
InvalidArgument(String),
#[error("not found: {0}")]
NotFound(String),
#[error("not yet implemented in Phase A: {0}")]
NotYetImplemented(&'static str),
#[error("conflict: {0}")]
Conflict(String),
#[error("unauthorized: {policy} — {reason}")]
Unauthorized { policy: String, reason: String },
#[error("audit log write failed after the mutation committed: {0}")]
Audit(String),
#[error("revenue policy: {0}")]
RevenuePolicy(#[from] crate::environment::BundleDeploymentError),
}
impl From<LifecycleError> for OpError {
fn from(err: LifecycleError) -> Self {
match err {
LifecycleError::NotFound {
env_id,
revision_id,
} => OpError::NotFound(format!(
"revision `{revision_id}` not found in env `{env_id}`"
)),
LifecycleError::InvalidTransition { from, to } => {
OpError::Conflict(format!("spec rejects transition `{from:?} → {to:?}`"))
}
LifecycleError::Conflict {
revision_id,
actual,
expected_starts,
} => OpError::Conflict(format!(
"revision `{revision_id}` is in `{actual:?}`; expected one of {expected_starts:?}"
)),
LifecycleError::EmptyChain => {
OpError::InvalidArgument("empty transition chain".to_string())
}
LifecycleError::ActiveTrafficReference {
revision_id,
splits,
} => {
let detail = splits
.iter()
.map(|s| {
format!(
"deployment `{}` / bundle `{}` ({}bps)",
s.deployment_id, s.bundle_id, s.weight_bps
)
})
.collect::<Vec<_>>()
.join(", ");
OpError::Conflict(format!(
"revision `{revision_id}` is still referenced by live traffic split(s): [{detail}]; rebalance via `gtc op traffic set` before archiving"
))
}
LifecycleError::HealthGateFailed {
revision_id,
failed_checks,
message,
} => {
let checks = if failed_checks.is_empty() {
String::from("none reported")
} else {
failed_checks
.iter()
.map(|c| format!("{c:?}"))
.collect::<Vec<_>>()
.join(", ")
};
OpError::Conflict(format!(
"revision `{revision_id}` failed warm/ready health gate (checks: [{checks}]): {message}"
))
}
LifecycleError::Store(source) => OpError::Store(source),
}
}
}
impl OpError {
pub fn kind(&self) -> &'static str {
match self {
OpError::Store(_) => "store",
OpError::Spec(_) => "spec",
OpError::Io { .. } => "io",
OpError::AnswersParse { .. } => "answers-parse",
OpError::SchemaGeneration(_) => "schema-generation",
OpError::InvalidArgument(_) => "invalid-argument",
OpError::NotFound(_) => "not-found",
OpError::NotYetImplemented(_) => "not-yet-implemented",
OpError::Conflict(_) => "conflict",
OpError::Unauthorized { .. } => "unauthorized",
OpError::Audit(_) => "audit",
OpError::RevenuePolicy(_) => "revenue-policy",
}
}
}
#[derive(Debug)]
pub(crate) struct AuditCtx {
pub env_id: EnvId,
pub noun: &'static str,
pub verb: &'static str,
pub target: Value,
pub idempotency_key: Option<String>,
}
pub(crate) struct CommitMarker(std::cell::Cell<bool>);
impl CommitMarker {
pub(crate) fn new() -> Self {
Self(std::cell::Cell::new(false))
}
pub fn mark_committed(&self) {
self.0.set(true);
}
pub(crate) fn is_committed(&self) -> bool {
self.0.get()
}
}
#[derive(Debug, Default, Clone, Copy)]
pub(crate) struct AuditGens {
pub previous: Option<u64>,
pub new: Option<u64>,
}
impl AuditGens {
pub const NONE: Self = Self {
previous: None,
new: None,
};
}
pub(crate) fn audit_and_record<F>(
store: &LocalFsStore,
ctx: AuditCtx,
mutate: F,
) -> Result<OpOutcome, OpError>
where
F: FnOnce(&CommitMarker) -> Result<(OpOutcome, AuditGens), OpError>,
{
let decision = authorize_local_only(&ctx.env_id);
let commit_marker = CommitMarker::new();
let (result, gens) = match &decision {
AuditDecision::Deny { policy, reason } => (
Err(OpError::Unauthorized {
policy: policy.clone(),
reason: reason.clone(),
}),
AuditGens::default(),
),
AuditDecision::Allow { .. } => match mutate(&commit_marker) {
Ok((outcome, g)) => (Ok(outcome), g),
Err(err) => (Err(err), AuditGens::default()),
},
};
let committed = result.is_ok() || commit_marker.is_committed();
let audit_result = match &result {
Ok(_) => AuditResult::Ok,
Err(OpError::NotYetImplemented(detail)) => AuditResult::NotYetImplemented {
detail: (*detail).to_string(),
},
Err(err) => AuditResult::Error {
kind: err.kind().to_string(),
message: err.to_string(),
},
};
let event = AuditEvent {
schema: crate::environment::AUDIT_EVENT_SCHEMA_V1.into(),
event_id: ulid::Ulid::new().to_string(),
ts: chrono::Utc::now(),
actor: current_local_actor(),
env_id: ctx.env_id.as_str().to_string(),
noun: ctx.noun.to_string(),
verb: ctx.verb.to_string(),
target: ctx.target,
previous_generation: gens.previous,
new_generation: gens.new,
idempotency_key: ctx.idempotency_key,
authorization: decision,
result: audit_result,
};
let append_outcome = AuditLog::for_env(store, &ctx.env_id).and_then(|log| log.append(&event));
if let Err(e) = append_outcome {
if committed {
return Err(OpError::Audit(format!(
"{e} (event_id={}, {}.{} on env `{}`)",
event.event_id, event.noun, event.verb, event.env_id
)));
}
tracing::warn!(
target: "greentic.audit",
error = %e,
event_id = %event.event_id,
"failed to append audit event for a non-committing op; continuing with op result"
);
}
result
}
#[derive(Debug, Clone, Default)]
pub struct OpFlags {
pub schema_only: bool,
pub answers: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize)]
pub struct OpOutcome {
pub op: &'static str,
pub noun: &'static str,
pub result: Value,
}
impl OpOutcome {
pub fn new(noun: &'static str, op: &'static str, result: Value) -> Self {
Self { op, noun, result }
}
}
pub fn load_answers<T: serde::de::DeserializeOwned>(path: &std::path::Path) -> Result<T, OpError> {
let bytes = std::fs::read(path).map_err(|source| OpError::Io {
path: path.to_path_buf(),
source,
})?;
let ext = path
.extension()
.and_then(|e| e.to_str())
.map(|s| s.to_ascii_lowercase());
match ext.as_deref() {
Some("yaml") | Some("yml") => {
serde_yaml_bw::from_slice(&bytes).map_err(|e| OpError::AnswersParse {
path: path.to_path_buf(),
message: format!("yaml: {e}"),
})
}
Some("json") => serde_json::from_slice(&bytes).map_err(|e| OpError::AnswersParse {
path: path.to_path_buf(),
message: format!("json: {e}"),
}),
_ => {
serde_json::from_slice(&bytes).or_else(|json_err| {
serde_yaml_bw::from_slice(&bytes).map_err(|yaml_err| OpError::AnswersParse {
path: path.to_path_buf(),
message: format!("json: {json_err}; yaml: {yaml_err}"),
})
})
}
}
}
pub fn render_error(noun: &'static str, op: &'static str, err: &OpError) -> Value {
serde_json::json!({
"op": op,
"noun": noun,
"error": {
"kind": err.kind(),
"message": err.to_string(),
}
})
}