pub mod migrate;
pub mod preflight;
use std::collections::HashMap;
use crate::backends::control_plane::ControlPlaneError;
use crate::backends::control_plane::postgres::{ControlPlaneSettings, PostgresControlPlane};
use crate::config::{Config, Mode};
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum OpsError {
#[error("{0}")]
Config(String),
#[error(
"{target}: `{dsn_env}` is unset or empty in the environment; export it before running \
this command"
)]
MissingDsn { target: String, dsn_env: String },
#[error("{target}: {message}")]
Unreachable { target: String, message: String },
#[error("{target}: {message}")]
Refused { target: String, message: String },
}
impl OpsError {
pub fn is_retryable(&self) -> bool {
matches!(self, Self::Unreachable { .. })
}
}
pub fn load(path: &str) -> Result<Config, OpsError> {
Config::load(path)
.map_err(|error| OpsError::Config(format!("failed to load config from `{path}`: {error}")))
}
pub fn inference_refusal(config: &Config) -> Option<&'static str> {
match config.mode {
Mode::Stateless => None,
Mode::Stateful => Some(
"`mode = \"stateful\"` serves `/admin/v1` against the control plane, but a published \
revision cannot yet be compiled into a runtime snapshot, so this replica refuses \
inference rather than serving an empty configuration. Use `mode = \"stateless\"` \
(the default) to serve inference until revision convergence ships.",
),
}
}
pub(crate) const CONTROL_PLANE: &str = "control plane";
pub(crate) fn control_plane(config: &Config) -> Option<&crate::config::ControlPlane> {
match config.mode {
Mode::Stateless => None,
Mode::Stateful => config.control_plane.as_ref(),
}
}
pub(crate) fn control_plane_dsn_env(control_plane: &crate::config::ControlPlane) -> String {
control_plane
.dsn_env
.as_deref()
.unwrap_or_default()
.trim()
.to_owned()
}
pub(crate) fn dsn<'a>(
env: &'a HashMap<String, String>,
target: &str,
dsn_env: &str,
) -> Result<&'a str, OpsError> {
env.get(dsn_env)
.map(String::as_str)
.filter(|dsn| !dsn.trim().is_empty())
.ok_or_else(|| OpsError::MissingDsn {
target: target.to_owned(),
dsn_env: dsn_env.to_owned(),
})
}
pub(crate) async fn open_control_plane(
control_plane: &crate::config::ControlPlane,
env: &HashMap<String, String>,
) -> Result<PostgresControlPlane, OpsError> {
let dsn_env = control_plane_dsn_env(control_plane);
let dsn = dsn(env, CONTROL_PLANE, &dsn_env)?;
PostgresControlPlane::connect_for_maintenance(
dsn,
ControlPlaneSettings::for_maintenance(control_plane),
)
.await
.map_err(control_plane_error)
}
pub(crate) fn control_plane_error(error: ControlPlaneError) -> OpsError {
match error {
ControlPlaneError::Unavailable { message, .. } => OpsError::Unreachable {
target: CONTROL_PLANE.to_owned(),
message,
},
other => OpsError::Refused {
target: CONTROL_PLANE.to_owned(),
message: other.to_string(),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
pub(super) fn stateful_toml() -> &'static str {
"mode = \"stateful\"\n\
[control_plane]\n\
dsn_env = \"GW_CONTROL_PLANE_DSN\"\n\
[secret_store]\n\
kek_env = \"GW_KEK\"\n\
[[admin_breakglass]]\n\
env = \"GW_BREAKGLASS\"\n"
}
pub(super) fn stateless_toml() -> &'static str {
"[[gateway_key]]\nenv = \"GW_KEY\"\nnamespace = \"platform\"\n\
[[namespace]]\nid = \"platform\"\ndefault = true\n"
}
#[test]
fn stateless_mode_has_no_control_plane_to_act_on() {
let config = Config::from_toml_str(stateless_toml()).expect("valid stateless config");
assert!(
control_plane(&config).is_none(),
"a stateless install must not acquire a PostgreSQL dependency from an ops command"
);
}
#[test]
fn stateful_mode_acts_on_the_configured_reference() {
let config = Config::from_toml_str(stateful_toml()).expect("valid stateful config");
let control_plane = control_plane(&config).expect("stateful mode requires a control plane");
assert_eq!(control_plane_dsn_env(control_plane), "GW_CONTROL_PLANE_DSN");
}
#[test]
fn an_unsatisfied_reference_names_the_variable_and_never_a_value() {
let mut env = HashMap::new();
env.insert("GW_CONTROL_PLANE_DSN".to_owned(), " ".to_owned());
let error = dsn(&env, CONTROL_PLANE, "GW_CONTROL_PLANE_DSN")
.expect_err("whitespace is not a connection string");
assert_eq!(
error,
OpsError::MissingDsn {
target: CONTROL_PLANE.to_owned(),
dsn_env: "GW_CONTROL_PLANE_DSN".to_owned(),
}
);
let rendered = error.to_string();
assert!(rendered.contains("GW_CONTROL_PLANE_DSN"), "{rendered}");
assert!(!error.is_retryable(), "exporting a variable is not a retry");
env.insert(
"GW_CONTROL_PLANE_DSN".to_owned(),
"postgres://axond:hunter2@db/axond".to_owned(),
);
assert_eq!(
dsn(&env, CONTROL_PLANE, "GW_CONTROL_PLANE_DSN").expect("resolved"),
"postgres://axond:hunter2@db/axond"
);
}
#[test]
fn a_missing_variable_is_reported_without_connecting() {
let error = dsn(&HashMap::new(), CONTROL_PLANE, "GW_CONTROL_PLANE_DSN")
.expect_err("an unset variable cannot be resolved");
assert!(matches!(error, OpsError::MissingDsn { .. }));
}
#[test]
fn an_outage_is_retryable_and_a_denial_is_not() {
let outage = control_plane_error(ControlPlaneError::Unavailable {
backend: "postgres",
message: "connection refused".to_owned(),
});
assert!(outage.is_retryable(), "{outage}");
let denial = control_plane_error(ControlPlaneError::Denied {
backend: "postgres",
message: "a newer gateway owns this database".to_owned(),
});
assert!(!denial.is_retryable(), "{denial}");
assert!(denial.to_string().contains("newer gateway"));
}
}