diff --git a/dev-tools/omdb/src/bin/omdb/db.rs b/dev-tools/omdb/src/bin/omdb/db.rs index c1bc06f553..e3836f7cff 100644 --- a/dev-tools/omdb/src/bin/omdb/db.rs +++ b/dev-tools/omdb/src/bin/omdb/db.rs @@ -306,6 +306,9 @@ enum DbCommands { Instances(InstancesOptions), /// Print information about the network Network(NetworkArgs), + /// Print information about migrations + #[clap(alias = "migration")] + Migrations(MigrationsArgs), /// Print information about snapshots Snapshots(SnapshotArgs), /// Validate the contents of the database @@ -544,6 +547,75 @@ enum NetworkCommands { ListVnics, } +#[derive(Debug, Args)] +struct MigrationsArgs { + #[command(subcommand)] + command: MigrationsCommands, +} + +#[derive(Debug, Subcommand)] +enum MigrationsCommands { + /// List migrations + #[clap(alias = "ls")] + List(MigrationsListArgs), + // N.B. I'm making this a subcommand not because there are currently any + // other subcommands, but to reserve the optionality to add things other + // than `list`... +} + +#[derive(Debug, Args)] +struct MigrationsListArgs { + /// Include only migrations where at least one side reports the migration + /// is in progress. + /// + /// By default, migrations in all states are included. This can be combined + /// with the `--pending`, `--completed`, and `--failed` arguments to include + /// migrations in multiple states. + #[arg(short = 'r', long, action = ArgAction::SetTrue)] + in_progress: bool, + + /// Include only migrations where at least one side is still pending (the + /// sled-agent hasn't reported in yet). + /// + /// By default, migrations in all states are included. This can be combined + /// with the `--in-progress`, `--completed` and `--failed` arguments to + /// include migrations in multiple states. + #[arg(short = 'p', long, action = ArgAction::SetTrue)] + pending: bool, + + /// Include only migrations where at least one side reports the migration + /// has completed. + /// + /// By default, migrations in all states are included. This can be combined + /// with the `--in-progress`, `--pending`, and `--failed` arguments to + /// include migrations in multiple states. + #[arg(short = 'c', long, action = ArgAction::SetTrue)] + completed: bool, + + /// Include only migrations where at least one side reports the migration + /// has failed. + /// + /// By default, migrations in all states are included. This can be combined + /// with the `--pending`, `--in-progress`, and --completed` arguments to + /// include migrations in multiple states. + #[arg(short = 'f', long, action = ArgAction::SetTrue)] + failed: bool, + + /// Show only migrations for this instance ID. + /// + /// By default, all instances are shown. This argument be repeated to select + /// other instances. + #[arg(short = 'i', long = "instance-id")] + instance_ids: Vec, + + /// Output all data from the migration record. + /// + /// This includes update and deletion timestamps, the source and target + /// generation numbers. + #[arg(short, long, action = ArgAction::SetTrue)] + verbose: bool, +} + #[derive(Debug, Args)] struct SnapshotArgs { #[command(subcommand)] @@ -730,6 +802,11 @@ impl DbArgs { ) .await } + DbCommands::Migrations(MigrationsArgs { + command: MigrationsCommands::List(args), + }) => { + cmd_db_migrations_list(&datastore, &self.fetch_opts, args).await + } DbCommands::Snapshots(SnapshotArgs { command: SnapshotCommands::Info(uuid), }) => cmd_db_snapshot_info(&opctx, &datastore, uuid).await, @@ -2705,11 +2782,6 @@ async fn cmd_db_eips( owner_disposition: Option, } - // Display an empty cell for an Option if it's None. - fn display_option_blank(opt: &Option) -> String { - opt.as_ref().map(|x| x.to_string()).unwrap_or_else(|| "".to_string()) - } - if verbose { for ip in &ips { if verbose { @@ -4094,3 +4166,175 @@ async fn cmd_db_reconfigurator_save( eprintln!("wrote {}", output_path); Ok(()) } + +// Migrations + +async fn cmd_db_migrations_list( + datastore: &DataStore, + fetch_opts: &DbFetchOptions, + args: &MigrationsListArgs, +) -> Result<(), anyhow::Error> { + use db::model::Migration; + use db::model::MigrationState; + use db::schema::migration::dsl; + use omicron_common::api::internal::nexus; + + let mut state_filters = Vec::new(); + if args.completed { + state_filters.push(MigrationState(nexus::MigrationState::Completed)); + } + if args.failed { + state_filters.push(MigrationState(nexus::MigrationState::Failed)); + } + if args.in_progress { + state_filters.push(MigrationState(nexus::MigrationState::InProgress)); + } + if args.pending { + state_filters.push(MigrationState(nexus::MigrationState::Pending)); + } + + let mut query = dsl::migration.into_boxed(); + + if !fetch_opts.include_deleted { + query = query.filter(dsl::time_deleted.is_null()); + } + + if !state_filters.is_empty() { + query = query.filter( + dsl::source_state + .eq_any(state_filters.clone()) + .or(dsl::target_state.eq_any(state_filters)), + ); + } + + if !args.instance_ids.is_empty() { + query = + query.filter(dsl::instance_id.eq_any(args.instance_ids.clone())); + } + + let migrations = query + .limit(i64::from(u32::from(fetch_opts.fetch_limit))) + .order_by(dsl::time_created) + // This is just to prove to CRDB that it can use the + // migrations-by-time-created index, it doesn't actually do anything. + .filter(dsl::time_created.gt(chrono::DateTime::UNIX_EPOCH)) + .select(Migration::as_select()) + .load_async(&*datastore.pool_connection_for_tests().await?) + .await + .context("listing migrations")?; + + check_limit(&migrations, fetch_opts.fetch_limit, || "listing migrations"); + + #[derive(Tabled)] + #[tabled(rename_all = "SCREAMING_SNAKE_CASE")] + struct Vmms { + src_state: MigrationState, + tgt_state: MigrationState, + src_vmm: Uuid, + tgt_vmm: Uuid, + } + + impl From<&'_ Migration> for Vmms { + fn from( + &Migration { + source_propolis_id, + target_propolis_id, + source_state, + target_state, + .. + }: &Migration, + ) -> Self { + Self { + src_state: source_state, + tgt_state: target_state, + src_vmm: source_propolis_id, + tgt_vmm: target_propolis_id, + } + } + } + + let table = if args.verbose { + // If verbose mode is enabled, include the migration's ID as well as the + // source and target updated timestamps. + #[derive(Tabled)] + #[tabled(rename_all = "SCREAMING_SNAKE_CASE")] + struct VerboseMigrationRow { + created: chrono::DateTime, + id: Uuid, + instance: Uuid, + #[tabled(inline)] + vmms: Vmms, + #[tabled(display_with = "display_option_blank")] + src_updated: Option>, + #[tabled(display_with = "display_option_blank")] + tgt_updated: Option>, + #[tabled(display_with = "display_option_blank")] + deleted: Option>, + } + + let rows = migrations.into_iter().map(|m| VerboseMigrationRow { + id: m.id, + instance: m.instance_id, + vmms: Vmms::from(&m), + src_updated: m.time_source_updated, + tgt_updated: m.time_target_updated, + created: m.time_created, + deleted: m.time_deleted, + }); + + tabled::Table::new(rows) + .with(tabled::settings::Style::empty()) + .with(tabled::settings::Padding::new(0, 1, 0, 0)) + .to_string() + } else if args.instance_ids.len() == 1 { + // If only the migrations for a single instance are shown, we omit the + // instance ID row for conciseness sake. + #[derive(Tabled)] + #[tabled(rename_all = "SCREAMING_SNAKE_CASE")] + struct SingleInstanceMigrationRow { + created: chrono::DateTime, + #[tabled(inline)] + vmms: Vmms, + } + let rows = migrations.into_iter().map(|m| SingleInstanceMigrationRow { + created: m.time_created, + vmms: Vmms::from(&m), + }); + + tabled::Table::new(rows) + .with(tabled::settings::Style::empty()) + .with(tabled::settings::Padding::new(0, 1, 0, 0)) + .to_string() + } else { + // Otherwise, the default format includes the instance ID, but omits + // most of the timestamps for brevity. + #[derive(Tabled)] + #[tabled(rename_all = "SCREAMING_SNAKE_CASE")] + struct MigrationRow { + created: chrono::DateTime, + instance: Uuid, + #[tabled(inline)] + vmms: Vmms, + } + + let rows = migrations.into_iter().map(|m| MigrationRow { + created: m.time_created, + instance: m.instance_id, + vmms: Vmms::from(&m), + }); + + tabled::Table::new(rows) + .with(tabled::settings::Style::empty()) + .with(tabled::settings::Padding::new(0, 1, 0, 0)) + .to_string() + }; + + println!("{table}"); + + Ok(()) +} + +// Display an empty cell for an Option if it's None. +fn display_option_blank(opt: &Option) -> String { + opt.as_ref().map(|x| x.to_string()).unwrap_or_else(|| "".to_string()) +} diff --git a/dev-tools/omdb/tests/usage_errors.out b/dev-tools/omdb/tests/usage_errors.out index 116b0b3911..3dd9308708 100644 --- a/dev-tools/omdb/tests/usage_errors.out +++ b/dev-tools/omdb/tests/usage_errors.out @@ -116,6 +116,7 @@ Commands: sleds Print information about sleds instances Print information about customer instances network Print information about the network + migrations Print information about migrations snapshots Print information about snapshots validate Validate the contents of the database volumes Print information about volumes @@ -159,6 +160,7 @@ Commands: sleds Print information about sleds instances Print information about customer instances network Print information about the network + migrations Print information about migrations snapshots Print information about snapshots validate Validate the contents of the database volumes Print information about volumes diff --git a/nexus/db-model/src/schema_versions.rs b/nexus/db-model/src/schema_versions.rs index 97c933c987..c8ad779cfc 100644 --- a/nexus/db-model/src/schema_versions.rs +++ b/nexus/db-model/src/schema_versions.rs @@ -17,7 +17,7 @@ use std::collections::BTreeMap; /// /// This must be updated when you change the database schema. Refer to /// schema/crdb/README.adoc in the root of this repository for details. -pub const SCHEMA_VERSION: SemverVersion = SemverVersion::new(84, 0, 0); +pub const SCHEMA_VERSION: SemverVersion = SemverVersion::new(85, 0, 0); /// List of all past database schema versions, in *reverse* order /// @@ -29,6 +29,7 @@ static KNOWN_VERSIONS: Lazy> = Lazy::new(|| { // | leaving the first copy as an example for the next person. // v // KnownVersion::new(next_int, "unique-dirname-with-the-sql-files"), + KnownVersion::new(85, "add-migrations-by-time-created-index"), KnownVersion::new(84, "region-read-only"), KnownVersion::new(83, "dataset-address-optional"), KnownVersion::new(82, "region-port"), diff --git a/schema/crdb/add-migrations-by-time-created-index/up.sql b/schema/crdb/add-migrations-by-time-created-index/up.sql new file mode 100644 index 0000000000..1b55c5b668 --- /dev/null +++ b/schema/crdb/add-migrations-by-time-created-index/up.sql @@ -0,0 +1,9 @@ + +/* Migrations by time created. + * + * Currently, this is only used by OMDB for ordering the `omdb migration list` + * output, but it may be used by other UIs in the future... +*/ +CREATE INDEX IF NOT EXISTS migrations_by_time_created ON omicron.public.migration ( + time_created +); diff --git a/schema/crdb/dbinit.sql b/schema/crdb/dbinit.sql index f6ded221cd..a1d642cd06 100644 --- a/schema/crdb/dbinit.sql +++ b/schema/crdb/dbinit.sql @@ -4131,6 +4131,15 @@ CREATE INDEX IF NOT EXISTS lookup_migrations_by_instance_id ON omicron.public.mi instance_id ); +/* Migrations by time created. + * + * Currently, this is only used by OMDB for ordering the `omdb migration list` + * output, but it may be used by other UIs in the future... +*/ +CREATE INDEX IF NOT EXISTS migrations_by_time_created ON omicron.public.migration ( + time_created +); + /* Lookup region snapshot by snapshot id */ CREATE INDEX IF NOT EXISTS lookup_region_snapshot_by_snapshot_id on omicron.public.region_snapshot ( snapshot_id @@ -4147,7 +4156,7 @@ INSERT INTO omicron.public.db_metadata ( version, target_version ) VALUES - (TRUE, NOW(), NOW(), '84.0.0', NULL) + (TRUE, NOW(), NOW(), '85.0.0', NULL) ON CONFLICT DO NOTHING; COMMIT;