1use std::fmt;
8use std::io::{self, Write};
9use std::sync::Arc;
10
11use crate::access::{Scope, Scopes};
12use crate::audit::{Actor, AuditError};
13use crate::backend;
14use crate::clock::{Clock, SystemClock};
15use crate::config::{Config, StorageError};
16use crate::datadir::{self, LockError, Sharing, Work};
17use crate::envelope::{Key, KeyError};
18use crate::fs::OsFs;
19use crate::ids::{KeyId, TenantId};
20use crate::retention::Retention;
21use crate::store::ExamineError;
22use crate::tenants::{
23 self, BackupError, OpenFailure, RegistryError, Removal, TenantError, TenantExamination,
24 Tenants, Unexamined,
25};
26use time::OffsetDateTime;
27use time::format_description::well_known::Rfc3339;
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum Command {
32 TenantCreate {
33 name: String,
34 retention: Option<String>,
35 },
36 TenantList,
37 TenantSetRetention {
39 id: String,
40 retention: String,
41 },
42 TenantRemove {
43 id: String,
44 force: bool,
45 override_holds: bool,
46 },
47 TenantSetMetadata {
49 id: String,
50 metadata: String,
51 },
52 StorageMoveObjects,
56 ExportParquet {
60 tenant: String,
61 to: String,
62 since: Option<String>,
63 until: Option<String>,
64 },
65 KeyCreate {
66 owner: Owner,
67 label: String,
68 },
69 KeyList {
70 tenant: Option<String>,
71 },
72 KeyRevoke {
73 id: String,
74 },
75 AuditExport {
76 log: Log,
77 since: Option<i64>,
78 },
79 RekeyTenant {
81 id: String,
82 },
83 RekeyMaster {
85 new_master: std::path::PathBuf,
86 },
87 Check,
89 Backup {
92 to: std::path::PathBuf,
93 },
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum Owner {
99 Tenant { id: String, scopes: Vec<String> },
100 Operator,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub enum Log {
106 Tenant(String),
107 Admin,
108}
109
110impl Command {
111 pub fn work(&self) -> Work {
113 match self {
114 Command::TenantCreate { .. } => Work::TenantCreate,
115 Command::TenantList => Work::TenantList,
116 Command::TenantSetRetention { .. } => Work::TenantSetRetention,
117 Command::TenantRemove { .. } => Work::TenantRemove,
118 Command::TenantSetMetadata { .. } => Work::TenantSetMetadata,
119 Command::ExportParquet { .. } => Work::ExportParquet,
120 Command::StorageMoveObjects => Work::StorageMoveObjects,
121 Command::KeyCreate { .. } => Work::KeyCreate,
122 Command::KeyList { .. } => Work::KeyList,
123 Command::KeyRevoke { .. } => Work::KeyRevoke,
124 Command::AuditExport { .. } => Work::AuditExport,
125 Command::RekeyTenant { .. } => Work::RekeyTenant,
126 Command::RekeyMaster { .. } => Work::RekeyMaster,
127 Command::Check => Work::Check,
128 Command::Backup { .. } => Work::Backup,
129 }
130 }
131}
132
133#[derive(Debug)]
135pub enum CommandError {
136 DataDir(LockError),
137 MasterKey(KeyError),
138 WrongMasterKey,
140 BadPeriod(String),
143 Tenant(TenantError),
144 Open(RegistryError),
146 UnknownKey,
148 Output(io::Error),
150 SameMasterKey,
152 CheckFound(usize),
154 Backup(BackupError),
155 Storage(StorageError),
157 BadLevel(String),
159 BadTime(String),
161 BadTarget(String),
163 NoS3,
166 Export(crate::export::ExportError),
167 Move {
170 tenant: TenantId,
171 why: String,
172 },
173}
174
175impl fmt::Display for CommandError {
176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177 match self {
178 CommandError::DataDir(e) => write!(f, "{e}"),
179 CommandError::MasterKey(e) => write!(f, "the master key cannot be read: {e:?}"),
180 CommandError::Open(e) => write!(
181 f,
182 "the registry cannot be opened: {}",
183 crate::tenants::with_causes(e)
184 ),
185 CommandError::WrongMasterKey | CommandError::Tenant(TenantError::WrongKey) => {
186 f.write_str("the master key is not the one server.sqlite was made under")
187 }
188 CommandError::BadPeriod(text) => write!(
189 f,
190 "{text:?} is not a period such as 7y, 18m or 90d (or, for set-retention, default)"
191 ),
192 CommandError::UnknownKey => f.write_str("no key has this id"),
193 CommandError::Output(e) => write!(f, "the output could not be written: {e}"),
194 CommandError::SameMasterKey => {
195 f.write_str("the new master key is the one already in use")
196 }
197 CommandError::CheckFound(n) => write!(f, "check found {n} problem(s); see its report"),
198 CommandError::Storage(e) => write!(f, "the storage cannot be used: {e}"),
199 CommandError::BadLevel(text) => write!(
200 f,
201 "{text:?} is not a metadata level: none, counts or full"
202 ),
203 CommandError::BadTime(text) => write!(
204 f,
205 "{text:?} is not an RFC 3339 time such as 2026-11-05T00:00:00Z"
206 ),
207 CommandError::BadTarget(text) => write!(
208 f,
209 "{text:?} is not a directory or an s3://bucket/prefix"
210 ),
211 CommandError::NoS3 => f.write_str(
212 "an s3:// target needs an [export.s3] or [storage.s3] table, in a build with the s3 feature",
213 ),
214 CommandError::Export(e) => write!(f, "{e}"),
215 CommandError::Move { tenant, why } => write!(
216 f,
217 "moving tenant {tenant}'s objects to the bucket stopped: {why}; what moved stays \
218 moved, and running storage move-objects again moves the rest"
219 ),
220 CommandError::Backup(e) => f.write_str(match e {
221 BackupError::Exists => {
222 "the backup's path already exists; a backup is written to a new directory"
223 }
224 BackupError::KeptChanging => {
225 "tenants were removed or versions purged during each try; try again"
226 }
227 BackupError::Io => "the backup could not be written, or the data could not be read",
228 BackupError::AuditFailed => "the audit record could not be written",
229 }),
230 CommandError::Tenant(e) => f.write_str(match e {
231 TenantError::NotFound => "no tenant has this id",
232 TenantError::NameTaken => "another tenant has this name",
233 TenantError::BadName => {
234 "a tenant name is 1 to 128 characters, with no control characters and no space at either end"
235 }
236 TenantError::HoldsVcons => "the tenant holds vCons; add --force to remove them too",
237 TenantError::UnderHold => {
238 "a vCon of the tenant is under a legal hold; only --override-holds removes it"
239 }
240 TenantError::BadScopes => {
241 "a tenant key's scopes are one or more of write, read, delete, legal and audit, each named once"
242 }
243 TenantError::LimitReached => {
244 "the master key has wrapped all the tenant keys it may; run rekey master"
245 }
246 TenantError::AuditFailed => "the audit record could not be written",
247 TenantError::Closing => "the tenant is being removed",
248 TenantError::CliOnly => "only the command line may do this",
249 TenantError::WrongKey => unreachable!("matched above"),
250 TenantError::Io => "the data could not be read or written",
251 }),
252 }
253 }
254}
255
256pub fn run(config: &Config, command: &Command, out: &mut dyn Write) -> Result<(), CommandError> {
259 let sharing = command.work().sharing();
260 let _lock = datadir::lock(&config.data_dir, sharing).map_err(CommandError::DataDir)?;
261 let master = Key::load_master(&config.master_key).map_err(CommandError::MasterKey)?;
262 if let Command::RekeyMaster { new_master } = command {
265 return rekey_master(config, &master, new_master, out);
266 }
267 let open = if sharing == Sharing::Beside || matches!(command, Command::Check) {
271 Tenants::open_beside_in
272 } else {
273 Tenants::open_in
274 };
275 let objects =
276 backend::objects_for(&config.storage, &config.data_dir).map_err(CommandError::Storage)?;
277 #[cfg(feature = "s3")]
279 let bucket = match &objects {
280 backend::ObjectPlace::S3(s3) => Some(s3.clone()),
281 backend::ObjectPlace::Local => None,
282 };
283 let tenants = open(
284 &config.data_dir,
285 master,
286 Arc::new(OsFs),
287 Arc::new(SystemClock),
288 objects,
289 )
290 .map_err(|e| match e {
291 RegistryError::WrongKey => CommandError::WrongMasterKey,
292 other => CommandError::Open(other),
293 })?;
294 let who = Actor::Cli;
295 match command {
296 Command::TenantCreate { name, retention } => {
297 let retention = retention.as_deref().map(period).transpose()?;
298 let id = tenants.create(who, name, retention)?;
299 line(out, &serde_json::json!({ "id": id.to_string() }))
300 }
301 Command::TenantList => {
302 for tenant in tenants.list(who)? {
303 line(out, &tenant.to_json())?;
304 }
305 Ok(())
306 }
307 Command::TenantSetRetention { id, retention } => {
308 let retention = match retention.as_str() {
309 "default" => None,
310 text => Some(period(text)?),
311 };
312 let id = tenant_id(id)?;
313 tenants.set_retention(who, id, retention)?;
314 line(
315 out,
316 &serde_json::json!({
317 "id": id.to_string(),
318 "retention": retention.map(|r| r.to_string()),
319 }),
320 )
321 }
322 Command::TenantRemove {
323 id,
324 force,
325 override_holds,
326 } => {
327 let removal = match (force, override_holds) {
328 (_, true) => Removal::ForceOverridingHolds,
329 (true, false) => Removal::Force,
330 (false, false) => Removal::Empty,
331 };
332 Ok(tenants.remove(who, tenant_id(id)?, removal)?)
333 }
334 Command::KeyCreate { owner, label } => {
335 let (tenant, scopes) = match owner {
336 Owner::Tenant { id, scopes } => (
337 Some(tenant_id(id)?),
338 Scopes::named(scopes).ok_or(CommandError::Tenant(TenantError::BadScopes))?,
339 ),
340 Owner::Operator => (None, Scopes::of(&[Scope::Admin])),
341 };
342 let made = tenants.create_key(who, tenant, scopes, label)?;
343 line(
344 out,
345 &serde_json::json!({ "id": made.id.to_string(), "token": made.token }),
346 )
347 }
348 Command::KeyList { tenant } => {
349 let tenant = tenant.as_deref().map(tenant_id).transpose()?;
350 for key in tenants.list_keys(who, tenant)? {
351 let shown = key.to_json().ok_or(CommandError::Tenant(TenantError::Io))?;
352 line(out, &shown)?;
353 }
354 Ok(())
355 }
356 Command::KeyRevoke { id } => {
357 let id = KeyId::parse(id).ok_or(CommandError::UnknownKey)?;
358 tenants.revoke_key(who, id).map_err(|e| match e {
359 TenantError::NotFound => CommandError::UnknownKey,
360 other => CommandError::Tenant(other),
361 })
362 }
363 Command::TenantSetMetadata { id, metadata } => {
364 let level = crate::export::Metadata::parse(metadata)
365 .ok_or_else(|| CommandError::BadLevel(metadata.clone()))?;
366 let id = tenant_id(id)?;
367 tenants.set_metadata(who, id, level)?;
368 line(
369 out,
370 &serde_json::json!({ "id": id.to_string(), "metadata": level.as_str() }),
371 )
372 }
373 Command::ExportParquet {
374 tenant,
375 to,
376 since,
377 until,
378 } => export_parquet(
379 config,
380 &tenants,
381 tenant,
382 to,
383 since.as_deref(),
384 until.as_deref(),
385 out,
386 ),
387 Command::RekeyTenant { id } => Ok(tenants.rekey_tenant(who, tenant_id(id)?)?),
388 Command::RekeyMaster { .. } => unreachable!("rotated before the registry is opened"),
389 Command::Check => check(config, &tenants, out),
390 #[cfg(feature = "s3")]
391 Command::StorageMoveObjects => match bucket {
392 Some(s3) => move_objects(config, &tenants, &s3, out),
393 None => Err(not_a_bucket()),
394 },
395 #[cfg(not(feature = "s3"))]
396 Command::StorageMoveObjects => Err(not_a_bucket()),
397 Command::Backup { to } => {
398 let done = tenants.backup(who, to).map_err(CommandError::Backup)?;
399 line(
400 out,
401 &serde_json::json!({
402 "tenants": done.tenants,
403 "versions": done.versions,
404 "tries": done.tries,
405 }),
406 )
407 }
408 Command::AuditExport { log, since } => {
409 let exported = match log {
410 Log::Admin => tenants.export_admin(*since, out),
411 Log::Tenant(id) => tenants.store(tenant_id(id)?)?.export(*since, out),
412 };
413 exported.map_err(|e| match e {
414 AuditError::Write(e) => CommandError::Output(e),
415 AuditError::Sqlite(_) => CommandError::Tenant(TenantError::Io),
416 })
417 }
418 }
419}
420
421impl From<TenantError> for CommandError {
422 fn from(e: TenantError) -> CommandError {
423 CommandError::Tenant(e)
424 }
425}
426
427fn export_parquet(
430 config: &Config,
431 tenants: &Tenants,
432 tenant: &str,
433 to: &str,
434 since: Option<&str>,
435 until: Option<&str>,
436 out: &mut dyn Write,
437) -> Result<(), CommandError> {
438 use crate::export::target::Place;
439 use crate::export::{self, Plan};
440 let time = |text: Option<&str>| {
441 text.map(|t| {
442 OffsetDateTime::parse(t, &Rfc3339)
443 .map(crate::clock::nanos)
444 .map_err(|_| CommandError::BadTime(t.to_owned()))
445 })
446 .transpose()
447 };
448 let (since, until) = (time(since)?, time(until)?);
449 let place = Place::parse(to).ok_or_else(|| CommandError::BadTarget(to.to_owned()))?;
450 let id = tenant_id(tenant)?;
451 let level = tenants.metadata(id)?;
452 let target: Box<dyn export::Target> = match place {
453 Place::Dir(dir) => Box::new(export::DirTarget::new(&dir, Arc::new(OsFs))),
454 Place::S3 { bucket, prefix } => s3_target(config, &bucket, &prefix)?,
455 };
456 let store = tenants.store(id)?;
457 let plan = Plan {
458 level,
459 since,
460 until,
461 sizes: export::SIZES,
462 limits: vcon::read::Limits::new(config.inflated_limit),
463 };
464 let done = export::export(&store, &plan, target.as_ref(), SystemClock.now())
465 .map_err(CommandError::Export)?;
466 line(
467 out,
468 &serde_json::json!({
469 "tenant": id.to_string(),
470 "metadata": level.as_str(),
471 "to": target.describe(),
472 "manifest": done.manifest,
473 "seq": done.seq,
474 "files": done.files.len(),
475 "rows": done.rows,
476 "bytes": done.bytes,
477 "skipped": done.skipped,
478 "unread": done.unread,
479 }),
480 )
481}
482
483#[cfg(feature = "s3")]
486fn s3_target(
487 config: &Config,
488 bucket: &str,
489 prefix: &str,
490) -> Result<Box<dyn crate::export::Target>, CommandError> {
491 use crate::config::{ExportS3, ObjectStore};
492 let (service, table) = match (&config.export.s3, &config.storage.objects) {
493 (Some(service), _) => (service.clone(), "export.s3"),
494 (None, ObjectStore::S3(storage)) => (ExportS3::from(storage), "storage.s3"),
495 (None, ObjectStore::Local) => return Err(CommandError::NoS3),
496 };
497 let target = crate::export::s3::S3Target::connect(&service.for_bucket(bucket, prefix), table)
498 .map_err(CommandError::Storage)?;
499 Ok(Box::new(target))
500}
501
502#[cfg(not(feature = "s3"))]
503fn s3_target(
504 _config: &Config,
505 _bucket: &str,
506 _prefix: &str,
507) -> Result<Box<dyn crate::export::Target>, CommandError> {
508 Err(CommandError::NoS3)
509}
510
511fn rekey_master(
513 config: &Config,
514 master: &Key,
515 new_master: &std::path::Path,
516 out: &mut dyn Write,
517) -> Result<(), CommandError> {
518 let new = Key::load_master(new_master).map_err(CommandError::MasterKey)?;
519 if new.id() == master.id() {
520 return Err(CommandError::SameMasterKey);
521 }
522 tenants::rekey_master(&config.data_dir, master, &new, SystemClock.now()).map_err(
523 |e| match e {
524 TenantError::WrongKey => CommandError::WrongMasterKey,
525 other => CommandError::Tenant(other),
526 },
527 )?;
528 line(
529 out,
530 &serde_json::json!({
531 "master_key_id": new.id().to_string(),
532 "next": "point master_key in the configuration at the new key file, start the server, then destroy every copy of the old master key",
533 }),
534 )
535}
536
537fn not_a_bucket() -> CommandError {
542 CommandError::Storage(StorageError(
543 "storage move-objects moves the objects to the bucket that storage.objects = \"s3\" \
544 names; this configuration keeps them in the data directory"
545 .to_owned(),
546 ))
547}
548
549#[cfg(feature = "s3")]
554fn move_objects(
555 config: &Config,
556 tenants: &Tenants,
557 s3: &Arc<crate::backend::s3::S3>,
558 out: &mut dyn Write,
559) -> Result<(), CommandError> {
560 use crate::backend::migrate::MoveError;
561 let mut total = 0;
562 for id in tenants.ids()? {
563 let dir = config
564 .data_dir
565 .join("tenants")
566 .join(id.to_string())
567 .join("objects");
568 let moved = if dir.exists() {
569 let from = crate::backend::local::LocalObjects::new(dir, Arc::new(OsFs));
570 crate::backend::migrate::move_objects(&from, &s3.tenant(id)).map_err(|e| {
571 let why = match e {
572 MoveError::From(e) => {
573 format!("an object file could not be read or removed: {e}")
574 }
575 MoveError::To(e) => format!("the bucket could not be written or read: {e}"),
576 MoveError::Differs(version) => format!(
577 "version {version} read back from the bucket differs from its file, \
578 which stays in the data directory"
579 ),
580 };
581 CommandError::Move { tenant: id, why }
582 })?
583 } else {
584 0
585 };
586 let missing = tenants
587 .store(id)?
588 .check()
589 .map_err(|e| CommandError::Move {
590 tenant: id,
591 why: format!("the objects in the bucket could not be checked: {e:?}"),
592 })?
593 .len();
594 total += moved;
595 line(
596 out,
597 &serde_json::json!({ "tenant": id.to_string(), "moved": moved, "missing": missing }),
598 )?;
599 }
600 line(out, &serde_json::json!({ "moved": total }))
601}
602
603fn check(config: &Config, tenants: &Tenants, out: &mut dyn Write) -> Result<(), CommandError> {
604 let now = SystemClock.now();
605 let found = tenants.examine(config.retention_default, now)?;
606 let time = |at: Option<OffsetDateTime>| at.and_then(|at| at.format(&Rfc3339).ok());
607 let mut total = 0;
608 for tenant in &found.tenants {
609 let named = problems(
610 tenant,
611 config.purge_interval,
612 now,
613 config.storage.parquet.is_some(),
614 );
615 total += named.len();
616 let store = tenant.store.as_ref().ok();
617 line(
618 out,
619 &serde_json::json!({
620 "tenant": tenant.id.to_string(),
621 "name": tenant.name,
622 "versions": store.map(|s| s.versions),
623 "packed": store.map(|s| s.packed),
624 "damaged": store.map(|s| s.damaged.iter().map(ToString::to_string).collect::<Vec<_>>()),
625 "leftovers": store.map(|s| &s.leftovers),
626 "past_due": store.map(|s| s.due),
627 "oldest_past_due": time(store.and_then(|s| s.oldest_due_since)),
628 "purges_awaiting_rotation": store.map(|s| s.purges_since_key),
629 "oldest_purge_awaiting_rotation": time(store.and_then(|s| s.oldest_purge_since_key)),
630 "rotation_stopped": tenant.rotation_stopped,
631 "problems": named,
632 }),
633 )?;
634 }
635 line(
636 out,
637 &serde_json::json!({
638 "tenants": found.tenants.len(),
639 "removed_tenants_awaiting_master_rotation": found.removed_since_master,
640 "oldest_removal_awaiting_master_rotation": time(found.oldest_removed_since_master),
641 "problems": total,
642 }),
643 )?;
644 match total {
645 0 => Ok(()),
646 n => Err(CommandError::CheckFound(n)),
647 }
648}
649
650pub fn problems(
657 tenant: &TenantExamination,
658 purge_interval: std::time::Duration,
659 now: OffsetDateTime,
660 parquet: bool,
661) -> Vec<String> {
662 let mut found = Vec::new();
663 if tenant.rotation_stopped {
664 found.push("a key rotation was stopped part way; run rekey tenant again".to_owned());
665 }
666 match &tenant.store {
667 Err(Unexamined::Open(OpenFailure::WrongKey)) => {
668 found.push("its catalog does not open under its tenant key".to_owned());
669 }
670 Err(Unexamined::Open(OpenFailure::Access { path, error })) => {
671 found.push(format!(
672 "its catalog cannot be opened: {}: {error}",
673 path.display()
674 ));
675 }
676 Err(Unexamined::Open(OpenFailure::Refused(e))) => {
677 found.push(format!("its catalog cannot be opened: {e:?}"));
678 }
679 Err(Unexamined::Examine(ExamineError::Objects { kind, os_error })) => {
680 let number = os_error
681 .map(|n| format!(" (os error {n})"))
682 .unwrap_or_default();
683 found.push(format!("its objects cannot be read: {kind}{number}"));
684 }
685 Err(Unexamined::Examine(ExamineError::Failed(e))) => {
686 found.push(format!("it could not be examined: {e:?}"));
687 }
688 Ok(store) => {
689 if store.packed > 0 && !parquet {
690 found.push(format!(
691 "{} version(s) are packed into Parquet files, and the configuration has no \
692 [storage.parquet]: serve refuses to start until the table is put back",
693 store.packed
694 ));
695 }
696 for version in &store.damaged {
697 found.push(format!("version {version} is missing or does not open"));
698 }
699 if let Some(since) = store.oldest_due_since
700 && now - since > purge_interval
701 {
702 found.push(format!(
703 "{} version(s) due for purge, the oldest longer than purge_interval: the sweep is not keeping up",
704 store.due
705 ));
706 }
707 }
708 }
709 found
710}
711
712fn period(text: &str) -> Result<Retention, CommandError> {
714 Retention::parse(text).ok_or_else(|| CommandError::BadPeriod(text.to_owned()))
715}
716
717fn tenant_id(text: &str) -> Result<TenantId, CommandError> {
720 TenantId::parse(text).ok_or(CommandError::Tenant(TenantError::NotFound))
721}
722
723fn line(out: &mut dyn Write, value: &serde_json::Value) -> Result<(), CommandError> {
725 writeln!(out, "{value}").map_err(CommandError::Output)
726}
727
728#[cfg(test)]
729mod tests {
730 use serde_json::{Value, json};
731
732 use super::*;
733 use crate::store::Caller;
734 use crate::store::testing::vcon;
735
736 const UUID: &str = "01a07da8-c2bb-83e5-b9a2-279e0d16bc46";
737
738 struct Setup {
739 _dir: tempfile::TempDir,
740 config: Config,
741 }
742
743 fn setup() -> Setup {
744 let dir = tempfile::tempdir().expect("temp dir");
745 let data = dir.path().join("data");
746 std::fs::create_dir(&data).expect("data dir");
747 let master = dir.path().join("master.key");
748 std::fs::write(&master, [0x5a; 32]).expect("master key file");
749 let config = crate::config::parse(&format!(
750 "listen = \"127.0.0.1:1\"\ndata_dir = {data:?}\nmaster_key = {master:?}\n\
751 [tls]\ncert = \"c\"\nkey = \"k\"\n"
752 ))
753 .expect("a configuration");
754 Setup { _dir: dir, config }
755 }
756
757 impl Setup {
758 fn run(&self, command: Command) -> Result<Vec<Value>, CommandError> {
760 let mut out = Vec::new();
761 run(&self.config, &command, &mut out)?;
762 Ok(String::from_utf8(out)
763 .expect("text")
764 .lines()
765 .map(|line| serde_json::from_str(line).expect("a JSON line"))
766 .collect())
767 }
768
769 fn create(&self, name: &str) -> String {
770 let out = self
771 .run(Command::TenantCreate {
772 name: name.to_owned(),
773 retention: None,
774 })
775 .expect("made");
776 out[0]["id"].as_str().expect("an id").to_owned()
777 }
778
779 fn tenants(&self) -> Tenants {
781 Tenants::open_beside(
782 &self.config.data_dir,
783 Key::load_master(&self.config.master_key).expect("master"),
784 Arc::new(OsFs),
785 Arc::new(SystemClock),
786 )
787 .expect("opens")
788 }
789
790 fn tenant_dirs(&self) -> Vec<String> {
791 let mut names: Vec<String> = std::fs::read_dir(self.config.data_dir.join("tenants"))
792 .expect("tenants dir")
793 .map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
794 .collect();
795 names.sort();
796 names
797 }
798 }
799
800 fn tenant_error(result: Result<Vec<Value>, CommandError>) -> TenantError {
801 match result {
802 Err(CommandError::Tenant(e)) => e,
803 other => panic!("not a tenant error: {other:?}"),
804 }
805 }
806
807 #[test]
808 fn tenant_create_prints_the_new_id_and_list_shows_it() {
809 let s = setup();
810 let out = s
811 .run(Command::TenantCreate {
812 name: "acme".to_owned(),
813 retention: Some("18m".to_owned()),
814 })
815 .expect("made");
816 assert_eq!(out.len(), 1);
817 let id = out[0]["id"].as_str().expect("an id").to_owned();
818 assert!(TenantId::parse(&id).is_some(), "{id}");
819 assert_eq!(out[0], json!({ "id": id }));
820 let beta = s.create("beta");
821 assert_eq!(
822 s.run(Command::TenantList).expect("listed"),
823 [
824 json!({ "id": id, "name": "acme", "retention": "18m", "metadata": "none" }),
825 json!({ "id": beta, "name": "beta", "retention": null, "metadata": "none" }),
826 ]
827 );
828 }
829
830 #[test]
831 fn a_bad_period_is_refused_before_anything_is_made() {
832 let s = setup();
833 let error = s
834 .run(Command::TenantCreate {
835 name: "acme".to_owned(),
836 retention: Some("7w".to_owned()),
837 })
838 .expect_err("refused");
839 assert!(
840 matches!(&error, CommandError::BadPeriod(p) if p == "7w"),
841 "{error:?}"
842 );
843 assert!(error.to_string().contains("7w"), "{error}");
844 assert_eq!(
845 s.run(Command::TenantList).expect("listed"),
846 Vec::<Value>::new()
847 );
848 let id = s.create("acme");
849 let error = s
850 .run(Command::TenantSetRetention {
851 id,
852 retention: "forever".to_owned(),
853 })
854 .expect_err("refused");
855 assert!(matches!(&error, CommandError::BadPeriod(p) if p == "forever"));
856 }
857
858 #[test]
859 fn set_retention_takes_a_period_or_default() {
860 let s = setup();
861 let id = s.create("acme");
862 let set = |retention: &str| {
863 s.run(Command::TenantSetRetention {
864 id: id.clone(),
865 retention: retention.to_owned(),
866 })
867 .expect("set")
868 };
869 assert_eq!(set("90d"), [json!({ "id": id, "retention": "90d" })]);
870 assert_eq!(
871 s.run(Command::TenantList).expect("listed")[0]["retention"],
872 "90d"
873 );
874 assert_eq!(set("default"), [json!({ "id": id, "retention": null })]);
875 assert_eq!(
876 s.run(Command::TenantList).expect("listed")[0]["retention"],
877 Value::Null
878 );
879 }
880
881 #[test]
882 fn an_unknown_or_malformed_tenant_is_not_found() {
883 let s = setup();
884 for id in [TenantId::generate().to_string(), "not-an-id".to_owned()] {
885 assert_eq!(
886 tenant_error(s.run(Command::TenantSetRetention {
887 id: id.clone(),
888 retention: "7y".to_owned()
889 })),
890 TenantError::NotFound
891 );
892 assert_eq!(
893 tenant_error(s.run(Command::TenantRemove {
894 id: id.clone(),
895 force: true,
896 override_holds: false
897 })),
898 TenantError::NotFound
899 );
900 assert_eq!(
901 tenant_error(s.run(Command::KeyCreate {
902 owner: Owner::Tenant {
903 id: id.clone(),
904 scopes: vec!["read".to_owned()]
905 },
906 label: "l".to_owned()
907 })),
908 TenantError::NotFound
909 );
910 assert_eq!(
911 tenant_error(s.run(Command::KeyList {
912 tenant: Some(id.clone())
913 })),
914 TenantError::NotFound
915 );
916 assert_eq!(
917 tenant_error(s.run(Command::AuditExport {
918 log: Log::Tenant(id.clone()),
919 since: None
920 })),
921 TenantError::NotFound
922 );
923 }
924 }
925
926 #[test]
929 fn remove_goes_as_far_as_its_flags_allow() {
930 let s = setup();
931 let empty = s.create("empty");
932 let full = s.create("full");
933 {
934 let tenants = s.tenants();
935 let store = tenants
936 .store(TenantId::parse(&full).expect("id"))
937 .expect("store");
938 let caller = Caller { key: KeyId([1; 8]) };
939 let (found, body) = vcon(UUID, "kept");
940 store.create(&caller, &found, &body).expect("stored");
941 store.set_hold(&caller, found.uuid, true).expect("held");
942 }
943 let remove = |id: &str, force, override_holds| {
944 s.run(Command::TenantRemove {
945 id: id.to_owned(),
946 force,
947 override_holds,
948 })
949 };
950 assert_eq!(
951 remove(&empty, false, false).expect("removed"),
952 Vec::<Value>::new()
953 );
954 assert_eq!(
955 tenant_error(remove(&full, false, false)),
956 TenantError::HoldsVcons
957 );
958 assert_eq!(
959 tenant_error(remove(&full, true, false)),
960 TenantError::UnderHold
961 );
962 assert_eq!(
963 remove(&full, true, true).expect("removed"),
964 Vec::<Value>::new()
965 );
966 assert_eq!(
967 s.run(Command::TenantList).expect("listed"),
968 Vec::<Value>::new()
969 );
970 assert_eq!(s.tenant_dirs(), Vec::<String>::new());
971 }
972
973 #[test]
974 fn keys_are_made_listed_and_revoked() {
975 let s = setup();
976 let tenant = s.create("acme");
977 let made = s
978 .run(Command::KeyCreate {
979 owner: Owner::Tenant {
980 id: tenant.clone(),
981 scopes: vec!["write".to_owned(), "read".to_owned()],
982 },
983 label: "producer".to_owned(),
984 })
985 .expect("made");
986 let id = made[0]["id"].as_str().expect("id").to_owned();
987 let token = made[0]["token"].as_str().expect("token").to_owned();
988 assert_eq!(made.len(), 1);
989 assert_eq!(made[0].as_object().expect("object").len(), 2);
990 assert!(token.starts_with(&format!("vcs_{id}_")), "{token}");
991 let operator = s
992 .run(Command::KeyCreate {
993 owner: Owner::Operator,
994 label: "ops".to_owned(),
995 })
996 .expect("made")[0]["id"]
997 .as_str()
998 .expect("id")
999 .to_owned();
1000
1001 let listed = s.run(Command::KeyList { tenant: None }).expect("listed");
1002 assert_eq!(listed.len(), 2);
1003 assert_eq!(listed[0]["id"], id.as_str());
1004 assert_eq!(listed[0]["tenant"], tenant.as_str());
1005 assert_eq!(listed[0]["scopes"], json!(["write", "read"]));
1006 assert_eq!(listed[0]["label"], "producer");
1007 assert_eq!(listed[0]["revoked_at"], Value::Null);
1008 assert!(
1009 listed[0]["created_at"]
1010 .as_str()
1011 .is_some_and(|t| t.ends_with('Z'))
1012 );
1013 assert!(
1014 listed.iter().all(|k| k.get("token").is_none()),
1015 "never a token"
1016 );
1017 assert_eq!(listed[1]["id"], operator.as_str());
1018 assert_eq!(listed[1]["tenant"], Value::Null);
1019 assert_eq!(listed[1]["scopes"], json!(["admin"]));
1020
1021 let own = s
1022 .run(Command::KeyList {
1023 tenant: Some(tenant.clone()),
1024 })
1025 .expect("listed");
1026 assert_eq!(own, listed[..1]);
1027
1028 assert_eq!(
1029 s.run(Command::KeyRevoke { id: id.clone() })
1030 .expect("revoked"),
1031 Vec::<Value>::new()
1032 );
1033 let listed = s.run(Command::KeyList { tenant: None }).expect("listed");
1034 assert!(listed[0]["revoked_at"].as_str().is_some(), "{listed:?}");
1035 assert_eq!(listed[1]["revoked_at"], Value::Null);
1036 }
1037
1038 #[test]
1039 fn bad_scopes_and_unknown_keys_are_refused() {
1040 let s = setup();
1041 let tenant = s.create("acme");
1042 for scopes in [
1043 vec!["superuser"],
1044 vec!["read", "superuser"],
1045 vec!["read", "read"],
1046 vec!["admin"],
1047 vec![],
1048 ] {
1049 let error = s.run(Command::KeyCreate {
1050 owner: Owner::Tenant {
1051 id: tenant.clone(),
1052 scopes: scopes.iter().map(|s| (*s).to_owned()).collect(),
1053 },
1054 label: "l".to_owned(),
1055 });
1056 assert_eq!(tenant_error(error), TenantError::BadScopes, "{scopes:?}");
1057 }
1058 assert_eq!(
1059 s.run(Command::KeyList { tenant: None }).expect("listed"),
1060 Vec::<Value>::new()
1061 );
1062 for id in ["0123456789abcdef".to_owned(), "nope".to_owned()] {
1063 assert!(
1064 matches!(
1065 s.run(Command::KeyRevoke { id }),
1066 Err(CommandError::UnknownKey)
1067 ),
1068 "unknown"
1069 );
1070 }
1071 }
1072
1073 #[test]
1074 fn audit_export_writes_either_log_from_a_sequence_number() {
1075 let s = setup();
1076 let tenant = s.create("acme");
1077 s.run(Command::TenantList).expect("listed");
1078 s.run(Command::KeyCreate {
1079 owner: Owner::Tenant {
1080 id: tenant.clone(),
1081 scopes: vec!["read".to_owned()],
1082 },
1083 label: "l".to_owned(),
1084 })
1085 .expect("made");
1086
1087 let admin = s
1088 .run(Command::AuditExport {
1089 log: Log::Admin,
1090 since: None,
1091 })
1092 .expect("exported");
1093 let actions: Vec<&str> = admin
1094 .iter()
1095 .map(|r| r["action"].as_str().expect("action"))
1096 .collect();
1097 assert_eq!(actions, ["tenant_create", "tenant_list", "key_create"]);
1098 assert!(admin.iter().all(|r| r["actor"] == "cli"));
1099 let since = admin[0]["seq"].as_i64().expect("seq");
1100 let later = s
1101 .run(Command::AuditExport {
1102 log: Log::Admin,
1103 since: Some(since),
1104 })
1105 .expect("exported");
1106 assert_eq!(later, admin[1..]);
1107
1108 let own = s
1109 .run(Command::AuditExport {
1110 log: Log::Tenant(tenant),
1111 since: None,
1112 })
1113 .expect("exported");
1114 let actions: Vec<&str> = own
1115 .iter()
1116 .map(|r| r["action"].as_str().expect("action"))
1117 .collect();
1118 assert_eq!(actions, ["key_create"]);
1119 }
1120
1121 #[test]
1122 fn rekey_tenant_rotates_the_key_and_prints_nothing() {
1123 let s = setup();
1124 let id = s.create("acme");
1125 let tenant = TenantId::parse(&id).expect("id");
1126 let before = s.tenants().tenant_key_id_for_test(tenant);
1127 assert_eq!(
1128 s.run(Command::RekeyTenant { id: id.clone() })
1129 .expect("rekeyed"),
1130 Vec::<Value>::new()
1131 );
1132 assert_ne!(s.tenants().tenant_key_id_for_test(tenant), before);
1133 assert_eq!(
1134 tenant_error(s.run(Command::RekeyTenant {
1135 id: TenantId::generate().to_string()
1136 })),
1137 TenantError::NotFound
1138 );
1139 let server = datadir::lock(&s.config.data_dir, Sharing::Serve).expect("a server");
1140 assert!(matches!(
1141 s.run(Command::RekeyTenant { id }),
1142 Err(CommandError::DataDir(LockError::InUse(Sharing::Alone)))
1143 ));
1144 drop(server);
1145 }
1146
1147 #[test]
1148 fn rekey_master_moves_the_data_to_the_new_master_key() {
1149 let mut s = setup();
1150 s.create("acme");
1151 let new_file = s.config.master_key.with_file_name("new.key");
1152 std::fs::write(&new_file, [0x5c; 32]).expect("new key");
1153 assert!(matches!(
1154 s.run(Command::RekeyMaster {
1155 new_master: s.config.master_key.clone()
1156 }),
1157 Err(CommandError::SameMasterKey)
1158 ));
1159 let server = datadir::lock(&s.config.data_dir, Sharing::Serve).expect("a server");
1160 assert!(matches!(
1161 s.run(Command::RekeyMaster {
1162 new_master: new_file.clone()
1163 }),
1164 Err(CommandError::DataDir(LockError::InUse(Sharing::Alone)))
1165 ));
1166 drop(server);
1167 let out = s
1168 .run(Command::RekeyMaster {
1169 new_master: new_file.clone(),
1170 })
1171 .expect("rotated");
1172 let new_id = Key::load_master(&new_file).expect("key").id().to_string();
1173 assert_eq!(out.len(), 1);
1174 assert_eq!(out[0]["master_key_id"], new_id.as_str());
1175 assert!(
1176 out[0]["next"]
1177 .as_str()
1178 .is_some_and(|n| n.contains("destroy"))
1179 );
1180 assert!(matches!(
1181 s.run(Command::TenantList),
1182 Err(CommandError::WrongMasterKey)
1183 ));
1184 s.config.master_key = new_file;
1185 assert_eq!(s.run(Command::TenantList).expect("listed").len(), 1);
1186 }
1187
1188 mod check {
1189 use time::macros::datetime;
1190
1191 use super::*;
1192 use crate::ids::VersionId;
1193 use crate::store::{Examination, StoreError};
1194
1195 const NOW: OffsetDateTime = datetime!(2026-10-25 12:00 UTC);
1196 const HOUR: std::time::Duration = std::time::Duration::from_secs(3600);
1197
1198 fn sound() -> Examination {
1199 Examination {
1200 versions: 3,
1201 packed: 0,
1202 damaged: vec![],
1203 leftovers: vec!["a.tmp".to_owned()],
1204 due: 0,
1205 oldest_due_since: None,
1206 purges_since_key: 4,
1207 oldest_purge_since_key: Some(datetime!(2026-10-01 00:00 UTC)),
1208 }
1209 }
1210
1211 fn tenant(store: Result<Examination, Unexamined>, stopped: bool) -> TenantExamination {
1212 TenantExamination {
1213 id: TenantId([1; 16]),
1214 name: "acme".to_owned(),
1215 rotation_stopped: stopped,
1216 store,
1217 }
1218 }
1219
1220 #[test]
1221 fn leftovers_and_work_waiting_for_rotation_are_not_problems() {
1222 assert_eq!(
1223 problems(&tenant(Ok(sound()), false), HOUR, NOW, true),
1224 Vec::<String>::new()
1225 );
1226 }
1227
1228 #[test]
1229 fn each_problem_is_named() {
1230 let damaged = Examination {
1231 damaged: vec![VersionId([2; 16]), VersionId([3; 16])],
1232 ..sound()
1233 };
1234 assert_eq!(
1235 problems(&tenant(Ok(damaged), false), HOUR, NOW, true),
1236 [
1237 "version 02020202020202020202020202020202 is missing or does not open",
1238 "version 03030303030303030303030303030303 is missing or does not open",
1239 ],
1240 "one line per damaged version, in its order"
1241 );
1242 assert_eq!(
1243 problems(&tenant(Ok(sound()), true), HOUR, NOW, true),
1244 ["a key rotation was stopped part way; run rekey tenant again"],
1245 "a stopped rotation is named with the command that finishes it"
1246 );
1247 assert_eq!(
1248 problems(
1249 &tenant(
1250 Err(Unexamined::Open(OpenFailure::Refused(TenantError::Io))),
1251 false
1252 ),
1253 HOUR,
1254 NOW,
1255 true,
1256 ),
1257 ["its catalog cannot be opened: Io"],
1258 "a refused catalog names the refusal, not a key"
1259 );
1260 }
1261
1262 #[test]
1266 fn a_key_problem_and_an_access_problem_are_told_apart() {
1267 let key = problems(
1268 &tenant(Err(Unexamined::Open(OpenFailure::WrongKey)), false),
1269 HOUR,
1270 NOW,
1271 true,
1272 );
1273 assert_eq!(key, ["its catalog does not open under its tenant key"]);
1274 let access = OpenFailure::Access {
1275 path: std::path::PathBuf::from("/srv/data/tenants/01/catalog.sqlite"),
1276 error: std::io::Error::from_raw_os_error(libc::EACCES),
1277 };
1278 assert_eq!(
1279 problems(
1280 &tenant(Err(Unexamined::Open(access)), false),
1281 HOUR,
1282 NOW,
1283 true
1284 ),
1285 [
1286 "its catalog cannot be opened: /srv/data/tenants/01/catalog.sqlite: Permission denied (os error 13)"
1287 ]
1288 );
1289 }
1290
1291 #[test]
1295 fn packed_versions_without_storage_parquet_are_a_problem() {
1296 let packed = Examination {
1297 packed: 2,
1298 ..sound()
1299 };
1300 assert_eq!(
1301 problems(&tenant(Ok(packed.clone()), false), HOUR, NOW, false),
1302 [
1303 "2 version(s) are packed into Parquet files, and the configuration has no [storage.parquet]: serve refuses to start until the table is put back"
1304 ],
1305 "without the table"
1306 );
1307 assert_eq!(
1308 problems(&tenant(Ok(packed), false), HOUR, NOW, true),
1309 Vec::<String>::new(),
1310 "with the table"
1311 );
1312 assert_eq!(
1313 problems(&tenant(Ok(sound()), false), HOUR, NOW, false),
1314 Vec::<String>::new(),
1315 "nothing packed"
1316 );
1317 }
1318
1319 #[test]
1324 fn objects_that_cannot_be_read_are_told_apart_from_a_catalog() {
1325 let unread = |kind, os_error| {
1326 problems(
1327 &tenant(
1328 Err(Unexamined::Examine(ExamineError::Objects {
1329 kind,
1330 os_error,
1331 })),
1332 false,
1333 ),
1334 HOUR,
1335 NOW,
1336 true,
1337 )
1338 };
1339 assert_eq!(
1340 unread(std::io::ErrorKind::PermissionDenied, Some(libc::EACCES)),
1341 ["its objects cannot be read: permission denied (os error 13)"],
1342 "a file the process may not read"
1343 );
1344 assert_eq!(
1345 unread(std::io::ErrorKind::TimedOut, None),
1346 ["its objects cannot be read: timed out"],
1347 "a service's failure has a kind and no system error number"
1348 );
1349 assert_eq!(
1350 problems(
1351 &tenant(
1352 Err(Unexamined::Examine(ExamineError::Failed(StoreError::Io))),
1353 false
1354 ),
1355 HOUR,
1356 NOW,
1357 true,
1358 ),
1359 ["it could not be examined: Io"],
1360 "any other failure, with its error"
1361 );
1362 }
1363
1364 #[test]
1365 fn a_sweep_is_stalled_only_once_a_version_waited_longer_than_the_interval() {
1366 let waited = |since| Examination {
1367 due: 1,
1368 oldest_due_since: Some(since),
1369 ..sound()
1370 };
1371 let ok = waited(datetime!(2026-10-25 11:00 UTC));
1372 assert_eq!(
1373 problems(&tenant(Ok(ok), false), HOUR, NOW, true),
1374 Vec::<String>::new()
1375 );
1376 let stalled = waited(datetime!(2026-10-25 10:59:59 UTC));
1377 assert_eq!(
1378 problems(&tenant(Ok(stalled), false), HOUR, NOW, true),
1379 [
1380 "1 version(s) due for purge, the oldest longer than purge_interval: the sweep is not keeping up"
1381 ],
1382 "one second past purge_interval is a stalled sweep"
1383 );
1384 }
1385
1386 #[test]
1387 fn check_reports_each_tenant_and_a_summary_and_exits_1_on_a_problem() {
1388 let s = setup();
1389 let id = s.create("acme");
1390 let out = s.run(Command::Check).expect("nothing wrong");
1391 assert_eq!(out.len(), 2);
1392 assert_eq!(out[0]["tenant"], id.as_str());
1393 assert_eq!(out[0]["problems"], json!([]));
1394 assert_eq!(out[1]["tenants"], 1);
1395 assert_eq!(out[1]["problems"], 0);
1396
1397 let tenant = TenantId::parse(&id).expect("id");
1398 let version = {
1399 let tenants = s.tenants();
1400 let store = tenants.store(tenant).expect("store");
1401 let (found, body) = vcon(UUID, "kept");
1402 let Ok(crate::store::Created::New(v)) =
1403 store.create(&Caller { key: KeyId([1; 8]) }, &found, &body)
1404 else {
1405 panic!("stored")
1406 };
1407 v
1408 };
1409 let objects = s.config.data_dir.join("tenants").join(&id).join("objects");
1410 std::fs::remove_file(objects.join(version.to_string())).expect("remove");
1411 std::fs::write(objects.join(format!("{version}.tmp")), b"x").expect("leftover");
1412 let mut printed = Vec::new();
1413 let found = run(&s.config, &Command::Check, &mut printed);
1414 assert!(
1415 matches!(found, Err(CommandError::CheckFound(1))),
1416 "{found:?}"
1417 );
1418 let lines: Vec<Value> = String::from_utf8(printed)
1419 .expect("text")
1420 .lines()
1421 .map(|l| serde_json::from_str(l).expect("json"))
1422 .collect();
1423 assert_eq!(lines[0]["damaged"], json!([version.to_string()]));
1424 assert_eq!(lines[0]["leftovers"], json!([format!("{version}.tmp")]));
1425 assert_eq!(lines[1]["problems"], 1);
1426 assert!(
1427 objects.join(format!("{version}.tmp")).exists(),
1428 "check reports leftovers; it does not remove them"
1429 );
1430 let server = datadir::lock(&s.config.data_dir, Sharing::Serve).expect("a server");
1431 assert!(matches!(
1432 s.run(Command::Check),
1433 Err(CommandError::DataDir(LockError::InUse(Sharing::Alone)))
1434 ));
1435 drop(server);
1436 }
1437 }
1438
1439 #[test]
1442 fn beside_a_server_only_the_beside_commands_run() {
1443 let s = setup();
1444 let tenant = s.create("acme");
1445 let server = datadir::lock(&s.config.data_dir, Sharing::Serve).expect("a server");
1446 for command in [
1447 Command::TenantCreate {
1448 name: "beta".to_owned(),
1449 retention: None,
1450 },
1451 Command::TenantRemove {
1452 id: tenant.clone(),
1453 force: true,
1454 override_holds: false,
1455 },
1456 ] {
1457 assert!(
1458 matches!(
1459 s.run(command.clone()),
1460 Err(CommandError::DataDir(LockError::InUse(Sharing::Alone)))
1461 ),
1462 "{command:?}"
1463 );
1464 }
1465 s.run(Command::TenantList).expect("beside");
1466 s.run(Command::TenantSetRetention {
1467 id: tenant.clone(),
1468 retention: "1y".to_owned(),
1469 })
1470 .expect("beside");
1471 s.run(Command::KeyList { tenant: None }).expect("beside");
1472 let made = s
1473 .run(Command::KeyCreate {
1474 owner: Owner::Operator,
1475 label: "l".to_owned(),
1476 })
1477 .expect("beside");
1478 s.run(Command::KeyRevoke {
1479 id: made[0]["id"].as_str().expect("an id").to_owned(),
1480 })
1481 .expect("beside");
1482 s.run(Command::AuditExport {
1483 log: Log::Admin,
1484 since: None,
1485 })
1486 .expect("beside");
1487 s.run(Command::Backup {
1488 to: s._dir.path().join("backup"),
1489 })
1490 .expect("beside");
1491 drop(server);
1492 assert_eq!(s.run(Command::TenantList).expect("listed").len(), 1);
1493 }
1494
1495 #[test]
1498 fn only_a_command_that_runs_alone_recovers() {
1499 let s = setup();
1500 s.create("acme");
1501 let in_progress = TenantId::generate().to_string();
1502 std::fs::create_dir(s.config.data_dir.join("tenants").join(&in_progress))
1503 .expect("a directory with no row");
1504 s.run(Command::TenantList).expect("listed");
1505 s.run(Command::KeyList { tenant: None }).expect("listed");
1506 assert!(
1507 s.tenant_dirs().contains(&in_progress),
1508 "left by beside commands"
1509 );
1510 s.create("beta");
1511 assert!(
1512 !s.tenant_dirs().contains(&in_progress),
1513 "removed by an alone command"
1514 );
1515 }
1516
1517 #[test]
1518 fn a_wrong_master_key_is_named() {
1519 let s = setup();
1520 s.create("acme");
1521 std::fs::write(&s.config.master_key, [0x5b; 32]).expect("another key");
1522 let error = s.run(Command::TenantList).expect_err("refused");
1523 assert!(matches!(error, CommandError::WrongMasterKey), "{error:?}");
1524 assert!(error.to_string().contains("master key"), "{error}");
1525 std::fs::write(&s.config.master_key, [0x5a; 31]).expect("short key");
1526 let error = s.run(Command::TenantList).expect_err("refused");
1527 assert!(matches!(error, CommandError::MasterKey(_)), "{error:?}");
1528 }
1529
1530 #[test]
1531 fn backup_prints_what_it_copied_and_check_finds_the_copy_whole() {
1532 let s = setup();
1533 s.create("acme");
1534 s.create("globex");
1535 let to = s._dir.path().join("backup");
1536 let out = s.run(Command::Backup { to: to.clone() }).expect("backs up");
1537 assert_eq!(
1538 out,
1539 [serde_json::json!({ "tenants": 2, "versions": 0, "tries": 1 })]
1540 );
1541 let copy = Setup {
1542 _dir: tempfile::tempdir().expect("temp dir"),
1543 config: Config {
1544 data_dir: to,
1545 ..s.config.clone()
1546 },
1547 };
1548 let checked = copy.run(Command::Check).expect("check finds no problem");
1549 assert_eq!(checked.last().expect("a summary")["problems"], 0);
1550 let names: Vec<_> = copy
1551 .run(Command::TenantList)
1552 .expect("lists")
1553 .iter()
1554 .map(|t| t["name"].clone())
1555 .collect();
1556 assert_eq!(names, ["acme", "globex"]);
1557 }
1558
1559 #[test]
1560 fn a_backup_path_already_there_is_refused() {
1561 let s = setup();
1562 let to = s._dir.path().join("backup");
1563 std::fs::create_dir(&to).expect("dir");
1564 let error = s.run(Command::Backup { to }).expect_err("refused");
1565 assert!(
1566 matches!(error, CommandError::Backup(BackupError::Exists)),
1567 "{error:?}"
1568 );
1569 }
1570
1571 fn says(error: &CommandError) -> &'static str {
1575 match error {
1576 CommandError::DataDir(_) => "the data directory is in use",
1577 CommandError::MasterKey(_) => "the master key cannot be read",
1578 CommandError::Open(_) => "opening /srv/data/server.sqlite failed: disk I/O error",
1580 CommandError::WrongMasterKey => "is not the one server.sqlite was made under",
1581 CommandError::BadPeriod(_) => "\"7w\" is not a period",
1582 CommandError::UnknownKey => "no key has this id",
1583 CommandError::Output(_) => "the output could not be written",
1584 CommandError::SameMasterKey => "already in use",
1585 CommandError::CheckFound(_) => "check found 2 problem(s)",
1586 CommandError::Storage(_) => "the storage cannot be used: bucket \"vcon\"",
1587 CommandError::BadLevel(_) => "\"most\" is not a metadata level",
1588 CommandError::BadTime(_) => "\"yesterday\" is not an RFC 3339 time",
1589 CommandError::BadTarget(_) => "\"s3://\" is not a directory or an s3://bucket/prefix",
1590 CommandError::NoS3 => "[export.s3] or [storage.s3]",
1591 CommandError::Export(_) => "another export wrote",
1592 CommandError::Move { .. } => "running storage move-objects again moves the rest",
1593 CommandError::Backup(e) => match e {
1594 BackupError::Exists => "already exists",
1595 BackupError::KeptChanging => "try again",
1596 BackupError::Io => "could not be written",
1597 BackupError::AuditFailed => "audit record",
1598 },
1599 CommandError::Tenant(e) => match e {
1600 TenantError::NotFound => "no tenant",
1601 TenantError::NameTaken => "another tenant has this name",
1602 TenantError::BadName => "a tenant name is",
1603 TenantError::HoldsVcons => "--force",
1604 TenantError::UnderHold => "--override-holds",
1605 TenantError::LimitReached => "rekey master",
1606 TenantError::AuditFailed => "audit record",
1607 TenantError::Closing => "being removed",
1608 TenantError::CliOnly => "command line",
1609 TenantError::BadScopes => "scopes",
1610 TenantError::WrongKey => "is not the one server.sqlite was made under",
1613 TenantError::Io => "could not be read or written",
1614 },
1615 }
1616 }
1617
1618 #[test]
1621 fn storage_move_objects_needs_a_bucket() {
1622 let s = setup();
1623 s.create("acme");
1624 let error = s.run(Command::StorageMoveObjects).expect_err("refused");
1625 assert!(
1626 error.to_string().contains("storage.objects = \"s3\""),
1627 "names the setting: {error}"
1628 );
1629 }
1630
1631 #[cfg(feature = "backend-tests")]
1635 #[test]
1636 fn storage_move_objects_moves_each_tenants_objects_to_the_bucket() {
1637 use crate::backend::Objects;
1638 let s = setup();
1639 let id = s.create("acme");
1640 store_one(&s, &id);
1641 let tenant = TenantId::parse(&id).expect("an id");
1642 let local = s.config.data_dir.join("tenants").join(&id).join("objects");
1643 let files = || std::fs::read_dir(&local).expect("listed").count();
1644 assert_eq!(files(), 1, "one object file");
1645 let (bucket, mut settings) = crate::backend::s3::remote::bucket(false);
1646 let secret = crate::backend::s3::remote::secret_file();
1647 settings.secret_access_key_file = Some(secret.1.clone());
1648 let mut config = s.config.clone();
1649 config.storage = crate::config::Storage {
1650 objects: crate::config::ObjectStore::S3(settings),
1651 parquet: None,
1652 };
1653 let moving = |config: &Config| -> Vec<Value> {
1654 let mut out = Vec::new();
1655 run(config, &Command::StorageMoveObjects, &mut out).expect("moved");
1656 String::from_utf8(out)
1657 .expect("text")
1658 .lines()
1659 .map(|l| serde_json::from_str(l).expect("a JSON line"))
1660 .collect()
1661 };
1662 assert_eq!(
1663 moving(&config),
1664 [
1665 json!({"tenant": id, "moved": 1, "missing": 0}),
1666 json!({"moved": 1})
1667 ],
1668 "one object moved, and nothing missing"
1669 );
1670 assert_eq!(files(), 0, "no object file is left");
1671 assert_eq!(
1672 bucket.tenant(tenant).list().expect("listed").len(),
1673 1,
1674 "the object is in the bucket"
1675 );
1676 let tenants = Tenants::open_beside_in(
1677 &config.data_dir,
1678 Key::load_master(&config.master_key).expect("master"),
1679 Arc::new(OsFs),
1680 Arc::new(SystemClock),
1681 crate::backend::ObjectPlace::S3(bucket.clone()),
1682 )
1683 .expect("opens");
1684 let (_, body) = vcon(UUID, "exported");
1685 assert_eq!(
1686 tenants
1687 .store(tenant)
1688 .expect("its store")
1689 .get(&Caller { key: KeyId([7; 8]) }, UUID.parse().expect("uuid"))
1690 .expect("reads")
1691 .body,
1692 body,
1693 "the vCon reads back from the bucket"
1694 );
1695 drop(tenants);
1696 assert_eq!(
1697 moving(&config),
1698 [
1699 json!({"tenant": id, "moved": 0, "missing": 0}),
1700 json!({"moved": 0})
1701 ],
1702 "a second run moves nothing"
1703 );
1704 }
1705
1706 fn store_one(s: &Setup, id: &str) {
1708 let store = s
1709 .tenants()
1710 .store(TenantId::parse(id).expect("an id"))
1711 .expect("the store");
1712 let (found, body) = vcon(UUID, "exported");
1713 store
1714 .create(&Caller { key: KeyId([7; 8]) }, &found, &body)
1715 .expect("stored");
1716 }
1717
1718 #[test]
1719 fn tenant_set_metadata_prints_the_level_and_list_shows_it() {
1720 let s = setup();
1721 let id = s.create("acme");
1722 let out = s
1723 .run(Command::TenantSetMetadata {
1724 id: id.clone(),
1725 metadata: "counts".to_owned(),
1726 })
1727 .expect("set");
1728 assert_eq!(
1729 out,
1730 [json!({ "id": id, "metadata": "counts" })],
1731 "the command prints the tenant and its level"
1732 );
1733 assert_eq!(
1734 s.run(Command::TenantList).expect("listed")[0]["metadata"],
1735 "counts",
1736 "the list shows the level"
1737 );
1738 assert!(
1739 matches!(
1740 s.run(Command::TenantSetMetadata {
1741 id: id.clone(),
1742 metadata: "most".to_owned(),
1743 }),
1744 Err(CommandError::BadLevel(text)) if text == "most"
1745 ),
1746 "a level that is not one is refused, naming it"
1747 );
1748 assert_eq!(
1749 tenant_error(s.run(Command::TenantSetMetadata {
1750 id: TenantId::generate().to_string(),
1751 metadata: "full".to_owned(),
1752 })),
1753 TenantError::NotFound,
1754 "a tenant id no tenant has is not found"
1755 );
1756 }
1757
1758 #[test]
1761 fn export_parquet_runs_beside_a_server_and_prints_what_it_wrote() {
1762 let s = setup();
1763 let id = s.create("acme");
1764 store_one(&s, &id);
1765 s.run(Command::TenantSetMetadata {
1766 id: id.clone(),
1767 metadata: "counts".to_owned(),
1768 })
1769 .expect("set");
1770 let to = s._dir.path().join("export");
1771 let export = Command::ExportParquet {
1772 tenant: id.clone(),
1773 to: to.display().to_string(),
1774 since: Some("2000-01-01T00:00:00Z".to_owned()),
1775 until: None,
1776 };
1777 let server = datadir::lock(&s.config.data_dir, Sharing::Serve).expect("a server");
1778 let out = s.run(export.clone()).expect("exports beside a server");
1779 drop(server);
1780 assert_eq!(out.len(), 1, "{out:?}");
1781 let line = &out[0];
1782 let manifest = format!("tenant={id}/manifest/00000000000000000001.json");
1783 assert_eq!(line["tenant"], id, "the line names the tenant");
1784 assert_eq!(
1785 line["metadata"], "counts",
1786 "the export is at the tenant's level"
1787 );
1788 assert_eq!(
1789 line["to"],
1790 to.display().to_string(),
1791 "the line names the target"
1792 );
1793 assert_eq!(line["manifest"], manifest, "the line names the manifest");
1794 assert_eq!(line["seq"], 1, "the first export to the target");
1795 assert_eq!(line["files"], 1, "one store date, one file");
1796 assert_eq!(line["rows"], 1, "the one vCon stored");
1797 assert_eq!(line["skipped"], 0, "nothing was purged during the run");
1798 assert_eq!(line["unread"], 0, "every body was read");
1799 let m: Value =
1800 serde_json::from_slice(&std::fs::read(to.join(&manifest)).expect("the manifest"))
1801 .expect("JSON");
1802 assert_eq!(m["bytes"], line["bytes"], "the bytes the manifest names");
1803 assert_eq!(
1804 m["since"], "2000-01-01T00:00:00Z",
1805 "the manifest records --since"
1806 );
1807 let alone = datadir::lock(&s.config.data_dir, Sharing::Alone).expect("alone");
1808 assert!(
1809 matches!(
1810 s.run(export),
1811 Err(CommandError::DataDir(LockError::InUse(Sharing::Beside)))
1812 ),
1813 "an export does not run beside a command that runs alone"
1814 );
1815 drop(alone);
1816 }
1817
1818 #[test]
1819 fn export_parquet_refuses_a_bad_time_target_or_tenant() {
1820 let s = setup();
1821 let id = s.create("acme");
1822 let export = |to: &str, since: Option<&str>, until: Option<&str>| Command::ExportParquet {
1823 tenant: id.clone(),
1824 to: to.to_owned(),
1825 since: since.map(str::to_owned),
1826 until: until.map(str::to_owned),
1827 };
1828 let dir = s._dir.path().join("out").display().to_string();
1829 assert!(
1830 matches!(
1831 s.run(export(&dir, Some("yesterday"), None)),
1832 Err(CommandError::BadTime(t)) if t == "yesterday"
1833 ),
1834 "a word that is not an RFC 3339 time is refused, naming it"
1835 );
1836 assert!(
1837 matches!(
1838 s.run(export(&dir, None, Some("2026-13-01T00:00:00Z"))),
1839 Err(CommandError::BadTime(_))
1840 ),
1841 "a month 13 is not a time"
1842 );
1843 assert!(
1844 matches!(
1845 s.run(export("s3://", None, None)),
1846 Err(CommandError::BadTarget(t)) if t == "s3://"
1847 ),
1848 "an s3:// URL with no bucket is refused, naming it"
1849 );
1850 assert!(
1851 matches!(
1852 s.run(export("s3://reports/vcon", None, None)),
1853 Err(CommandError::NoS3)
1854 ),
1855 "no S3 service is configured"
1856 );
1857 assert_eq!(
1858 tenant_error(s.run(Command::ExportParquet {
1859 tenant: TenantId::generate().to_string(),
1860 to: dir.clone(),
1861 since: None,
1862 until: None,
1863 })),
1864 TenantError::NotFound,
1865 "an unknown tenant is not found"
1866 );
1867 assert!(
1868 !std::path::Path::new(&dir).exists(),
1869 "a refused export writes nothing"
1870 );
1871 }
1872
1873 #[test]
1874 fn each_error_says_what_went_wrong() {
1875 let tenant = [
1876 TenantError::NotFound,
1877 TenantError::NameTaken,
1878 TenantError::BadName,
1879 TenantError::HoldsVcons,
1880 TenantError::UnderHold,
1881 TenantError::LimitReached,
1882 TenantError::AuditFailed,
1883 TenantError::Closing,
1884 TenantError::CliOnly,
1885 TenantError::BadScopes,
1886 TenantError::WrongKey,
1887 TenantError::Io,
1888 ]
1889 .map(CommandError::Tenant);
1890 let backup = [
1891 BackupError::Exists,
1892 BackupError::KeptChanging,
1893 BackupError::Io,
1894 BackupError::AuditFailed,
1895 ]
1896 .map(CommandError::Backup);
1897 let rest = [
1898 CommandError::DataDir(LockError::InUse(Sharing::Alone)),
1899 CommandError::MasterKey(crate::envelope::KeyError::WrongSize),
1900 CommandError::Open(RegistryError::failed(
1901 tenants::OpenStep::OpenCatalog,
1902 std::path::PathBuf::from("/srv/data/server.sqlite"),
1903 io::Error::other("disk I/O error"),
1904 )),
1905 CommandError::WrongMasterKey,
1906 CommandError::BadPeriod("7w".to_owned()),
1907 CommandError::UnknownKey,
1908 CommandError::Output(io::Error::other("closed")),
1909 CommandError::SameMasterKey,
1910 CommandError::CheckFound(2),
1911 CommandError::Storage(StorageError(
1912 "bucket \"vcon\" cannot be reached or read".to_owned(),
1913 )),
1914 CommandError::BadLevel("most".to_owned()),
1915 CommandError::BadTime("yesterday".to_owned()),
1916 CommandError::BadTarget("s3://".to_owned()),
1917 CommandError::NoS3,
1918 CommandError::Export(crate::export::ExportError::Raced("m".to_owned())),
1919 CommandError::Move {
1920 tenant: TenantId([1; 16]),
1921 why: "the bucket could not be written".to_owned(),
1922 },
1923 ];
1924 for error in tenant.into_iter().chain(backup).chain(rest) {
1925 let text = error.to_string();
1926 assert!(text.contains(says(&error)), "{error:?}: {text}");
1927 }
1928 }
1929}