The mint creates each client's secret in the swarm secret store and writes only the digest to disk. It mints only when the store answers and holds none. A new unit moves the plaintext files an earlier release left into the store and removes each once the store holds the same value. The bridge reads its secret from the store into /run before each start. The secret publisher unit and its path unit are gone.
703 lines
26 KiB
Rust
703 lines
26 KiB
Rust
//! The swarm builder's build service.
|
|
//!
|
|
//! Connects to the swarm queue as its own OIDC client, takes build requests
|
|
//! from swarm-controller on [`swarm_wire::builder::SUBJECT`], builds
|
|
//! each agent config at its commit, answers on the request's reply subject,
|
|
//! and logs one line per build. It writes nothing to the forge: the
|
|
//! controller turns the reply into a commit status. It also answers
|
|
//! [`swarm_wire::builder::ARCHETYPES_SUBJECT`] with the agent
|
|
//! archetypes its hyperhive source declares, which the controller stores.
|
|
//!
|
|
//! Lock requests on [`swarm_wire::builder::LOCK_SUBJECT`] carry the swarm meta
|
|
//! flake's `flake.nix`, and the answer is the `flake.lock` `nix` wrote for it in
|
|
//! a scratch directory. The controller commits it.
|
|
//!
|
|
//! Meta build requests on [`swarm_wire::builder::META_SUBJECT`] name an agent
|
|
//! and an `internal/meta` commit, and the answer is the store path of that
|
|
//! agent's toplevel, built at the flake's own lock. They take the build slot
|
|
//! PR checks take. The controller records the path; the builder writes nothing.
|
|
//! When a cache is set (see [`push`]), a green meta build is pushed to it before
|
|
//! the reply, and the reply says whether that worked.
|
|
//!
|
|
//! The store identity comes from the `BAO_*` environment. The queue client
|
|
//! secret is read at start and again when authelia refuses it, the forge token
|
|
//! once per build, both from the store under that identity.
|
|
|
|
use std::future::Future;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use anyhow::{Context, Result, anyhow, bail};
|
|
use futures_util::StreamExt as _;
|
|
use swarm_queue_client::{ClientSecret, QueueConfig};
|
|
use swarm_secret_client::forge::Credential;
|
|
use swarm_secret_client::stored_secret::StoredSecret;
|
|
use swarm_wire::builder::{
|
|
ARCHETYPES_DEADLINE, ARCHETYPES_SUBJECT, BUILD_DEADLINE, BuildAgent, BuildFromMeta,
|
|
FromMetaReply, LOCK_DEADLINE, LOCK_SUBJECT, LockMeta, LockReply, META_SUBJECT, QUEUE_GROUP,
|
|
REPLY_DEADLINE, Reply, SUBJECT,
|
|
};
|
|
|
|
mod archetypes;
|
|
mod nix_build;
|
|
mod push;
|
|
|
|
/// The cert-auth role this service logs in to the store as. `swarm-bao.nix`
|
|
/// writes it, matching the leaf's subject.
|
|
const CERT_ROLE: &str = "swarm-builder";
|
|
|
|
/// The environment prefix `swarm-builder.nix` sets.
|
|
const PREFIX: &str = "SWARM_BUILDER";
|
|
|
|
/// The longest the store login and forge token read may take before a build.
|
|
/// Each store request alone may take 30 s, so without this bound the two could
|
|
/// spend the minute `REPLY_DEADLINE` leaves after `BUILD_DEADLINE`.
|
|
const TOKEN_DEADLINE: Duration = Duration::from_secs(20);
|
|
|
|
/// What is held back from the minute `REPLY_DEADLINE` leaves after
|
|
/// `BUILD_DEADLINE`: [`TOKEN_DEADLINE`], then 10 s to publish the reply and for
|
|
/// it to reach the controller. Both come after the slot wait.
|
|
const SLOT_MARGIN: Duration = Duration::from_secs(30);
|
|
|
|
/// The longest a request may wait for the build slot. A build that gets the
|
|
/// slot later could still be running when the controller stops waiting for the
|
|
/// reply.
|
|
const MAX_SLOT_WAIT: Duration = REPLY_DEADLINE
|
|
.saturating_sub(BUILD_DEADLINE)
|
|
.saturating_sub(SLOT_MARGIN);
|
|
|
|
/// What is held back from a meta build's reply deadline for publishing the reply
|
|
/// after its push.
|
|
const REPLY_MARGIN: Duration = Duration::from_secs(10);
|
|
|
|
/// How long the push of a meta build that ends at now may run: at most
|
|
/// [`push::PUSH_DEADLINE`], and never past `reply_by`, so a push cannot make the
|
|
/// reply later than the controller waits.
|
|
fn push_budget(reply_by: tokio::time::Instant) -> Duration {
|
|
push::PUSH_DEADLINE.min(reply_by.saturating_duration_since(tokio::time::Instant::now()))
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
hive_log::init();
|
|
swarm_secret_client::install_crypto_provider();
|
|
let var = |name: &str| {
|
|
let full = format!("{PREFIX}_{name}");
|
|
std::env::var(&full).with_context(|| format!("{full} is unset"))
|
|
};
|
|
let client_id = var("OIDC_CLIENT_ID")?;
|
|
let builder = Arc::new(nix_build::Builder {
|
|
nix: PathBuf::from(var("NIX")?),
|
|
check_flake: var("CHECK_FLAKE")?,
|
|
hyperhive: var("HYPERHIVE_FLAKE")?,
|
|
nixpkgs: var("NIXPKGS_FLAKE")?,
|
|
forge_url: forge_url()?,
|
|
config_org: var("CONFIG_ORG")?,
|
|
deadline: BUILD_DEADLINE,
|
|
lock_deadline: LOCK_DEADLINE,
|
|
system: var("SYSTEM")?,
|
|
cache: push_target()?,
|
|
});
|
|
let secret =
|
|
swarm_secret_client::read_with_startup_retry("reading the queue client secret", || {
|
|
client_secret(&client_id)
|
|
})
|
|
.await?;
|
|
let evaluator = archetypes::Evaluator {
|
|
nix: builder.nix.clone(),
|
|
hyperhive: builder.hyperhive.clone(),
|
|
nixpkgs: builder.nixpkgs.clone(),
|
|
deadline: ARCHETYPES_DEADLINE,
|
|
};
|
|
let reread_id = client_id.clone();
|
|
let secret = StoredSecret::new(client_id.clone(), secret, move || {
|
|
let client_id = reread_id.clone();
|
|
async move { client_secret(&client_id).await.map_err(Into::into) }
|
|
});
|
|
let queue = swarm_queue_client::connect(QueueConfig {
|
|
url: var("NATS_URL")?,
|
|
token_endpoint: var("OIDC_TOKEN_ENDPOINT")?,
|
|
client_id,
|
|
client_secret: ClientSecret::Stored(secret),
|
|
ca_file: std::env::var(format!("{PREFIX}_OIDC_CA_FILE"))
|
|
.ok()
|
|
.map(PathBuf::from),
|
|
})
|
|
.await?;
|
|
let mut requests = queue
|
|
.queue_subscribe(SUBJECT, QUEUE_GROUP.to_owned())
|
|
.await
|
|
.with_context(|| format!("subscribing to {SUBJECT}"))?;
|
|
tracing::info!(subject = SUBJECT, "builder: awaiting builds");
|
|
let evaluations = queue
|
|
.queue_subscribe(ARCHETYPES_SUBJECT, QUEUE_GROUP.to_owned())
|
|
.await
|
|
.with_context(|| format!("subscribing to {ARCHETYPES_SUBJECT}"))?;
|
|
// Outside the build slot below: a list refresh does not wait on a build.
|
|
tokio::spawn(answer_evaluations(evaluations, queue.clone(), evaluator));
|
|
let locks = queue
|
|
.queue_subscribe(LOCK_SUBJECT, QUEUE_GROUP.to_owned())
|
|
.await
|
|
.with_context(|| format!("subscribing to {LOCK_SUBJECT}"))?;
|
|
// Outside the build slot too: a lock builds nothing, so it does not wait
|
|
// behind an hour-long build.
|
|
tokio::spawn(answer_locks(locks, queue.clone(), Arc::clone(&builder)));
|
|
|
|
// One build at a time. swarm-controller already sends one at a time; this
|
|
// holds the bound for any request it did not serialise, and `in_slot`
|
|
// refuses one that waited too long behind another. Meta builds take the
|
|
// same slot as PR checks.
|
|
let slot = Arc::new(tokio::sync::Semaphore::new(1));
|
|
serve_meta_builds(&queue, &builder, &slot).await?;
|
|
|
|
while let Some(msg) = requests.next().await {
|
|
let received = tokio::time::Instant::now();
|
|
let Some(reply_to) = msg.reply.clone() else {
|
|
tracing::warn!("a build arrived with no reply subject; dropping it");
|
|
continue;
|
|
};
|
|
let queue = queue.clone();
|
|
let slot = Arc::clone(&slot);
|
|
let builder = Arc::clone(&builder);
|
|
tokio::spawn(async move {
|
|
let reply = match serde_json::from_slice::<BuildAgent>(&msg.payload) {
|
|
Ok(job) => {
|
|
let Some(reply) =
|
|
in_slot(&slot, received, || busy(&job), handle(&job, &builder)).await
|
|
else {
|
|
return;
|
|
};
|
|
reply
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "an undecodable build; answering with the error");
|
|
Reply::Failed {
|
|
error_tail: format!("the builder could not decode the request: {e}"),
|
|
}
|
|
}
|
|
};
|
|
match serde_json::to_vec(&reply) {
|
|
Ok(body) => {
|
|
if let Err(e) = queue.publish(reply_to, body.into()).await {
|
|
tracing::warn!(error = %e, "publishing a build's reply failed");
|
|
}
|
|
}
|
|
Err(e) => tracing::warn!(error = %e, "encoding a build's reply failed"),
|
|
}
|
|
});
|
|
}
|
|
bail!("the subscription to {SUBJECT} ended")
|
|
}
|
|
|
|
/// Answer every archetype evaluation request, one at a time, with the
|
|
/// evaluation's result.
|
|
async fn answer_evaluations(
|
|
mut requests: async_nats::Subscriber,
|
|
queue: async_nats::Client,
|
|
evaluator: archetypes::Evaluator,
|
|
) {
|
|
while let Some(msg) = requests.next().await {
|
|
let Some(reply_to) = msg.reply.clone() else {
|
|
tracing::warn!("an archetype evaluation arrived with no reply subject; dropping it");
|
|
continue;
|
|
};
|
|
let reply = evaluator.eval().await;
|
|
match &reply {
|
|
swarm_wire::builder::ArchetypesReply::Evaluated { archetypes, .. } => {
|
|
tracing::info!(count = archetypes.len(), "archetypes evaluated");
|
|
}
|
|
swarm_wire::builder::ArchetypesReply::Failed { error_tail } => {
|
|
tracing::warn!(%error_tail, "archetype evaluation failed");
|
|
}
|
|
}
|
|
match serde_json::to_vec(&reply) {
|
|
Ok(body) => {
|
|
if let Err(e) = queue.publish(reply_to, body.into()).await {
|
|
tracing::warn!(error = %e, "publishing an evaluation's reply failed");
|
|
}
|
|
}
|
|
Err(e) => tracing::warn!(error = %e, "encoding an evaluation's reply failed"),
|
|
}
|
|
}
|
|
tracing::error!("the subscription to {ARCHETYPES_SUBJECT} ended");
|
|
}
|
|
|
|
/// Answer every meta-flake lock request, one at a time, with the lock `nix`
|
|
/// wrote or why it did not.
|
|
async fn answer_locks(
|
|
mut requests: async_nats::Subscriber,
|
|
queue: async_nats::Client,
|
|
builder: Arc<nix_build::Builder>,
|
|
) {
|
|
while let Some(msg) = requests.next().await {
|
|
let Some(reply_to) = msg.reply.clone() else {
|
|
tracing::warn!("a meta-flake lock arrived with no reply subject; dropping it");
|
|
continue;
|
|
};
|
|
let reply = handle_lock(&msg.payload, &builder).await;
|
|
match serde_json::to_vec(&reply) {
|
|
Ok(body) => {
|
|
if let Err(e) = queue.publish(reply_to, body.into()).await {
|
|
tracing::warn!(error = %e, "publishing a lock's reply failed");
|
|
}
|
|
}
|
|
Err(e) => tracing::warn!(error = %e, "encoding a lock's reply failed"),
|
|
}
|
|
}
|
|
tracing::error!("the subscription to {LOCK_SUBJECT} ended");
|
|
}
|
|
|
|
/// Decode one lock request, lock it and log the lock.
|
|
async fn handle_lock(payload: &[u8], builder: &nix_build::Builder) -> LockReply {
|
|
let job: LockMeta = match serde_json::from_slice(payload) {
|
|
Ok(job) => job,
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "an undecodable lock; answering with the error");
|
|
return LockReply::Failed {
|
|
error_tail: format!("the builder could not decode the request: {e}"),
|
|
};
|
|
}
|
|
};
|
|
let reply = match within(TOKEN_DEADLINE, forge_token()).await {
|
|
Ok(token) => builder.lock(&job, &token).await,
|
|
Err(e) => LockReply::Failed {
|
|
error_tail: format!("{e:#}"),
|
|
},
|
|
};
|
|
match &reply {
|
|
LockReply::Locked { .. } => tracing::info!("swarm meta flake locked"),
|
|
LockReply::Failed { error_tail } => {
|
|
tracing::warn!(%error_tail, "swarm meta flake lock failed");
|
|
}
|
|
}
|
|
reply
|
|
}
|
|
|
|
/// Subscribe to meta build requests and answer them in the background.
|
|
async fn serve_meta_builds(
|
|
queue: &async_nats::Client,
|
|
builder: &Arc<nix_build::Builder>,
|
|
slot: &Arc<tokio::sync::Semaphore>,
|
|
) -> Result<()> {
|
|
let requests = queue
|
|
.queue_subscribe(META_SUBJECT, QUEUE_GROUP.to_owned())
|
|
.await
|
|
.with_context(|| format!("subscribing to {META_SUBJECT}"))?;
|
|
tokio::spawn(answer_meta_builds(
|
|
requests,
|
|
queue.clone(),
|
|
Arc::clone(builder),
|
|
Arc::clone(slot),
|
|
));
|
|
Ok(())
|
|
}
|
|
|
|
/// Answer every agent build from the meta flake, each in its own task, with the
|
|
/// build's result.
|
|
async fn answer_meta_builds(
|
|
mut requests: async_nats::Subscriber,
|
|
queue: async_nats::Client,
|
|
builder: Arc<nix_build::Builder>,
|
|
slot: Arc<tokio::sync::Semaphore>,
|
|
) {
|
|
while let Some(msg) = requests.next().await {
|
|
let received = tokio::time::Instant::now();
|
|
let Some(reply_to) = msg.reply.clone() else {
|
|
tracing::warn!("a meta build arrived with no reply subject; dropping it");
|
|
continue;
|
|
};
|
|
let (queue, builder, slot) = (queue.clone(), Arc::clone(&builder), Arc::clone(&slot));
|
|
tokio::spawn(async move {
|
|
let reply = match serde_json::from_slice::<BuildFromMeta>(&msg.payload) {
|
|
Ok(job) => {
|
|
let Some(reply) = in_slot(
|
|
&slot,
|
|
received,
|
|
|| busy_meta(&job),
|
|
handle_meta(&job, &builder, received + REPLY_DEADLINE - REPLY_MARGIN),
|
|
)
|
|
.await
|
|
else {
|
|
return;
|
|
};
|
|
reply
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "an undecodable meta build; answering with the error");
|
|
FromMetaReply::Failed {
|
|
error_tail: format!("the builder could not decode the request: {e}"),
|
|
}
|
|
}
|
|
};
|
|
match serde_json::to_vec(&reply) {
|
|
Ok(body) => {
|
|
if let Err(e) = queue.publish(reply_to, body.into()).await {
|
|
tracing::warn!(error = %e, "publishing a meta build's reply failed");
|
|
}
|
|
}
|
|
Err(e) => tracing::warn!(error = %e, "encoding a meta build's reply failed"),
|
|
}
|
|
});
|
|
}
|
|
tracing::error!("the subscription to {META_SUBJECT} ended");
|
|
}
|
|
|
|
/// `build`'s reply once a request holds the build slot, or `refuse`'s, without
|
|
/// running `build`, once it has waited [`MAX_SLOT_WAIT`] since `received`.
|
|
/// `None` only when the slot is closed.
|
|
async fn in_slot<T>(
|
|
slot: &tokio::sync::Semaphore,
|
|
received: tokio::time::Instant,
|
|
refuse: impl FnOnce() -> T,
|
|
build: impl Future<Output = T>,
|
|
) -> Option<T> {
|
|
let Ok(permit) = tokio::time::timeout_at(received + MAX_SLOT_WAIT, slot.acquire()).await else {
|
|
return Some(refuse());
|
|
};
|
|
let _permit = permit.ok()?;
|
|
Some(build.await)
|
|
}
|
|
|
|
/// The refusal text of a request that waited [`MAX_SLOT_WAIT`] for the slot.
|
|
fn busy_tail() -> String {
|
|
format!(
|
|
"the swarm builder was busy with another build for {} s, too long for this one \
|
|
to finish before swarm-controller stops waiting for it",
|
|
MAX_SLOT_WAIT.as_secs()
|
|
)
|
|
}
|
|
|
|
/// Log and answer a PR check that waited too long for the slot.
|
|
fn busy(job: &BuildAgent) -> Reply {
|
|
tracing::warn!(
|
|
agent = %job.agent,
|
|
rev = %job.rev,
|
|
pr = job.pr,
|
|
waited_secs = MAX_SLOT_WAIT.as_secs(),
|
|
"build refused: another build held the slot too long for this one to finish \
|
|
before swarm-controller stops waiting"
|
|
);
|
|
Reply::Failed {
|
|
error_tail: busy_tail(),
|
|
}
|
|
}
|
|
|
|
/// Log and answer a meta build that waited too long for the slot.
|
|
fn busy_meta(job: &BuildFromMeta) -> FromMetaReply {
|
|
tracing::warn!(
|
|
agent = %job.agent,
|
|
meta_rev = %job.meta_rev,
|
|
waited_secs = MAX_SLOT_WAIT.as_secs(),
|
|
"meta build refused: another build held the slot too long for this one to finish \
|
|
before swarm-controller stops waiting"
|
|
);
|
|
FromMetaReply::Unavailable {
|
|
reason: busy_tail(),
|
|
}
|
|
}
|
|
|
|
/// Build one meta request and log the build.
|
|
async fn handle_meta(
|
|
job: &BuildFromMeta,
|
|
builder: &nix_build::Builder,
|
|
reply_by: tokio::time::Instant,
|
|
) -> FromMetaReply {
|
|
let reply = match within(TOKEN_DEADLINE, forge_token()).await {
|
|
Ok(token) => builder.build_from_meta(job, &token).await,
|
|
Err(e) => FromMetaReply::Unavailable {
|
|
reason: format!("{e:#}"),
|
|
},
|
|
};
|
|
let reply = push::cache_built(
|
|
reply,
|
|
builder.cache.as_ref(),
|
|
attic_push_token(),
|
|
push_budget(reply_by),
|
|
&job.agent,
|
|
)
|
|
.await;
|
|
match &reply {
|
|
FromMetaReply::Built {
|
|
out_path,
|
|
system,
|
|
cached,
|
|
} => tracing::info!(
|
|
agent = %job.agent,
|
|
meta_rev = %job.meta_rev,
|
|
%out_path,
|
|
%system,
|
|
cached,
|
|
"agent meta build built"
|
|
),
|
|
FromMetaReply::Failed { error_tail } => tracing::warn!(
|
|
agent = %job.agent,
|
|
meta_rev = %job.meta_rev,
|
|
%error_tail,
|
|
"agent meta build failed"
|
|
),
|
|
FromMetaReply::Unavailable { reason } => tracing::warn!(
|
|
agent = %job.agent,
|
|
meta_rev = %job.meta_rev,
|
|
%reason,
|
|
"agent meta build not run"
|
|
),
|
|
}
|
|
reply
|
|
}
|
|
|
|
/// Build one request and log the build.
|
|
async fn handle(job: &BuildAgent, builder: &nix_build::Builder) -> Reply {
|
|
let reply = match within(TOKEN_DEADLINE, forge_token()).await {
|
|
Ok(token) => builder.build(job, &token).await,
|
|
Err(e) => Reply::Failed {
|
|
error_tail: format!("{e:#}"),
|
|
},
|
|
};
|
|
log_build(job, &reply);
|
|
reply
|
|
}
|
|
|
|
/// `read`'s result, or an error once it has run for `deadline`.
|
|
async fn within<T>(deadline: Duration, read: impl Future<Output = Result<T>>) -> Result<T> {
|
|
tokio::time::timeout(deadline, read)
|
|
.await
|
|
.unwrap_or_else(|_| {
|
|
Err(anyhow!(
|
|
"reading the forge token took more than {} s",
|
|
deadline.as_secs()
|
|
))
|
|
})
|
|
}
|
|
|
|
/// The one journal line a build leaves.
|
|
fn log_build(job: &BuildAgent, reply: &Reply) {
|
|
match reply {
|
|
Reply::Built { out_path } => tracing::info!(
|
|
agent = %job.agent,
|
|
rev = %job.rev,
|
|
pr = job.pr,
|
|
%out_path,
|
|
"agent config built"
|
|
),
|
|
Reply::Failed { error_tail } => tracing::warn!(
|
|
agent = %job.agent,
|
|
rev = %job.rev,
|
|
pr = job.pr,
|
|
%error_tail,
|
|
"agent config build failed"
|
|
),
|
|
}
|
|
}
|
|
|
|
/// The token the builder pushes closures to the cache with, read on every push so
|
|
/// a rotation takes effect on the next one.
|
|
async fn attic_push_token() -> Result<String> {
|
|
let path = swarm_secret_client::builder::attic_push_token_path()?;
|
|
let store = swarm_secret_client::SecretStore::from_env(CERT_ROLE)
|
|
.await
|
|
.context("logging in to the secret store")?;
|
|
let stored: Option<swarm_secret_client::builder::AtticPushToken> = store
|
|
.read_optional(&path)
|
|
.await
|
|
.with_context(|| format!("reading {path}"))?;
|
|
match stored {
|
|
Some(t) if !t.value.trim().is_empty() => Ok(t.value.trim().to_owned()),
|
|
_ => bail!("nothing at {path}: the cache push token has not been written"),
|
|
}
|
|
}
|
|
|
|
/// The forge token the builder fetches config repos with, read on every
|
|
/// build so a rotation takes effect on the next one.
|
|
async fn forge_token() -> Result<String> {
|
|
let path = swarm_secret_client::builder::forge_token_path()?;
|
|
let store = swarm_secret_client::SecretStore::from_env(CERT_ROLE)
|
|
.await
|
|
.context("logging in to the secret store")?;
|
|
let stored: Option<Credential> = store
|
|
.read_optional(&path)
|
|
.await
|
|
.with_context(|| format!("reading {path}"))?;
|
|
match stored {
|
|
Some(c) if !c.value.trim().is_empty() => Ok(c.value.trim().to_owned()),
|
|
_ => bail!("nothing at {path}: swarm-controller has not minted the builder's forge token"),
|
|
}
|
|
}
|
|
|
|
async fn client_secret(client_id: &str) -> Result<String> {
|
|
use swarm_secret_client::builder::{ClientSecret, client_secret_path};
|
|
|
|
let path = client_secret_path(client_id)?;
|
|
let store = swarm_secret_client::SecretStore::from_env(CERT_ROLE)
|
|
.await
|
|
.context("logging in to the secret store")?;
|
|
let stored: Option<ClientSecret> = store
|
|
.read_optional(&path)
|
|
.await
|
|
.with_context(|| format!("reading {path}"))?;
|
|
match stored {
|
|
Some(s) if !s.value.trim().is_empty() => Ok(s.value.trim().to_owned()),
|
|
_ => bail!(
|
|
"nothing at {path}: authelia's mint, in its container, has not \
|
|
stored this service's client secret"
|
|
),
|
|
}
|
|
}
|
|
|
|
/// The cache the unit's environment names, if any. systemd sets
|
|
/// `RUNTIME_DIRECTORY` for the unit's runtime directory.
|
|
fn push_target() -> Result<Option<push::Remote>> {
|
|
let var = |name: &str| std::env::var(name).ok();
|
|
push::Remote::from_settings(
|
|
var(&format!("{PREFIX}_ATTIC_DOMAIN")),
|
|
var(&format!("{PREFIX}_ATTIC_CACHE")),
|
|
var("RUNTIME_DIRECTORY"),
|
|
)
|
|
}
|
|
|
|
/// The forge's base URL, which must be `https`: the credential helper offers
|
|
/// the token to `https` requests for its host alone.
|
|
fn forge_url() -> Result<String> {
|
|
let name = format!("{PREFIX}_FORGE_URL");
|
|
let url = std::env::var(&name).with_context(|| format!("{name} is unset"))?;
|
|
let url = url.trim_end_matches('/');
|
|
if !url.starts_with("https://") || url["https://".len()..].contains('/') {
|
|
bail!("{name} is {url:?}, not https://<host>[:port]");
|
|
}
|
|
Ok(url.to_owned())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
|
use tokio::sync::Semaphore;
|
|
use tokio::time::Instant;
|
|
|
|
use super::*;
|
|
|
|
fn job() -> BuildAgent {
|
|
BuildAgent {
|
|
agent: "atlas".to_owned(),
|
|
rev: "abc".to_owned(),
|
|
pr: 3,
|
|
}
|
|
}
|
|
|
|
/// A request behind a build that holds the slot for `held`: its reply,
|
|
/// whether its own build ran, and how long after arriving it was answered.
|
|
async fn behind_a_build_of(held: Duration) -> (Option<Reply>, bool, Duration) {
|
|
let slot = Arc::new(Semaphore::new(1));
|
|
let running = Arc::clone(&slot).acquire_owned().await.expect("open");
|
|
tokio::spawn(async move {
|
|
tokio::time::sleep(held).await;
|
|
drop(running);
|
|
});
|
|
let received = Instant::now();
|
|
let ran = AtomicBool::new(false);
|
|
let build = async {
|
|
ran.store(true, Ordering::SeqCst);
|
|
Reply::Built {
|
|
out_path: "/nix/store/x".to_owned(),
|
|
}
|
|
};
|
|
let reply = in_slot(&slot, received, || busy(&job()), build).await;
|
|
(reply, ran.load(Ordering::SeqCst), received.elapsed())
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn a_request_that_waited_past_the_slot_limit_is_refused_without_building() {
|
|
let (reply, built, answered_after) =
|
|
behind_a_build_of(MAX_SLOT_WAIT + Duration::from_mins(1)).await;
|
|
assert!(
|
|
matches!(&reply, Some(Reply::Failed { error_tail }) if error_tail.contains("busy")),
|
|
"{reply:?}"
|
|
);
|
|
assert!(!built, "a refused request must not build");
|
|
assert_eq!(answered_after, MAX_SLOT_WAIT);
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn a_request_that_gets_the_slot_in_time_builds() {
|
|
let (reply, built, _) =
|
|
behind_a_build_of(MAX_SLOT_WAIT.saturating_sub(Duration::from_secs(1))).await;
|
|
assert!(matches!(reply, Some(Reply::Built { .. })), "{reply:?}");
|
|
assert!(built);
|
|
}
|
|
|
|
/// A meta build waits on the slot a PR check holds, and is refused at the
|
|
/// same limit.
|
|
#[tokio::test(start_paused = true)]
|
|
async fn a_meta_build_shares_the_slot_with_pr_checks() {
|
|
let meta = BuildFromMeta {
|
|
agent: "atlas".to_owned(),
|
|
meta_rev: "abc".to_owned(),
|
|
};
|
|
let slot = Arc::new(Semaphore::new(1));
|
|
let check = Arc::clone(&slot).acquire_owned().await.expect("open");
|
|
let received = Instant::now();
|
|
let refused = in_slot(&slot, received, || busy_meta(&meta), async {
|
|
FromMetaReply::Built {
|
|
out_path: "/nix/store/x".to_owned(),
|
|
system: "s".to_owned(),
|
|
cached: false,
|
|
}
|
|
})
|
|
.await;
|
|
assert!(
|
|
matches!(&refused, Some(FromMetaReply::Unavailable { reason }) if reason.contains("busy")),
|
|
"{refused:?}"
|
|
);
|
|
assert_eq!(received.elapsed(), MAX_SLOT_WAIT);
|
|
// Control: once the check lets go, the same request builds.
|
|
drop(check);
|
|
let built = in_slot(&slot, Instant::now(), || busy_meta(&meta), async {
|
|
FromMetaReply::Built {
|
|
out_path: "/nix/store/x".to_owned(),
|
|
system: "s".to_owned(),
|
|
cached: false,
|
|
}
|
|
})
|
|
.await;
|
|
assert!(
|
|
matches!(built, Some(FromMetaReply::Built { .. })),
|
|
"{built:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn a_token_read_past_its_deadline_is_an_error_and_a_prompt_one_is_kept() {
|
|
let started = Instant::now();
|
|
let stuck = within(TOKEN_DEADLINE, std::future::pending::<Result<String>>()).await;
|
|
assert_eq!(started.elapsed(), TOKEN_DEADLINE);
|
|
assert!(
|
|
matches!(&stuck, Err(e) if e.to_string().contains("took more than 20 s")),
|
|
"{stuck:?}"
|
|
);
|
|
let prompt = within(TOKEN_DEADLINE, async { Ok("t".to_owned()) }).await;
|
|
assert_eq!(prompt.expect("read"), "t");
|
|
}
|
|
|
|
#[tokio::test(start_paused = true)]
|
|
async fn a_push_gets_the_deadline_unless_the_reply_is_due_sooner() {
|
|
let now = Instant::now();
|
|
assert_eq!(
|
|
push_budget(now + Duration::from_hours(1)),
|
|
push::PUSH_DEADLINE
|
|
);
|
|
assert_eq!(
|
|
push_budget(now + Duration::from_secs(90)),
|
|
Duration::from_secs(90)
|
|
);
|
|
assert_eq!(push_budget(now), Duration::ZERO);
|
|
}
|
|
|
|
#[test]
|
|
fn a_build_started_at_the_slot_limit_ends_inside_the_reply_deadline() {
|
|
assert!(MAX_SLOT_WAIT > Duration::ZERO);
|
|
assert!(MAX_SLOT_WAIT + BUILD_DEADLINE < REPLY_DEADLINE);
|
|
assert!(SLOT_MARGIN >= Duration::from_secs(30));
|
|
assert!(SLOT_MARGIN >= TOKEN_DEADLINE + Duration::from_secs(10));
|
|
assert!(MAX_SLOT_WAIT + SLOT_MARGIN + BUILD_DEADLINE <= REPLY_DEADLINE);
|
|
}
|
|
}
|