zuka
zuka/src/control/mod.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
RSmod.rs40.4 KBDownload
1// The control plane.
2//
3// Owns accounts, provisions a container per account, and proxies every data-plane
4// request to the right one. It holds no repositories itself.
5//
6// Two rules shape everything here:
7//
8// * **Provisioning is never awaited by a request.** Creating an account writes a
9// `Provisioning` record and returns; a reconciler drives it to `Ready` or
10// `Failed`. Waiting synchronously on container boot ties a user-facing request
11// to a multi-second operation that can fail halfway.
12// * **The tenant is told who is calling by a signed, single-use, request-bound
13// assertion.** Not a shared secret: a tenant runs the account's own CI, so
14// anything symmetric in there forges every other tenant.
15
16pub mod accounts;
17pub mod provision;
18pub mod proxy;
19
20use crate::error::Result;
21use crate::git::validate::Name;
22use accounts::{AccountRecord, AccountStore};
23use provision::{Provisioner, State};
24use std::sync::Arc;
25use std::time::Duration;
26
27/// How often the reconciler sweeps accounts that are not yet `Ready`.
28const RECONCILE_INTERVAL: Duration = Duration::from_secs(10);
29
30/// Tenants upgraded per sweep.
31///
32/// Staggered rather than all at once: an upgrade restarts the tenant, and restarting
33/// every account simultaneously turns a routine deploy into an outage.
34const UPGRADES_PER_SWEEP: usize = 2;
35
36/// How long a tenant may sit unused before it is stopped.
37///
38/// Always-on would mean one permanently running container per account, including for
39/// people who push twice a year. Stopping is cheap and starting is a few seconds,
40/// which the caller sees as a `429` with a `Retry-After`.
41const IDLE_STOP: Duration = Duration::from_secs(1800);
42
43/// Drive every non-ready account forward, forever.
44pub async fn reconcile_forever(store: AccountStore, provisioner: Arc<dyn Provisioner>) {
45 eprintln!("[control] reconciler running every {RECONCILE_INTERVAL:?}");
46 loop {
47 let store = store.clone();
48 let provisioner = Arc::clone(&provisioner);
49 let outcome =
50 tokio::task::spawn_blocking(move || reconcile_once(&store, provisioner.as_ref())).await;
51
52 if let Err(e) = outcome {
53 // A panic in one sweep must not stop the reconciler; the next sweep is
54 // the retry.
55 eprintln!("[control] reconcile sweep failed: {e}");
56 }
57 tokio::time::sleep(RECONCILE_INTERVAL).await;
58 }
59}
60
61/// One reconciliation sweep. Returns how many accounts changed state.
62pub fn reconcile_once(store: &AccountStore, provisioner: &dyn Provisioner) -> usize {
63 reconcile_at(store, provisioner, crate::account::token::now_secs())
64}
65
66/// As [`reconcile_once`], with the clock injected so idle behaviour is testable.
67pub fn reconcile_at(store: &AccountStore, provisioner: &dyn Provisioner, now: u64) -> usize {
68 let mut changed = 0;
69 let mut upgraded = 0;
70
71 for mut record in store.list() {
72 if record.state == State::Ready {
73 // A ready record with no usable endpoint cannot serve anything: the
74 // proxy turns an empty URL into a 429 and a version-matched tenant
75 // would otherwise sit there forever because upgrades are the only
76 // ready-state probe. Re-observe Incus and repair the durable record.
77 if record.endpoint.as_deref().is_none_or(str::is_empty) {
78 let Ok(account) = crate::git::validate::name(&record.name) else {
79 continue;
80 };
81 let next = drive(&account, &record, provisioner);
82 if next.state != record.state
83 || next.endpoint != record.endpoint
84 || next.version != record.binary_version
85 {
86 record.state = next.state;
87 record.endpoint = next.endpoint;
88 record.detail = next.detail;
89 record.binary_version = next.version;
90 if store.put(&record).is_ok() {
91 changed += 1;
92 eprintln!("[control] {} endpoint repaired", record.name);
93 }
94 }
95 continue;
96 }
97
98 // A tenant running an older binary is upgraded in place. Stopped tenants
99 // are skipped: they pick the new binary up when they are next started,
100 // because `create` is idempotent and re-pushes.
101 if record.binary_version.as_deref() != Some(crate::brand::build())
102 && upgraded < UPGRADES_PER_SWEEP
103 {
104 let Ok(account) = crate::git::validate::name(&record.name) else {
105 continue;
106 };
107 match provisioner.upgrade(&account) {
108 Ok(()) => {
109 record.binary_version = Some(crate::brand::build().to_string());
110 if store.put(&record).is_ok() {
111 upgraded += 1;
112 changed += 1;
113 eprintln!(
114 "[control] {} upgraded to {}",
115 record.name,
116 crate::brand::build()
117 );
118 }
119 }
120 // Left at the old version so the next sweep retries. A failed
121 // upgrade must not mark the tenant as upgraded.
122 Err(e) => eprintln!("[control] could not upgrade {}: {e}", record.name),
123 }
124 continue;
125 }
126
127 // Stop a tenant nobody has used. It comes back on the next request.
128 if now.saturating_sub(record.last_seen_at) >= IDLE_STOP.as_secs() {
129 let Ok(account) = crate::git::validate::name(&record.name) else {
130 continue;
131 };
132 match provisioner.stop(&account) {
133 Ok(()) => {
134 record.state = State::Stopped;
135 if store.put(&record).is_ok() {
136 changed += 1;
137 eprintln!("[control] {} -> Stopped (idle)", record.name);
138 }
139 }
140 Err(e) => eprintln!("[control] could not stop {}: {e}", record.name),
141 }
142 }
143 continue;
144 }
145
146 // A stopped tenant is only restarted once someone asks for it again.
147 if record.state == State::Stopped
148 && now.saturating_sub(record.last_seen_at) >= IDLE_STOP.as_secs()
149 {
150 continue;
151 }
152 let Ok(account) = crate::git::validate::name(&record.name) else {
153 continue;
154 };
155
156 let next = drive(&account, &record, provisioner);
157 if next.state != record.state
158 || next.endpoint != record.endpoint
159 || next.version != record.binary_version
160 {
161 record.state = next.state;
162 record.endpoint = next.endpoint;
163 record.detail = next.detail;
164 record.binary_version = next.version;
165 if store.put(&record).is_ok() {
166 changed += 1;
167 eprintln!("[control] {} -> {:?}", record.name, record.state);
168 }
169 }
170 }
171 changed
172}
173
174/// What one account's record should become.
175struct Next {
176 state: State,
177 endpoint: Option<String>,
178 detail: Option<String>,
179 version: Option<String>,
180}
181
182fn drive(account: &Name, record: &AccountRecord, provisioner: &dyn Provisioner) -> Next {
183 // A failed account is not retried automatically: repeatedly relaunching a
184 // container that cannot start burns the host. It is retried when someone asks,
185 // through `retry`.
186 if record.state == State::Failed {
187 return Next {
188 state: State::Failed,
189 endpoint: record.endpoint.clone(),
190 detail: record.detail.clone(),
191 version: record.binary_version.clone(),
192 };
193 }
194
195 match provisioner.status(account) {
196 // Already up: adopt whatever the provisioner reports.
197 Ok(Some(instance)) if instance.state == State::Ready => Next {
198 state: State::Ready,
199 endpoint: Some(instance.endpoint),
200 detail: None,
201 version: record.binary_version.clone(),
202 },
203 // Exists but stopped, and someone wants it: start it.
204 Ok(Some(_)) => match provisioner.start(account) {
205 Ok(instance) => Next {
206 state: State::Ready,
207 endpoint: Some(instance.endpoint),
208 detail: None,
209 version: record.binary_version.clone(),
210 },
211 Err(e) => Next {
212 state: State::Provisioning,
213 endpoint: record.endpoint.clone(),
214 detail: Some(e.detail_for_user()),
215 version: record.binary_version.clone(),
216 },
217 },
218 // Not there at all: create it.
219 // A fresh container gets the binary we are running, by construction.
220 Ok(None) => match provisioner.create(account) {
221 Ok(instance) => Next {
222 state: instance.state,
223 endpoint: Some(instance.endpoint),
224 detail: None,
225 version: Some(crate::brand::build().to_string()),
226 },
227 Err(e) => Next {
228 state: State::Failed,
229 endpoint: None,
230 detail: Some(e.detail_for_user()),
231 version: None,
232 },
233 },
234 Err(e) => Next {
235 state: State::Provisioning,
236 endpoint: record.endpoint.clone(),
237 detail: Some(e.detail_for_user()),
238 version: record.binary_version.clone(),
239 },
240 }
241}
242
243/// Move a failed account back into the queue.
244pub fn retry(store: &AccountStore, account: &Name) -> Result<()> {
245 let mut record = store
246 .get(account)?
247 .ok_or(crate::error::Error::NotFound("account"))?;
248 record.state = State::Provisioning;
249 record.detail = None;
250 store.put(&record)
251}
252
253// ── serving ────────────────────────────────────────────────────────────────
254
255use crate::account::assertion::Signer_;
256use crate::config::Config;
257use crate::http::response::{self, Body};
258use hyper::body::Incoming;
259use hyper::service::service_fn;
260use hyper::{Method, Request, Response, StatusCode};
261use hyper_util::rt::{TokioIo, TokioTimer};
262use std::convert::Infallible;
263use tokio::net::TcpListener;
264
265/// What the control plane needs to answer a request.
266pub struct ControlState {
267 pub config: Config,
268 pub accounts: AccountStore,
269 pub signer: Signer_,
270 pub client: reqwest::Client,
271 pub provisioner: Arc<dyn Provisioner>,
272 /// The control plane is the authentication authority.
273 ///
274 /// A tenant holds no tokens and cannot check one — it trusts the signed
275 /// assertion instead. So the token store lives here, and this is where a
276 /// credential is turned into an account.
277 tokens: crate::store::cached::Cached<crate::account::token::TokenFile>,
278}
279
280impl ControlState {
281 /// Authorise an operator action: creating, listing or destroying accounts.
282 ///
283 /// These are not tenant operations. Creating an account provisions a container
284 /// and deleting one destroys its repositories, so they cannot be reachable with
285 /// an ordinary account's credential — a tenant admin token must not be able to
286 /// delete a different tenant — and they certainly cannot be reachable with none.
287 ///
288 /// The operator is named by `<PREFIX>OPERATOR`. When it is unset every account
289 /// endpoint is refused, because the alternative default is the one this replaced:
290 /// a public host where a stranger could enumerate every tenant and delete them.
291 fn require_operator(&self, headers: &hyper::HeaderMap) -> Result<()> {
292 let configured = std::env::var(crate::brand::env_name("OPERATOR")).ok();
293 let tokens = self.tokens.get();
294 let identity =
295 crate::http::auth::resolve_local(&tokens, &crate::http::auth::extract(headers))?;
296 operator_allows(configured.as_deref(), &identity)
297 }
298
299 /// Resolve a caller to the account whose tenant should serve them.
300 fn account_for(&self, headers: &hyper::HeaderMap) -> Result<crate::git::validate::Name> {
301 let tokens = self.tokens.get();
302 let identity =
303 crate::http::auth::resolve_local(&tokens, &crate::http::auth::extract(headers))?;
304 Ok(identity.account)
305 }
306}
307
308/// Whether an identity may perform an operator action.
309///
310/// Separated from the request so the rule can be tested without a running control
311/// plane, an Incus host or a token file — the thing that must not be wrong here is
312/// the decision, not the plumbing that reaches it.
313fn operator_allows(configured: Option<&str>, identity: &crate::account::Identity) -> Result<()> {
314 let Some(operator) = configured.filter(|v| !v.is_empty()) else {
315 // No operator configured means nobody may manage accounts. The alternative
316 // default is the one this replaced: a public host on which a stranger could
317 // enumerate every tenant and delete them.
318 return Err(crate::error::Error::Forbidden);
319 };
320 let operator = crate::git::validate::name(operator)?;
321
322 // `require_account_admin` carries both remaining conditions: the identity must
323 // own the account, and a token confined to a repository list never qualifies —
324 // a repo-scoped token has no business provisioning containers.
325 identity.require_account_admin(&operator)
326}
327
328/// How the control plane should present a Git request to a tenant.
329///
330/// A valid credential for another account is not a rejected credential. Public Git
331/// reads still work through credential helpers that happen to offer such a token,
332/// but no assertion is minted for it. A missing or bad credential may only take this
333/// anonymous path for an upload-pack read; receive-pack always requires a caller.
334#[derive(Debug, Clone, Copy, PartialEq, Eq)]
335enum GitCaller {
336 Vouched,
337 Anonymous,
338}
339
340fn git_caller(
341 claimed: &crate::git::validate::Name,
342 caller: Result<crate::git::validate::Name>,
343 offered: bool,
344 writes: bool,
345) -> Result<GitCaller> {
346 match caller {
347 Ok(account) if account == *claimed => Ok(GitCaller::Vouched),
348 Ok(_) if writes => Err(crate::error::Error::NotFound("repository")),
349 Ok(_) => Ok(GitCaller::Anonymous),
350 Err(_) if writes || offered => Err(crate::error::Error::Unauthorized),
351 Err(_) => Ok(GitCaller::Anonymous),
352 }
353}
354
355/// Serve the control plane.
356pub async fn serve(
357 config: Config,
358 accounts: AccountStore,
359 signer: Signer_,
360 provisioner: Arc<dyn Provisioner>,
361) -> anyhow::Result<()> {
362 let bind = config.bind;
363 let client = proxy::client(config.proxy_timeout)?;
364 let tokens = crate::store::cached::Cached::load(
365 config.tokens_file(),
366 crate::account::token::TokenFile::load,
367 )?;
368 let state = Arc::new(ControlState {
369 config,
370 accounts,
371 signer,
372 client,
373 provisioner,
374 tokens,
375 });
376
377 let listener = TcpListener::bind(bind).await?;
378 eprintln!("[control] listening on http://{bind}");
379
380 loop {
381 let (stream, _peer) = listener.accept().await?;
382 let state = Arc::clone(&state);
383 tokio::spawn(async move {
384 let io = TokioIo::new(stream);
385 let service = service_fn(move |req| handle(req, Arc::clone(&state)));
386 if let Err(e) = hyper::server::conn::http1::Builder::new()
387 .timer(TokioTimer::new())
388 .header_read_timeout(state_header_timeout())
389 .serve_connection(io, service)
390 .await
391 {
392 let message = e.to_string();
393 if !message.contains("closed") && !message.contains("reset") {
394 eprintln!("[control] conn: {message}");
395 }
396 }
397 });
398 }
399}
400
401fn state_header_timeout() -> Duration {
402 Duration::from_secs(15)
403}
404
405async fn handle(
406 request: Request<Incoming>,
407 state: Arc<ControlState>,
408) -> std::result::Result<Response<Body>, Infallible> {
409 let path = request.uri().path().to_string();
410 let method = request.method().clone();
411
412 let outcome = route(request, &state).await;
413 Ok(match outcome {
414 Ok(response) => response,
415 Err(error) => {
416 if let Some(cause) = error.cause() {
417 eprintln!("[control] {method} {path} -> {} {cause:#}", error.status());
418 }
419 response::problem(&error)
420 }
421 })
422}
423
424async fn route(request: Request<Incoming>, state: &ControlState) -> Result<Response<Body>> {
425 let path = request.uri().path().to_string();
426 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
427
428 match (request.method(), segments.as_slice()) {
429 (&Method::GET, ["healthz"]) => Ok(response::json(
430 StatusCode::OK,
431 &serde_json::json!({
432 "status": "ok",
433 "mode": "control",
434 "version": crate::brand::VERSION,
435 "build": crate::brand::build(),
436 "accounts": state.accounts.list().len(),
437 }),
438 )),
439
440 // Accounts are the control plane's own resource; everything else is proxied.
441 (&Method::GET, ["v1", "accounts"]) => {
442 state.require_operator(request.headers())?;
443 Ok(response::json(
444 StatusCode::OK,
445 &serde_json::json!({ "items": state.accounts.list(), "truncated": false }),
446 ))
447 }
448 (&Method::POST, ["v1", "accounts", name]) => {
449 state.require_operator(request.headers())?;
450 let account = crate::git::validate::name(name)?;
451 let record = state.accounts.create(&account)?;
452 Ok(response::json(StatusCode::ACCEPTED, &record))
453 }
454 (&Method::GET, ["v1", "accounts", name]) => {
455 state.require_operator(request.headers())?;
456 let account = crate::git::validate::name(name)?;
457 let record = state
458 .accounts
459 .get(&account)?
460 .ok_or(crate::error::Error::NotFound("account"))?;
461 Ok(response::json(StatusCode::OK, &record))
462 }
463 (&Method::DELETE, ["v1", "accounts", name]) => {
464 state.require_operator(request.headers())?;
465 let account = crate::git::validate::name(name)?;
466 if !state.accounts.delete(&account)? {
467 return Err(crate::error::Error::NotFound("account"));
468 }
469 // The container goes with the record. Provisioning is idempotent, so a
470 // failure here is retried by the next sweep rather than blocking the
471 // response.
472 let provisioner = Arc::clone(&state.provisioner);
473 let name = account.clone();
474 tokio::task::spawn_blocking(move || {
475 if let Err(e) = provisioner.delete(&name) {
476 eprintln!("[control] could not remove {name}'s tenant: {e}");
477 }
478 });
479 Ok(response::no_content())
480 }
481 (&Method::POST, ["v1", "accounts", name, "retry"]) => {
482 state.require_operator(request.headers())?;
483 let account = crate::git::validate::name(name)?;
484 retry(&state.accounts, &account)?;
485 Ok(response::no_content())
486 }
487
488 // Everything else belongs to a tenant. Which one is decided by the
489 // credential, not by the path: a path segment is a claim, a token is not.
490 (_, ["v1", ..]) | (_, ["mcp"]) => {
491 let account = state.account_for(request.headers())?;
492 let endpoint = state.accounts.endpoint(&account)?;
493 proxy::forward(
494 &state.client,
495 &state.signer,
496 &endpoint,
497 account.as_str(),
498 request,
499 state.config.max_body_bytes,
500 )
501 .await
502 }
503
504 // Git paths name the account. Writes must match the credential's account;
505 // anonymous upload-pack is the one public Git read path, so a public clone
506 // can reach the tenant without minting an assertion for a stranger. A bad
507 // presented credential stays rejected rather than quietly becoming a clone.
508 (_, [path_account, repo, ..]) if repo.ends_with(".git") => {
509 let claimed = crate::git::validate::name(path_account)?;
510 let route =
511 crate::git::transport::parse_route(&path, request.uri().query().unwrap_or(""))
512 .ok_or(crate::error::Error::NotFound("route"))?;
513 let offered = crate::http::auth::presented(request.headers());
514 match git_caller(
515 &claimed,
516 state.account_for(request.headers()),
517 offered,
518 route.writes,
519 )? {
520 GitCaller::Vouched => {
521 let endpoint = state.accounts.endpoint(&claimed)?;
522 proxy::forward(
523 &state.client,
524 &state.signer,
525 &endpoint,
526 claimed.as_str(),
527 request,
528 state.config.max_body_bytes,
529 )
530 .await
531 }
532 GitCaller::Anonymous => {
533 // A missing account must challenge like a private repository,
534 // or an unauthenticated upload-pack probe becomes an account
535 // enumeration endpoint. Existing provisioning/paused accounts
536 // still return their honest retry response.
537 let endpoint = match state.accounts.endpoint(&claimed) {
538 Ok(endpoint) => endpoint,
539 Err(crate::error::Error::NotFound(_)) => {
540 return Err(crate::error::Error::Unauthorized)
541 }
542 Err(error) => return Err(error),
543 };
544 proxy::forward_anonymous(
545 &state.client,
546 &endpoint,
547 request,
548 state.config.max_body_bytes,
549 )
550 .await
551 }
552 }
553 }
554
555 // The stylesheet is static and identical in every build, so the control
556 // plane answers it rather than waking a tenant for it.
557 (&Method::GET, _) if crate::web::is_style_path(&path) => {
558 Ok(response::css(crate::web::STYLE))
559 }
560
561 // `/` features one repository, which lives in a tenant like any other.
562 (&Method::GET, []) => match state.config.home_repo() {
563 Some((account, repo)) => Ok(response::redirect(&format!("/{account}/{repo}"))),
564 None => Ok(crate::web::welcome()),
565 },
566
567 // The machine-readable front door follows the same rule as `/`.
568 (&Method::GET, ["llms.txt"]) => match state.config.home_repo() {
569 Some((account, repo)) => Ok(response::redirect(&format!("/{account}/{repo}/llms.txt"))),
570 None => Ok(crate::web::service_llms()),
571 },
572
573 // The package origin's root belongs to the host, not to an account. It is
574 // the one quiet sentence on the package hostname, so do not try to extract
575 // a tenant account from the bare internal prefix below.
576 (&Method::GET, ["_pkg"]) => Ok(response::package_landing()),
577
578 // The package-only Caddy host rewrites an external module URL into this
579 // internal prefix. It names the account in its path like the browser, but
580 // unlike the browser it is *always* anonymous: package v1 serves public
581 // source only, so a bearer token can never accidentally become private
582 // package support or make a cache credential-dependent.
583 (&Method::GET, ["_pkg", ..]) => {
584 let claimed = crate::package::account_from_segments(&segments)?;
585 let endpoint = state.accounts.endpoint(&claimed)?;
586 proxy::forward_anonymous(
587 &state.client,
588 &endpoint,
589 request,
590 state.config.max_body_bytes,
591 )
592 .await
593 }
594
595 // The browser surface. Unlike the git and API paths, the account comes from
596 // the path rather than the credential, because the whole point is that a
597 // stranger with no credential can reach a public repository.
598 (&Method::GET, [path_account, _repo, ..]) if crate::web::owns(&path) => {
599 let claimed = crate::git::validate::name(path_account)?;
600 let endpoint = state.accounts.endpoint(&claimed)?;
601
602 // Vouch only when the credential belongs to the account being browsed.
603 // A token is worth nothing in another account's tenant, so presenting
604 // one must not turn into an assertion naming that other account — and
605 // an anonymous forward is exactly right for browsing someone else's
606 // public repository.
607 let vouch = match state.account_for(request.headers()) {
608 Ok(name) if name == claimed => true,
609 Ok(_) => false,
610 // No credential at all is the anonymous case. A credential that was
611 // presented and rejected is still an error.
612 Err(_) if !crate::http::auth::presented(request.headers()) => false,
613 Err(e) => return Err(e),
614 };
615
616 if vouch {
617 proxy::forward(
618 &state.client,
619 &state.signer,
620 &endpoint,
621 claimed.as_str(),
622 request,
623 state.config.max_body_bytes,
624 )
625 .await
626 } else {
627 proxy::forward_anonymous(
628 &state.client,
629 &endpoint,
630 request,
631 state.config.max_body_bytes,
632 )
633 .await
634 }
635 }
636
637 _ => Err(crate::error::Error::NotFound("route")),
638 }
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644 use provision::FakeProvisioner;
645 use std::sync::atomic::Ordering;
646
647 fn setup() -> (tempfile::TempDir, AccountStore, Arc<FakeProvisioner>) {
648 let dir = tempfile::tempdir().unwrap();
649 let store = AccountStore::open(dir.path()).unwrap();
650 (dir, store, Arc::new(FakeProvisioner::new()))
651 }
652
653 fn identity_for(
654 account: &str,
655 scopes: Vec<crate::account::token::Scope>,
656 ) -> crate::account::Identity {
657 crate::account::Identity {
658 account: crate::git::validate::name(account).unwrap(),
659 token_id: "t".into(),
660 scopes,
661 repos: Vec::new(),
662 }
663 }
664
665 #[test]
666 fn account_management_is_refused_when_no_operator_is_named() {
667 use crate::account::token::Scope;
668 // The default has to be closed. Creating an account provisions a container
669 // and deleting one destroys its repositories, and both were once reachable
670 // with no credential at all.
671 let admin = identity_for("alice", vec![Scope::Admin]);
672 assert!(operator_allows(None, &admin).is_err());
673 assert!(operator_allows(Some(""), &admin).is_err());
674 }
675
676 #[test]
677 fn only_the_named_operator_may_manage_accounts() {
678 use crate::account::token::Scope;
679 assert!(operator_allows(Some("ops"), &identity_for("ops", vec![Scope::Admin])).is_ok());
680
681 // A tenant's own admin token must not reach account management, or any
682 // account could destroy any other.
683 assert!(operator_allows(Some("ops"), &identity_for("alice", vec![Scope::Admin])).is_err());
684
685 // The operator account still needs admin scope.
686 assert!(operator_allows(Some("ops"), &identity_for("ops", vec![Scope::RepoRead])).is_err());
687 assert!(
688 operator_allows(Some("ops"), &identity_for("ops", vec![Scope::RepoWrite])).is_err()
689 );
690 }
691
692 #[test]
693 fn an_operator_token_confined_to_repositories_is_refused() {
694 use crate::account::token::Scope;
695 // Confinement must not be escapable by an operation that names no
696 // repository — provisioning containers is not repository work.
697 let confined = crate::account::Identity {
698 repos: vec![crate::git::validate::name("site").unwrap()],
699 ..identity_for("ops", vec![Scope::Admin])
700 };
701 assert!(operator_allows(Some("ops"), &confined).is_err());
702 }
703
704 #[test]
705 fn a_public_git_read_can_be_anonymous_but_a_write_never_can() {
706 let worklyn = crate::git::validate::name("worklyn").unwrap();
707 let alice = crate::git::validate::name("alice").unwrap();
708
709 assert_eq!(
710 git_caller(
711 &worklyn,
712 Err(crate::error::Error::Unauthorized),
713 false,
714 false
715 )
716 .unwrap(),
717 GitCaller::Anonymous
718 );
719 // Credential helpers often offer a valid token for a different account.
720 // That must not block a public clone or mint that account's assertion.
721 assert_eq!(
722 git_caller(&worklyn, Ok(alice), true, false).unwrap(),
723 GitCaller::Anonymous
724 );
725 // A bad presented token remains a visible authentication failure.
726 assert!(git_caller(
727 &worklyn,
728 Err(crate::error::Error::Unauthorized),
729 true,
730 false
731 )
732 .is_err());
733 // Neither a missing credential nor another account can reach receive-pack.
734 assert!(git_caller(
735 &worklyn,
736 Err(crate::error::Error::Unauthorized),
737 false,
738 true
739 )
740 .is_err());
741 assert!(git_caller(
742 &worklyn,
743 Ok(crate::git::validate::name("alice").unwrap()),
744 true,
745 true
746 )
747 .is_err());
748 }
749
750 #[test]
751 fn a_new_account_is_driven_to_ready_without_a_request_waiting() {
752 let (_dir, store, fake) = setup();
753 let alice = crate::git::validate::name("alice").unwrap();
754
755 // Creation records intent and returns; nothing is provisioned yet.
756 store.create(&alice).unwrap();
757 assert_eq!(
758 store.get(&alice).unwrap().unwrap().state,
759 State::Provisioning
760 );
761
762 // First sweep creates it, second observes it running.
763 reconcile_once(&store, fake.as_ref());
764 reconcile_once(&store, fake.as_ref());
765
766 let record = store.get(&alice).unwrap().unwrap();
767 assert_eq!(record.state, State::Ready);
768 assert!(record.endpoint.is_some(), "a ready tenant must be routable");
769 }
770
771 #[test]
772 fn a_ready_record_with_a_lost_endpoint_repairs_itself() {
773 // A record can survive an old provisioning race with state=ready but an
774 // empty endpoint. Without this repair a matching-version tenant is never
775 // probed again, and every proxy request returns a misleading 429 forever.
776 let (_dir, store, fake) = setup();
777 settle(&store, &fake, "alice");
778 let alice = crate::git::validate::name("alice").unwrap();
779 let mut record = store.get(&alice).unwrap().unwrap();
780 record.endpoint = Some(String::new());
781 store.put(&record).unwrap();
782
783 assert_eq!(reconcile_once(&store, fake.as_ref()), 1);
784 let repaired = store.get(&alice).unwrap().unwrap();
785 assert_eq!(repaired.state, State::Ready);
786 assert_eq!(
787 repaired.endpoint.as_deref(),
788 Some("http://127.0.0.1:9/alice")
789 );
790 }
791
792 #[test]
793 fn a_provisioning_failure_is_recorded_with_its_reason() {
794 let (_dir, store, fake) = setup();
795 let alice = crate::git::validate::name("alice").unwrap();
796 store.create(&alice).unwrap();
797
798 fake.fail_next.store(true, Ordering::SeqCst);
799 reconcile_once(&store, fake.as_ref());
800
801 let record = store.get(&alice).unwrap().unwrap();
802 assert_eq!(record.state, State::Failed);
803 assert!(record.detail.is_some(), "a failure must say why");
804 }
805
806 #[test]
807 fn a_failed_account_is_not_retried_until_asked() {
808 let (_dir, store, fake) = setup();
809 let alice = crate::git::validate::name("alice").unwrap();
810 store.create(&alice).unwrap();
811
812 fake.fail_next.store(true, Ordering::SeqCst);
813 reconcile_once(&store, fake.as_ref());
814 assert_eq!(store.get(&alice).unwrap().unwrap().state, State::Failed);
815
816 // Sweeping again must not relaunch: a container that cannot start would be
817 // relaunched forever.
818 assert_eq!(reconcile_once(&store, fake.as_ref()), 0);
819 assert_eq!(store.get(&alice).unwrap().unwrap().state, State::Failed);
820
821 retry(&store, &alice).unwrap();
822 reconcile_once(&store, fake.as_ref());
823 reconcile_once(&store, fake.as_ref());
824 assert_eq!(store.get(&alice).unwrap().unwrap().state, State::Ready);
825 }
826
827 #[test]
828 fn a_stopped_tenant_is_started_again() {
829 let (_dir, store, fake) = setup();
830 let alice = crate::git::validate::name("alice").unwrap();
831 store.create(&alice).unwrap();
832 reconcile_once(&store, fake.as_ref());
833 reconcile_once(&store, fake.as_ref());
834
835 fake.stop(&alice).unwrap();
836 let mut record = store.get(&alice).unwrap().unwrap();
837 record.state = State::Stopped;
838 store.put(&record).unwrap();
839
840 reconcile_once(&store, fake.as_ref());
841 assert_eq!(store.get(&alice).unwrap().unwrap().state, State::Ready);
842 }
843
844 /// Drive an account all the way to Ready on the current version.
845 fn settle(store: &AccountStore, fake: &FakeProvisioner, name: &str) {
846 let account = crate::git::validate::name(name).unwrap();
847 store.create(&account).unwrap();
848 reconcile_once(store, fake);
849 reconcile_once(store, fake);
850 let mut record = store.get(&account).unwrap().unwrap();
851 record.last_seen_at = crate::account::token::now_secs();
852 store.put(&record).unwrap();
853 }
854
855 #[test]
856 fn a_new_tenant_records_the_version_it_was_built_with() {
857 let (_dir, store, fake) = setup();
858 settle(&store, &fake, "alice");
859
860 let record = store
861 .get(&crate::git::validate::name("alice").unwrap())
862 .unwrap()
863 .unwrap();
864 assert_eq!(record.state, State::Ready);
865 assert_eq!(
866 record.binary_version.as_deref(),
867 Some(crate::brand::build())
868 );
869 assert!(
870 fake.upgrades.lock().unwrap().is_empty(),
871 "a container built from the current binary needs no upgrade"
872 );
873 }
874
875 #[test]
876 fn a_tenant_on_an_old_binary_is_upgraded_in_place() {
877 let (_dir, store, fake) = setup();
878 settle(&store, &fake, "alice");
879 let alice = crate::git::validate::name("alice").unwrap();
880
881 // What a deploy looks like: the control plane is newer than the tenant.
882 let mut record = store.get(&alice).unwrap().unwrap();
883 record.binary_version = Some("0.0.1-old".into());
884 store.put(&record).unwrap();
885
886 reconcile_once(&store, fake.as_ref());
887
888 assert_eq!(fake.upgrades.lock().unwrap().as_slice(), ["alice"]);
889 assert_eq!(
890 store
891 .get(&alice)
892 .unwrap()
893 .unwrap()
894 .binary_version
895 .as_deref(),
896 Some(crate::brand::build())
897 );
898 // Upgrading must not have disturbed the tenant's state.
899 assert_eq!(store.get(&alice).unwrap().unwrap().state, State::Ready);
900 }
901
902 #[test]
903 fn a_failed_upgrade_leaves_the_old_version_so_the_next_sweep_retries() {
904 use std::sync::atomic::Ordering;
905 let (_dir, store, fake) = setup();
906 settle(&store, &fake, "alice");
907 let alice = crate::git::validate::name("alice").unwrap();
908
909 let mut record = store.get(&alice).unwrap().unwrap();
910 record.binary_version = Some("0.0.1-old".into());
911 store.put(&record).unwrap();
912
913 fake.fail_next.store(true, Ordering::SeqCst);
914 reconcile_once(&store, fake.as_ref());
915 assert_eq!(
916 store
917 .get(&alice)
918 .unwrap()
919 .unwrap()
920 .binary_version
921 .as_deref(),
922 Some("0.0.1-old"),
923 "a failed upgrade must not be recorded as done"
924 );
925
926 reconcile_once(&store, fake.as_ref());
927 assert_eq!(
928 store
929 .get(&alice)
930 .unwrap()
931 .unwrap()
932 .binary_version
933 .as_deref(),
934 Some(crate::brand::build()),
935 "the next sweep must retry"
936 );
937 }
938
939 #[test]
940 fn upgrades_are_staggered_so_a_deploy_is_not_an_outage() {
941 let (_dir, store, fake) = setup();
942 let names = ["a1", "a2", "a3", "a4", "a5"];
943
944 // Settle every account first: `settle` reconciles, so backdating inside the
945 // loop would let those sweeps do the upgrading and hide the stagger.
946 for name in names {
947 settle(&store, &fake, name);
948 }
949 for name in names {
950 let account = crate::git::validate::name(name).unwrap();
951 let mut record = store.get(&account).unwrap().unwrap();
952 record.binary_version = Some("0.0.1-old".into());
953 store.put(&record).unwrap();
954 }
955 fake.upgrades.lock().unwrap().clear();
956
957 reconcile_once(&store, fake.as_ref());
958 assert_eq!(
959 fake.upgrades.lock().unwrap().len(),
960 UPGRADES_PER_SWEEP,
961 "restarting every tenant at once turns a deploy into an outage"
962 );
963
964 // Successive sweeps finish the rest.
965 for _ in 0..3 {
966 reconcile_once(&store, fake.as_ref());
967 }
968 assert_eq!(fake.upgrades.lock().unwrap().len(), 5);
969 }
970
971 #[test]
972 fn a_stopped_tenant_is_not_upgraded_until_it_is_needed() {
973 let (_dir, store, fake) = setup();
974 settle(&store, &fake, "alice");
975 let alice = crate::git::validate::name("alice").unwrap();
976
977 let mut record = store.get(&alice).unwrap().unwrap();
978 record.state = State::Stopped;
979 record.binary_version = Some("0.0.1-old".into());
980 store.put(&record).unwrap();
981
982 reconcile_once(&store, fake.as_ref());
983 assert!(
984 fake.upgrades.lock().unwrap().is_empty(),
985 "a stopped tenant picks up the new binary when it starts; \
986 restarting it now would wake it for nothing"
987 );
988 }
989
990 #[test]
991 fn an_idle_tenant_is_stopped_and_woken_by_the_next_request() {
992 let (_dir, store, fake) = setup();
993 let alice = crate::git::validate::name("alice").unwrap();
994 store.create(&alice).unwrap();
995 reconcile_once(&store, fake.as_ref());
996 reconcile_once(&store, fake.as_ref());
997 assert_eq!(store.get(&alice).unwrap().unwrap().state, State::Ready);
998
999 // Long enough that nobody has used it.
1000 let later = crate::account::token::now_secs() + IDLE_STOP.as_secs() + 1;
1001 reconcile_at(&store, fake.as_ref(), later);
1002 assert_eq!(store.get(&alice).unwrap().unwrap().state, State::Stopped);
1003
1004 // Asking for it refreshes the timestamp and reports "come back shortly".
1005 let err = store.endpoint(&alice).unwrap_err();
1006 assert_eq!(err.status(), 429);
1007
1008 reconcile_once(&store, fake.as_ref());
1009 assert_eq!(
1010 store.get(&alice).unwrap().unwrap().state,
1011 State::Ready,
1012 "a request must bring a stopped tenant back"
1013 );
1014 }
1015
1016 #[test]
1017 fn a_busy_tenant_is_not_stopped() {
1018 let (_dir, store, fake) = setup();
1019 let alice = crate::git::validate::name("alice").unwrap();
1020 store.create(&alice).unwrap();
1021 reconcile_once(&store, fake.as_ref());
1022 reconcile_once(&store, fake.as_ref());
1023
1024 // Used just now.
1025 let mut record = store.get(&alice).unwrap().unwrap();
1026 record.last_seen_at = crate::account::token::now_secs();
1027 store.put(&record).unwrap();
1028
1029 reconcile_once(&store, fake.as_ref());
1030 assert_eq!(store.get(&alice).unwrap().unwrap().state, State::Ready);
1031 }
1032
1033 #[test]
1034 fn a_ready_account_is_left_alone() {
1035 let (_dir, store, fake) = setup();
1036 let alice = crate::git::validate::name("alice").unwrap();
1037 store.create(&alice).unwrap();
1038 reconcile_once(&store, fake.as_ref());
1039 reconcile_once(&store, fake.as_ref());
1040
1041 assert_eq!(
1042 reconcile_once(&store, fake.as_ref()),
1043 0,
1044 "a settled account must not be touched every sweep"
1045 );
1046 }
1047}