use std::collections::BTreeSet;
use super::compile::{ProjectionError, RevisionProjection};
use crate::config::{Config, Namespace, ProjectIdentity};
use crate::desired_state::{DesiredState, RevisionId, Tenancy};
#[derive(Debug, Clone, Copy, Default)]
pub struct TenancyProjection;
impl RevisionProjection for TenancyProjection {
fn name(&self) -> &'static str {
"tenancy"
}
fn project(
&self,
bootstrap: &Config,
state: &DesiredState,
_source: RevisionId,
) -> Result<Config, ProjectionError> {
let tenancy = Tenancy::of(state).map_err(|error| ProjectionError::Body {
reference: error.reference(),
detail: error.to_string(),
})?;
let mut config = bootstrap.clone();
if !bootstrap
.namespace
.iter()
.any(|namespace| namespace.default)
{
return Err(ProjectionError::Incomplete {
detail: "the bootstrap configuration declares no default namespace, and \
projecting a project cannot make one the default: a published project \
must not silently become where unnamed traffic lands. Declare a \
default `[[namespace]]` in the bootstrap configuration; selecting a \
default from desired state is not part of this slice"
.to_owned(),
});
}
let declared: BTreeSet<String> = bootstrap
.namespace
.iter()
.map(|namespace| namespace.id.clone())
.collect();
for project in tenancy.projects() {
if !tenancy.is_served(project.body.tenant()) {
continue;
}
let id = tenancy
.qualified_name(project.body.project())
.ok_or_else(|| ProjectionError::Body {
reference: project.reference,
detail: format!("project {} has no declared tenant", project.body.project()),
})?
.to_string();
if declared.contains(&id) {
return Err(ProjectionError::Incomplete {
detail: format!(
"namespace `{id}` is declared by the bootstrap configuration and by \
{}; one name cannot be owned by both a file and the control plane",
project.reference
),
});
}
config.namespace.push(Namespace {
id,
default: false,
allow_platform_fallback: false,
project: Some(ProjectIdentity {
tenant: project.body.tenant(),
project: project.body.project(),
}),
policy: None,
});
}
Ok(config)
}
}
#[cfg(test)]
mod tests {
use super::super::compile::testing::{bootstrap, env, revision};
use super::super::compile::{CandidateCompiler, RevisionCompiler};
use super::*;
use crate::desired_state::fixtures::{
alias, candidate, project, project_id, revision_id, state, tenant, tenant_body, tenant_id,
};
use crate::desired_state::{
DesiredState, ExpectedRevision, LoadedRevision, ProjectBody, QualifiedProject,
ResourceScope, ResourceVersion, ResourceVersionNumber, RevisionManifest, Slug,
TenantLifecycle,
};
fn namespaces(config: &Config) -> Vec<&str> {
config
.namespace
.iter()
.map(|namespace| namespace.id.as_str())
.collect()
}
fn hydrate(state: DesiredState) -> LoadedRevision {
let candidate = candidate(ExpectedRevision::Empty, "project", state);
let manifest = RevisionManifest::of(
revision_id(9),
None,
std::time::SystemTime::UNIX_EPOCH,
&candidate,
)
.expect("a publishable candidate");
LoadedRevision::assemble(manifest, candidate.state).expect("a consistent revision")
}
#[test]
fn every_project_becomes_a_tenant_qualified_namespace() {
let mut state = state();
state.insert(tenant(9, "globex")).expect("a distinct id");
state
.insert(project(&tenant_id(9), 12, "core"))
.expect("a distinct reference");
let config = TenancyProjection
.project(&bootstrap(), &state, revision_id(3))
.expect("the fixture tenancy is projectable");
assert_eq!(
namespaces(&config),
["platform", "acme/core", "globex/core"]
);
assert_eq!(
config
.namespace
.iter()
.filter(|namespace| namespace.default)
.map(|namespace| namespace.id.as_str())
.collect::<Vec<_>>(),
["platform"],
"the default namespace stays the boot fact it was"
);
assert!(
config
.namespace
.iter()
.skip(1)
.all(|namespace| !namespace.allow_platform_fallback),
"a projected project borrows no other namespace's credentials"
);
}
fn projected(state: &DesiredState) -> Vec<(String, Option<ProjectIdentity>)> {
TenancyProjection
.project(&bootstrap(), state, revision_id(3))
.expect("projectable")
.namespace
.into_iter()
.filter(|namespace| namespace.project.is_some())
.map(|namespace| (namespace.id, namespace.project))
.collect()
}
#[test]
fn a_suspended_tenants_projects_stop_being_namespaces_and_keep_being_rows() {
let mut state = DesiredState::new();
state.insert(tenant(1, "acme")).expect("fresh");
state
.insert(project(&tenant_id(1), 2, "core"))
.expect("a distinct reference");
state.insert(tenant(9, "globex")).expect("a distinct id");
state
.insert(project(&tenant_id(9), 12, "core"))
.expect("a distinct reference");
assert_eq!(
namespaces(
&TenancyProjection
.project(&bootstrap(), &state, revision_id(3))
.expect("projectable")
),
["platform", "acme/core", "globex/core"]
);
for lifecycle in [TenantLifecycle::Disabled, TenantLifecycle::Deleted] {
let mut suspended = state.clone();
suspended
.supersede(tenant_body(9, "Globex").in_lifecycle(lifecycle).version_at(
Slug::parse("globex").expect("a slug"),
ResourceVersionNumber::FIRST.next(),
))
.expect("a tenant this state declares");
assert_eq!(
namespaces(
&TenancyProjection
.project(&bootstrap(), &suspended, revision_id(3))
.expect("projectable")
),
["platform", "acme/core"],
"a {lifecycle} tenant serves nothing, and its neighbour is untouched"
);
assert!(
suspended
.resources()
.any(|version| version.slug.as_str() == "core"
&& version.scope == ResourceScope::Tenant(tenant_id(9))),
"the project row survives the transition it stopped serving"
);
}
}
#[test]
fn a_rename_moves_a_namespaces_name_and_not_what_it_is() {
let mut before = DesiredState::new();
before.insert(tenant(1, "acme")).expect("fresh");
before
.insert(project(&tenant_id(1), 2, "core"))
.expect("a distinct reference");
let mut after = DesiredState::new();
after.insert(tenant(1, "acme-inc")).expect("fresh");
after
.insert(project(&tenant_id(1), 2, "platform-core"))
.expect("a distinct reference");
let identity = ProjectIdentity {
tenant: tenant_id(1),
project: project_id(2),
};
assert_eq!(
projected(&before),
[("acme/core".to_owned(), Some(identity))]
);
assert_eq!(
projected(&after),
[("acme-inc/platform-core".to_owned(), Some(identity))],
"a rename changes the name a request uses and nothing that was accounted"
);
assert!(
TenancyProjection
.project(&bootstrap(), &before, revision_id(3))
.expect("projectable")
.namespace
.iter()
.find(|namespace| namespace.id == "platform")
.expect("the bootstrap namespace survives")
.project
.is_none()
);
}
#[test]
fn everything_the_bootstrap_owns_survives_the_projection_untouched() {
let bootstrap = bootstrap();
let config = TenancyProjection
.project(&bootstrap, &state(), revision_id(3))
.expect("projectable");
assert_eq!(config.provider.len(), bootstrap.provider.len());
assert_eq!(config.model.len(), bootstrap.model.len());
assert_eq!(config.credential.len(), bootstrap.credential.len());
assert_eq!(config.gateway_key.len(), bootstrap.gateway_key.len());
assert_eq!(config.mode, bootstrap.mode);
assert_eq!(config.namespace[0].id, bootstrap.namespace[0].id);
assert!(config.namespace[0].default);
assert_eq!(
namespaces(&config),
["platform", "acme/core"],
"the projection adds projects and nothing else"
);
let mut tenants_only = DesiredState::new();
tenants_only.insert(tenant(1, "acme")).expect("fresh");
tenants_only
.insert(alias(&tenant_id(1), 4, "fast", &[]))
.expect("a distinct reference");
assert_eq!(
namespaces(
&TenancyProjection
.project(&bootstrap, &tenants_only, revision_id(3))
.expect("projectable")
),
["platform"]
);
}
#[tokio::test]
async fn a_projected_revision_compiles_through_the_boot_gate_into_a_snapshot() {
let compiler = RevisionCompiler::with_secrets(
bootstrap(),
env(),
TenancyProjection,
crate::convergence::secrets::testing::permissive(),
);
assert_eq!(compiler.projection_name(), "tenancy");
let snapshot = compiler
.compile(&revision(), 3)
.await
.expect("a projected tenancy is servable");
assert_eq!(snapshot.generation, 3);
assert_eq!(namespaces(&snapshot.config), ["platform", "acme/core"]);
}
#[test]
fn a_project_a_bootstrap_namespace_already_names_is_refused() {
let bootstrap = Config::from_toml_str(
r#"
[[namespace]]
id = "acme/core"
default = true
[[gateway_key]]
env = "AXOND_KEY"
namespace = "acme/core"
"#,
)
.expect("a valid bootstrap config");
let error = TenancyProjection
.project(&bootstrap, &state(), revision_id(3))
.expect_err("one namespace cannot have two owners");
assert!(
matches!(error, ProjectionError::Incomplete { .. }),
"{error}"
);
assert!(error.to_string().contains("acme/core"), "{error}");
}
#[tokio::test]
async fn a_bootstrap_with_no_default_namespace_is_refused_rather_than_given_one() {
let bootstrap = Config::from_toml_str(
r#"
mode = "stateful"
[control_plane]
dsn_env = "GW_CONTROL_PLANE_DSN"
[secret_store]
kek_env = "GW_SECRET_STORE_KEK"
[[admin_breakglass]]
env = "GW_ADMIN_BREAKGLASS"
"#,
)
.expect("the minimum a stateful replica boots with");
assert!(
bootstrap.namespace.is_empty(),
"a stateful file declares no namespace at all"
);
let error = TenancyProjection
.project(&bootstrap, &state(), revision_id(3))
.expect_err("a projection may not nominate a default namespace");
let ProjectionError::Incomplete { detail } = &error else {
panic!("expected a deliberate incompleteness, got {error:?}");
};
assert!(detail.contains("default namespace"), "{detail}");
assert!(
detail.contains("not part of this slice"),
"the refusal says default selection is still gated: {detail}"
);
let Err(error) = RevisionCompiler::with_secrets(
bootstrap,
env(),
TenancyProjection,
crate::convergence::secrets::testing::permissive(),
)
.compile(&revision(), 1)
.await
else {
panic!("an incomplete bootstrap does not compile");
};
assert_eq!(error.reason(), "projection");
assert!(error.to_string().contains("default namespace"), "{error}");
}
#[test]
fn a_body_the_projection_cannot_read_is_refused_and_names_the_resource() {
let moved = ResourceVersion {
scope: ResourceScope::Tenant(tenant_id(9)),
..ProjectBody::new(
project_id(2),
tenant_id(1),
crate::desired_state::DisplayName::parse("Core").expect("a name"),
)
.version(Slug::parse("core").expect("a slug"))
};
let mut relocated = DesiredState::new();
relocated.insert(tenant(1, "acme")).expect("fresh");
relocated
.insert(tenant(9, "globex"))
.expect("a distinct id");
relocated
.insert(moved.clone())
.expect("a distinct reference");
let error = TenancyProjection
.project(&bootstrap(), &relocated, revision_id(3))
.expect_err("a project cannot be projected under another tenant");
let ProjectionError::Body { reference, detail } = &error else {
panic!("expected a body refusal, got {error:?}");
};
assert_eq!(*reference, moved.reference);
assert!(detail.contains("declares owner"), "{detail}");
}
#[test]
fn every_projected_id_is_distinct_and_decomposes_back_into_its_slugs() {
let mut state = state();
state.insert(tenant(9, "globex")).expect("a distinct id");
state
.insert(project(&tenant_id(9), 12, "core"))
.expect("a distinct reference");
state
.insert(project(&tenant_id(9), 13, "edge"))
.expect("a distinct reference");
let config = TenancyProjection
.project(&bootstrap(), &state, revision_id(3))
.expect("projectable");
let ids = namespaces(&config);
assert_eq!(
ids.iter().collect::<BTreeSet<_>>().len(),
ids.len(),
"one name cannot mean two namespaces: {ids:?}"
);
for id in ids.iter().filter(|id| **id != "platform") {
let qualified = QualifiedProject::parse(id)
.unwrap_or_else(|| panic!("a projected id is reversible, `{id}` is not"));
assert_eq!(
qualified.to_string(),
*id,
"and reversing it is lossless: `{id}`"
);
assert!(
!qualified
.tenant
.as_str()
.contains(QualifiedProject::SEPARATOR)
&& !qualified
.project
.as_str()
.contains(QualifiedProject::SEPARATOR),
"the separator appears once, between the slugs: `{id}`"
);
}
}
#[test]
fn projection_reads_state_and_nothing_else() {
let revision = hydrate(state());
let first = TenancyProjection
.project(&bootstrap(), revision.state(), revision_id(3))
.expect("projectable");
let second = TenancyProjection
.project(&bootstrap(), revision.state(), revision_id(3))
.expect("projectable");
assert_eq!(namespaces(&first), namespaces(&second));
}
}