Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use rhai::{Engine, Scope};

pub mod defs;
pub mod oi;
pub mod reserved;
pub mod runtime;
pub mod sysconst;
pub mod system;
Expand Down
3 changes: 3 additions & 0 deletions crates/core/src/oi/handler/ingresses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ pub(crate) fn create_site_ingress(
ctx: &RequestCtx,
) -> HandlerResult {
validate_hostname(&params.hostname)?;
// r[impl namespace.reserved]
crate::reserved::check_site_ingress_name(&params.name)
.map_err(|e| OiError::new(ErrorCode::RequirementsInvalid, e.to_string()))?;
if matches!(params.tls_provider, TlsProvider::Tailscale) {
return Err(OiError::new(
ErrorCode::RequirementsInvalid,
Expand Down
16 changes: 16 additions & 0 deletions crates/core/src/oi/handler/ingresses/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,3 +316,19 @@ fn discovery_status_and_refresh_without_tailscale() {
assert_eq!(code, "requirements_invalid");
assert!(msg.contains("not configured"), "{msg}");
}

// r[verify namespace.reserved]
// The Tailscale provider disables whatever row holds this name, so a manual
// ingress created under it was permanently stale.
#[test]
fn reserved_site_ingress_name_is_rejected_at_creation() {
let oi = TestOi::new();
let (code, message) = oi
.call(
"/ingresses/site/create",
json!({ "name": "tailscale", "hostname": "example.com" }),
)
.unwrap_err();
assert_eq!(code, "requirements_invalid");
assert!(message.contains("reserved"), "message: {message}");
}
16 changes: 16 additions & 0 deletions crates/core/src/oi/handler/volumes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ pub(crate) fn restore_held(
})?,
};

// r[impl namespace.reserved]
crate::reserved::check_site_volume_name(&target_name)
.map_err(|e| OiError::new(ErrorCode::RequirementsInvalid, e.to_string()))?;

let target_for_lookup = target_name.clone();
let existing = state
.db
Expand Down Expand Up @@ -277,6 +281,10 @@ pub(crate) fn create_site_volume(
) -> HandlerResult {
use crate::runtime::site_volumes::{SiteVolumeDef, SiteVolumeKind};

// r[impl namespace.reserved]
crate::reserved::check_site_volume_name(&params.name)
.map_err(|e| OiError::new(ErrorCode::RequirementsInvalid, e.to_string()))?;

let kind = match params.kind.as_str() {
"managed" => SiteVolumeKind::Managed,
"bind" => {
Expand Down Expand Up @@ -517,6 +525,10 @@ pub(crate) fn snapshot_site_volume(
) -> HandlerResult {
use crate::runtime::site_volumes::{SiteVolumeDef, SiteVolumeKind};

// r[impl namespace.reserved]
crate::reserved::check_site_volume_name(&params.name)
.map_err(|e| OiError::new(ErrorCode::RequirementsInvalid, e.to_string()))?;

let (source, source_path) = parse_source_vol_id(&params.source, state)
.map_err(|e| OiError::new(ErrorCode::RequirementsInvalid, e))?;

Expand Down Expand Up @@ -576,6 +588,10 @@ pub(crate) fn promote_site_volume(
) -> HandlerResult {
use crate::runtime::site_volumes::{SiteVolumeDef, SiteVolumeKind};

// r[impl namespace.reserved]
crate::reserved::check_site_volume_name(&params.name)
.map_err(|e| OiError::new(ErrorCode::RequirementsInvalid, e.to_string()))?;

let source_name = params.source.clone();
let source_def = state
.db
Expand Down
23 changes: 23 additions & 0 deletions crates/core/src/oi/handler/volumes/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -415,3 +415,26 @@ fn declared_external_volumes_guard_unmap() {
assert_eq!(code, "requirements_invalid");
assert!(msg.contains("declared by app"), "{msg}");
}

// r[verify namespace.reserved]
// The daemon deletes every site volume under `backup-snap-` at startup, so an
// operator volume created under that prefix was destroyed on the next restart.
#[test]
fn reserved_prefix_is_rejected_at_creation() {
let oi = TestOi::new();
let (code, message) = oi
.call(
"/volumes/site/create",
json!({ "name": "backup-snap-archive", "kind": "managed" }),
)
.unwrap_err();
assert_eq!(code, "requirements_invalid");
assert!(message.contains("reserved"), "message: {message}");

// A name that merely contains the prefix is the operator's.
oi.call(
"/volumes/site/create",
json!({ "name": "my-backup-snap-thing", "kind": "managed" }),
)
.expect("only the claimed prefix is reserved");
}
109 changes: 109 additions & 0 deletions crates/core/src/reserved.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
//! Names Seedling grants itself inside namespaces operators also use.
//!
//! Site volumes and site ingresses are operator-facing namespaces, and the
//! daemon takes names in both: `backup-snap-*` for the transient snapshots a
//! backup run creates, and `tailscale` for the ingress the discovery provider
//! maintains. Both are then recognised later *by name*, and both have a
//! destructive consumer — startup deletes everything under the snapshot
//! prefix, and the provider disables whatever row holds the ingress name.
//! Nothing stopped an operator creating an object with either name first.
//!
//! Two halves are needed and neither suffices alone. Reservation at creation
//! stops new collisions but cannot repair one that already exists, and does
//! not protect against a future code path that forgets to ask. So the
//! destructive consumers also match on *recorded ownership*: the startup
//! sweep skips names present in `site_volumes`, and the Tailscale provider
//! acts only on rows whose source is its own discovery. Reservation makes
//! collisions impossible going forward; ownership checks make them harmless
//! regardless of history.
//!
//! The constants lived apart before — `backup_execution.rs` and
//! `tailscale.rs` knew nothing about each other or about the creation
//! handlers — which is exactly how the gap opened.

use seedling_protocol::names::{SiteIngressName, SiteVolumeName};

/// Site-volume name prefixes the daemon claims.
///
/// Startup deletes every site volume under these, so an operator volume that
/// happened to match was destroyed on the next daemon start.
pub const RESERVED_SITE_VOLUME_PREFIXES: &[&str] =
&[crate::runtime::backup_execution::SNAPSHOT_NAME_PREFIX];

/// Site-ingress names the daemon claims.
pub const RESERVED_SITE_INGRESS_NAMES: &[&str] =
&[crate::runtime::tailscale::TAILSCALE_INGRESS_NAME];

/// A name that belongs to the daemon.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReservedName {
pub name: String,
pub reason: String,
}

impl std::fmt::Display for ReservedName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} is reserved: {}", self.name, self.reason)
}
}

/// Reject a site-volume name the daemon claims.
///
/// Creation only — never update or delete. An operator with a legacy
/// `backup-snap-*` volume must still be able to manage it out of existence.
// r[impl namespace.reserved]
pub fn check_site_volume_name(name: &SiteVolumeName) -> Result<(), ReservedName> {
for prefix in RESERVED_SITE_VOLUME_PREFIXES {
if name.as_str().starts_with(prefix) {
return Err(ReservedName {
name: name.as_str().to_owned(),
reason: format!(
"site volume names beginning {prefix:?} are used by backup runs and are \
deleted at daemon startup"
),
});
}
}
Ok(())
}

/// Reject a site-ingress name the daemon claims.
///
/// Creation only, for the same reason as above.
// r[impl namespace.reserved]
pub fn check_site_ingress_name(name: &SiteIngressName) -> Result<(), ReservedName> {
if RESERVED_SITE_INGRESS_NAMES.contains(&name.as_str()) {
return Err(ReservedName {
name: name.as_str().to_owned(),
reason: "this site ingress name is maintained by a discovery provider".to_owned(),
});
}
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;

// r[verify namespace.reserved]
#[test]
fn snapshot_prefix_is_reserved() {
let reserved = SiteVolumeName::new("backup-snap-archive").unwrap();
assert!(check_site_volume_name(&reserved).is_err());
let ordinary = SiteVolumeName::new("archive").unwrap();
assert!(check_site_volume_name(&ordinary).is_ok());
// A name that merely contains the prefix is fine — only the claim on
// the front of the namespace is the daemon's.
let contains = SiteVolumeName::new("my-backup-snap-thing").unwrap();
assert!(check_site_volume_name(&contains).is_ok());
}

// r[verify namespace.reserved]
#[test]
fn tailscale_ingress_name_is_reserved() {
let reserved = SiteIngressName::new("tailscale").unwrap();
assert!(check_site_ingress_name(&reserved).is_err());
let ordinary = SiteIngressName::new("tailscale-manual").unwrap();
assert!(check_site_ingress_name(&ordinary).is_ok());
}
}
17 changes: 17 additions & 0 deletions crates/core/src/runtime/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,23 @@ pub fn find_instance(db: &Db, id: InstanceId) -> rusqlite::Result<Option<Resourc
}
}

/// Every `display_name` this app has ever actuated.
///
/// The registry knows an app's units exactly; `seedling-{app}-` does not.
/// `display_name` is `{app}-{name}[-{suffix}]` or `{app}-{kind_slug}[-{name}]`,
/// and both app names and resource names may contain hyphens, so the encoding
/// is not prefix-free: `seedling-app-` matches every unit of an app called
/// `app-db`. These rows survive until uninstall *completes*, so they are
/// available for the whole window in which the match runs.
// r[impl identity.components]
pub fn display_names_for_app(db: &Db, app: &AppName) -> rusqlite::Result<Vec<String>> {
let mut stmt = db
.conn
.prepare("SELECT DISTINCT display_name FROM resource_instances WHERE app = ?1")?;
let rows = stmt.query_map(params![app], |row| row.get::<_, String>(0))?;
rows.collect()
}

// r[impl identity.components]
pub fn find_instances_for_group(
db: &Db,
Expand Down
33 changes: 33 additions & 0 deletions crates/core/src/runtime/history/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -709,3 +709,36 @@ fn delete_instance_clears_observations_and_faults_atomically() {
assert_eq!(obs_count, 0, "observations deleted");
assert_eq!(fault_count, 0, "faults deleted");
}

// r[verify app.uninstall.scope]
// `seedling-{app}-` is not prefix-free: both app names and resource names may
// contain hyphens, so uninstalling `app` matched every unit belonging to a
// sibling called `app-db`, and the retry branch reset and stopped that
// healthy sibling's units every tick while the uninstall never completed.
#[test]
fn display_names_are_scoped_to_their_own_app() {
let db = Db::open_in_memory().expect("open");

let instance = |app: &str, name: &str| ResourceInstance {
id: InstanceId::generate(),
app: AppName::new(app).unwrap(),
kind: ResourceKind::Deployment,
name: Some(name.to_owned()),
variant: InstanceVariant::Singleton,
display_name: format!("{app}-{name}"),
};

let app = instance("app", "web");
let sibling = instance("app-db", "web");
insert_instance(&db, &app).expect("insert app");
insert_instance(&db, &sibling).expect("insert sibling");

let names = display_names_for_app(&db, &AppName::new("app").unwrap()).expect("query");
assert_eq!(names, vec!["app-web".to_owned()]);
// The sibling's unit is `seedling-app-db-web.service`, which starts with
// `seedling-app-` — the exact match is what excludes it.
assert!(
format!("seedling-{}.service", sibling.display_name).starts_with("seedling-app-"),
"precondition: the sibling really does match the old prefix"
);
}
40 changes: 32 additions & 8 deletions crates/core/src/runtime/tailscale.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ pub const DEFAULT_SOCKET_PATH: &str = "/var/run/tailscale/tailscaled.sock";

/// Operator-visible name we use for the discovered Tailscale site ingress.
/// Matches the provider name so listings read naturally.
const TAILSCALE_INGRESS_NAME: &str = "tailscale";
pub(crate) const TAILSCALE_INGRESS_NAME: &str = "tailscale";

/// How often the provider polls tailscaled for the current identity.
const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(60);
Expand Down Expand Up @@ -334,10 +334,24 @@ impl TailscaleProvider {
fn mark_existing_stale(&self, stale: bool) {
self.db.call(move |db| {
let name = SiteIngressName::new_unchecked(TAILSCALE_INGRESS_NAME);
if let Ok(Some(_)) = site_ingresses::get(db, &name)
&& let Err(e) = site_ingresses::set_stale(db, &name, stale)
{
warn!(error = %e, "tailscale: failed to update stale flag");
// r[impl namespace.reserved] — act on the row this provider owns,
// not on whatever holds the name. A manual ingress an operator
// created before the name was reserved was disabled here on the
// provider's first bad poll, and never re-enabled.
match site_ingresses::get(db, &name) {
Ok(Some(def)) if !def.source.is_discovered() => {
warn!(
"tailscale: a manually-created site ingress holds the name \
{TAILSCALE_INGRESS_NAME:?}; leaving it alone"
);
}
Ok(Some(_)) => {
if let Err(e) = site_ingresses::set_stale(db, &name, stale) {
warn!(error = %e, "tailscale: failed to update stale flag");
}
}
Ok(None) => {}
Err(e) => warn!(error = %e, "tailscale: failed to look up the discovered ingress"),
}
});
}
Expand Down Expand Up @@ -435,9 +449,19 @@ fn upsert_discovered_row(
// case, which matches the design intent: a node-id change is "the
// host identity changed" and attachments shouldn't silently follow.
let name = SiteIngressName::new_unchecked(TAILSCALE_INGRESS_NAME);
if let Some(existing) = site_ingresses::get(db, &name)?
&& existing.source.is_discovered()
{
// r[impl namespace.reserved] — a manual row under this name is the
// operator's. Falling through to `create` collided on the primary key
// every poll, so the provider livelocked and the operator's row stayed
// stale forever.
if let Some(existing) = site_ingresses::get(db, &name)? {
if !existing.source.is_discovered() {
warn!(
"tailscale: a manually-created site ingress holds the name \
{TAILSCALE_INGRESS_NAME:?}; not creating the discovered one. Rename or remove \
it to let discovery take over."
);
return Ok(());
}
site_ingresses::delete(db, &name)?;
info!(
stale_node_id = ?match &existing.source {
Expand Down
Loading