Make agent creation swarm-only and refuse a name placed on another hive
swarm-controller's POST /api/agents now refuses (409) a name the swarm
has already placed on a different hive: a non-Destroyed declaration in
that hive's wanted state, or a SetAgentWanted node still queued for it.
The same name on the same hive is that agent being re-created and goes
through. A wanted state that cannot be read refuses (503/500) instead of
reading as "placed nowhere". Creations are serialised from that read to
the graph insert so two concurrent creations of one name cannot both
pass.
Hive-level creation is removed: hivectl `agent create` / `request-create`,
HostRequest::Spawn / RequestSpawn, the dashboard POST /api/request-spawn
route, and ApprovalKind::Spawn with its approve/resolve arms and the
approval-carrying `templates::spawn`. The swarm path (deploy request or
wanted-state sweep -> queue_first_deploy -> templates::first_deploy) used
none of them. Old `spawn` approval rows are skipped by collect_lenient,
as `init_config` rows were in a3b672d1.
policy.rs's comment on agent_object_name stated swarm-wide name
uniqueness as a fact; it now says where it is enforced and what that
check cannot see.
Refs #4396
This commit is contained in:
parent
1d8ec00ddc
commit
5785c0024c
35 changed files with 376 additions and 434 deletions
|
|
@ -122,7 +122,7 @@ pub enum NodeKind {
|
|||
/// node-inventory row for its three responsibilities and why it isn't
|
||||
/// named `AbortDeploy`.
|
||||
DeployTail { agent: String, approval_id: i64 },
|
||||
/// Tail node of an approval-carrying DAG (spawn / opaque deploy /
|
||||
/// Tail node of an approval-carrying DAG (opaque deploy /
|
||||
/// config-PR merge): resolve the approval row from how the work ended.
|
||||
ResolveApproval {
|
||||
approval_id: i64,
|
||||
|
|
|
|||
|
|
@ -440,27 +440,21 @@ pub fn approval_deploy(builder: &JobBuilder, agent: &str, approval_id: i64) {
|
|||
resolve_approval_tails(builder, approval_id, window);
|
||||
}
|
||||
|
||||
/// First-deploy spawn (approval-driven): `Provision` (proposed/applied
|
||||
/// repos, state subvolume, meta registration) then `Create`
|
||||
/// (`nixos-container create`), drop-in write, then `Reconcile` starts
|
||||
/// the container (`wanted = Up` written at approve time). All-or-nothing:
|
||||
/// `Provision` (lease-exempt, precedes the container) is the group root;
|
||||
/// `Create` (child) owns the agent lease; `WriteDropin` + `Reconcile`
|
||||
/// (children of `Create`) borrow it. A failure cancel-cascades the rest —
|
||||
/// unlike rebuild there's no recovery-reconcile (nothing to converge if the
|
||||
/// container was never created). Closed by a `ResolveApproval` tail root edged
|
||||
/// `AfterAny` onto `Provision` — the DAG's only other group-root, so its roll-up
|
||||
/// already carries the whole cascade.
|
||||
pub fn spawn(builder: &JobBuilder, agent: &str, approval_id: i64) {
|
||||
let provision = spawn_nodes(builder, agent);
|
||||
resolve_approval_tails(builder, approval_id, provision);
|
||||
}
|
||||
|
||||
/// The spawn subgraph with no tail, returning its group root.
|
||||
/// First deploy of an agent this hive has never seen, asked for by the swarm:
|
||||
/// `Provision` (proposed/applied repos, state subvolume, meta registration)
|
||||
/// then `Create` (`nixos-container create`), drop-in write, then `Reconcile`
|
||||
/// starts the container (`wanted = Up`, seeded by
|
||||
/// `swarm_status::queue_first_deploy`). All-or-nothing: `Provision`
|
||||
/// (lease-exempt, precedes the container) is the group root; `Create` (child)
|
||||
/// owns the agent lease; `WriteDropin` + `Reconcile` (children of `Create`)
|
||||
/// borrow it. A failure cancel-cascades the rest — unlike rebuild there's no
|
||||
/// recovery-reconcile (nothing to converge if the container was never created).
|
||||
///
|
||||
/// Split out for the same reason [`rebuild_nodes`] is: two callers want the
|
||||
/// same four nodes and disagree only about what closes them.
|
||||
pub(crate) fn spawn_nodes<'a>(builder: &'a JobBuilder, agent: &str) -> Handle<'a> {
|
||||
/// No approval tail: the operator authorised the creation at swarm level, and
|
||||
/// the deploy request carries that authorisation.
|
||||
///
|
||||
/// Returns the group root so a caller can wait on the whole subtree.
|
||||
pub fn first_deploy(builder: &JobBuilder, agent: &str) -> Vec<hive_jobq::NodeGuid> {
|
||||
let a = || agent.to_owned();
|
||||
let provision = builder
|
||||
.node(NodeKind::Provision { agent: a() })
|
||||
|
|
@ -479,20 +473,7 @@ pub(crate) fn spawn_nodes<'a>(builder: &'a JobBuilder, agent: &str) -> Handle<'a
|
|||
.needs(Resource::Agent(a()))
|
||||
.part_of(create)
|
||||
.after_ok(dropin);
|
||||
provision
|
||||
}
|
||||
|
||||
/// First deploy of an agent this hive has never seen, asked for by the swarm.
|
||||
///
|
||||
/// [`spawn`] without the approval tail, and the absence is the point rather
|
||||
/// than an omission: that flow exists because an operator used to approve the
|
||||
/// spawn *at the hive*. When the swarm asks, the operator has already clicked
|
||||
/// create at swarm level — the deploy request carries that authorisation, and a
|
||||
/// second gate here would be asking the same person the same question twice.
|
||||
///
|
||||
/// Returns the group root so a caller can wait on the whole subtree.
|
||||
pub fn first_deploy(builder: &JobBuilder, agent: &str) -> Vec<hive_jobq::NodeGuid> {
|
||||
vec![spawn_nodes(builder, agent).guid()]
|
||||
vec![provision.guid()]
|
||||
}
|
||||
|
||||
/// Teardown: `Stop` → `DestroyContainer` → (`PurgeState`) → `DestroyBookkeeping`.
|
||||
|
|
|
|||
|
|
@ -1624,10 +1624,10 @@ fn pause_shape_signal_drain() {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_shape_provision_create_dropin_reconcile() {
|
||||
fn first_deploy_shape_provision_create_dropin_reconcile() {
|
||||
let q = JobQueue::new(1);
|
||||
insert(&q, |builder| {
|
||||
templates::spawn(builder, "newbie", 7);
|
||||
templates::first_deploy(builder, "newbie");
|
||||
});
|
||||
assert_eq!(
|
||||
declared_shape(&q),
|
||||
|
|
@ -1636,14 +1636,6 @@ fn spawn_shape_provision_create_dropin_reconcile() {
|
|||
row("create", Some("provision"), &[]),
|
||||
row("write_dropin", Some("create"), &[]),
|
||||
row("reconcile", Some("create"), &[("write_dropin", "done")]),
|
||||
// One tail per outcome, each edged to accept only that one — so
|
||||
// *which* tail the graph lets run already is the answer, and
|
||||
// nothing branches at runtime. The three differ **only** in their
|
||||
// accepted outcome, which is why `declared_shape` spells the
|
||||
// outcome set out instead of bucketing it.
|
||||
row("resolve_approval", None, &[("provision", "done")]),
|
||||
row("resolve_approval", None, &[("provision", "failed")]),
|
||||
row("resolve_approval", None, &[("provision", "cancelled")]),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue