From bc5cd9bbcde71a9cd9556411a562118def4d3d9b Mon Sep 17 00:00:00 2001 From: Will Chandler Date: Tue, 22 Jul 2025 16:46:39 -0400 Subject: [PATCH 1/6] Add option for tabular output Currently all commands will display their results as pretty-printed JSON. Tabular output is convenient for easy comparison of a single field when multiple objects are returned, and more amenable to traditional shell processing. Add a new global `--format` flag to control output format, defaulting to the existing JSON output. As larger output items, such as instances, can easily overflow a typical terminal width, we allow users to specify which fields to print with `--format=table:field1,field2,...`. Non-scalar fields within a returned object will be printed in compact JSON format, e.g. `{"cpus":0,"memory":0,"storage":0}` for the `allocated` field on `oxide silo utilization list`. To determine the field names to be shown in the table header, we parse the schema for the return type as part of `OxideOverride`. This logic makes some assumptions about the shape of the data returned, and we need to ensure that it remains valid. Add a new return_types `xtask` job, which writes out all return types from the Oxide API to a file, against which we test the parsing logic. --- Cargo.lock | 14 + Cargo.toml | 3 + cli/Cargo.toml | 2 + cli/docs/cli.json | 1877 ++++++++++++++++- cli/src/cli_builder.rs | 73 +- cli/src/cmd_update.rs | 5 +- cli/src/context.rs | 12 +- cli/src/main.rs | 221 +- cli/src/oxide_override.rs | 595 ++++++ cli/tests/data/api_return_types.rs | 136 ++ ...test_table_project_list_basic_table.stdout | 5 + .../data/test_table_project_list_json.stdout | 27 + ...able_project_list_table_with_fields.stdout | 5 + cli/tests/test_table.rs | 125 ++ xtask/Cargo.toml | 1 + xtask/src/main.rs | 100 +- 16 files changed, 2969 insertions(+), 232 deletions(-) create mode 100644 cli/src/oxide_override.rs create mode 100644 cli/tests/data/api_return_types.rs create mode 100644 cli/tests/data/test_table_project_list_basic_table.stdout create mode 100644 cli/tests/data/test_table_project_list_json.stdout create mode 100644 cli/tests/data/test_table_project_list_table_with_fields.stdout create mode 100644 cli/tests/test_table.rs diff --git a/Cargo.lock b/Cargo.lock index d61174af..8d803230 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -640,6 +640,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "comfy-table" +version = "7.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a65ebfec4fb190b6f90e944a817d60499ee0744e582530e2c9900a22e591d9a" +dependencies = [ + "crossterm 0.28.1", + "unicode-segmentation", + "unicode-width 0.2.0", +] + [[package]] name = "compact_str" version = "0.8.1" @@ -2533,6 +2544,7 @@ dependencies = [ "clap", "clap_complete", "colored", + "comfy-table", "crossterm 0.29.0", "dialoguer", "dirs", @@ -2542,6 +2554,7 @@ dependencies = [ "futures", "httpmock", "humantime", + "indexmap", "indicatif", "libc", "log", @@ -4925,6 +4938,7 @@ version = "0.0.0" dependencies = [ "clap", "newline-converter", + "openapiv3", "progenitor", "regex", "rustc_version", diff --git a/Cargo.toml b/Cargo.toml index 070234c2..b3d67287 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ chrono = { version = "0.4.41", features = ["serde"] } clap = { version = "4.5.40", features = ["derive", "string", "env", "wrap_help"] } clap_complete = "4.5.54" colored = "3.0.0" +comfy-table = "7.1.4" crossterm = { version = "0.29.0", features = [ "event-stream" ] } dialoguer = "0.11.0" dirs = "6.0.0" @@ -32,6 +33,7 @@ flume = "0.11.1" futures = "0.3.31" httpmock = "0.7.0" humantime = "2.2.0" +indexmap = "2.10.0" indicatif = "0.17.12" libc = "0.2.174" log = "0.4.26" @@ -39,6 +41,7 @@ md5 = "0.7.0" newline-converter = "0.3.0" oauth2 = "5.0.0" open = "5.3.2" +openapiv3 = "2.2.0" oxide = { path = "sdk", version = "0.12.0" } oxide-httpmock = { path = "sdk-httpmock", version = "0.12.0" } oxnet = "0.1.2" diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 3ecfb65a..5b46b9c3 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -27,12 +27,14 @@ chrono = { workspace = true } clap = { workspace = true } clap_complete = { workspace = true } colored = { workspace = true } +comfy-table = { workspace = true } crossterm = { workspace = true } dialoguer = { workspace = true } dirs = { workspace = true } env_logger = { workspace = true } futures = { workspace = true } humantime = { workspace = true } +indexmap = { workspace = true } indicatif = { workspace = true } log = { workspace = true } md5 = { workspace = true } diff --git a/cli/docs/cli.json b/cli/docs/cli.json index 768d9878..f9871530 100644 --- a/cli/docs/cli.json +++ b/cli/docs/cli.json @@ -15,6 +15,11 @@ "long": "debug", "help": "Enable debug output" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "insecure", "help": "Disable certificate validation and hostname verification" @@ -37,6 +42,11 @@ { "name": "alert", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -47,6 +57,11 @@ { "name": "class", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -62,6 +77,11 @@ "long": "filter", "help": "An optional glob pattern for filtering alert class names.\n\nIf provided, only alert classes which match this glob pattern will be included in the response." }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -78,6 +98,11 @@ { "name": "receiver", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -89,6 +114,11 @@ "name": "delete", "about": "Delete alert receiver", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -104,6 +134,11 @@ "name": "list", "about": "List alert receivers", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -144,6 +179,11 @@ ], "help": "If true, include deliveries which have failed permanently.\n\nIf any of the \"pending\", \"failed\", or \"delivered\" query parameters are set to true, only deliveries matching those state(s) will be included in the response. If NO state filter parameters are set, then all deliveries are included.\n\nA delivery fails permanently when the retry limit of three total attempts is reached without a successful delivery." }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -179,6 +219,11 @@ "about": "Send liveness probe to alert receiver", "long_about": "This endpoint synchronously sends a liveness probe to the selected alert receiver. The response message describes the outcome of the probe: either the successful response (as appropriate), or indication of why the probe failed.\n\nThe result of the probe is represented as an `AlertDelivery` model. Details relating to the status of the probe depend on the alert delivery mechanism, and are included in the `AlertDeliveryAttempts` model. For example, webhook receiver liveness probes include the HTTP status code returned by the receiver endpoint.\n\nNote that the response status is `200 OK` as long as a probe request was able to be sent to the receiver endpoint. If an HTTP-based receiver, such as a webhook, responds to the another status code, including an error, this will be indicated by the response body, *not* the status of the response.\n\nThe `resend` query parameter can be used to request re-delivery of failed events if the liveness probe succeeds. If it is set to true and the liveness probe succeeds, any alerts for which delivery to this receiver has failed will be queued for re-delivery.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -206,6 +251,11 @@ "long": "alert-id", "help": "UUID of the alert" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -221,6 +271,11 @@ "name": "subscribe", "about": "Add alert receiver subscription", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -248,6 +303,11 @@ "name": "unsubscribe", "about": "Remove alert receiver subscription", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -267,6 +327,11 @@ "name": "view", "about": "Fetch alert receiver", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -281,6 +346,11 @@ { "name": "webhook", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -299,6 +369,11 @@ "long": "endpoint", "help": "The URL that webhook notification requests should be sent to" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -320,6 +395,11 @@ { "name": "secret", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -331,6 +411,11 @@ "name": "add", "about": "Add secret to webhook receiver", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -358,6 +443,11 @@ "name": "delete", "about": "Remove secret from webhook receiver", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -373,6 +463,11 @@ "name": "list", "about": "List webhook receiver secret IDs", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -398,6 +493,11 @@ "long": "endpoint", "help": "The URL that webhook notification requests should be sent to" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -436,6 +536,11 @@ "short": "F", "help": "Add a typed parameter in key=value format" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "header", "short": "H", @@ -484,6 +589,11 @@ "about": "Login, logout, and get the status of your authentication.", "long_about": "Login, logout, and get the status of your authentication.\n\nManage `oxide`'s authentication state.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -500,6 +610,11 @@ "long": "browser", "help": "Override the default browser when opening the authentication URL" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "host", "short": "H", @@ -535,6 +650,11 @@ "short": "f", "help": "Skip confirmation prompt" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -547,6 +667,11 @@ "about": "Verifies and displays information about your authentication state.", "long_about": "Verifies and displays information about your authentication state.\n\nThis command validates the authentication state for each profile in the\ncurrent configuration.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -559,6 +684,11 @@ { "name": "auth-settings", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -574,6 +704,11 @@ "long": "device-token-max-ttl-seconds", "help": "Maximum lifetime of a device token in seconds. If set to null, users will be able to create tokens that do not expire." }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -593,6 +728,11 @@ "name": "view", "about": "Fetch current silo's auth settings", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -605,6 +745,11 @@ { "name": "bundle", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -616,6 +761,11 @@ "name": "create", "about": "Create a new support bundle", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -632,6 +782,11 @@ "long": "bundle-id", "help": "ID of the support bundle" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -648,6 +803,11 @@ "short": "c", "help": "The size, in bytes, of each range request to use when downloading bundles" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "id", "help": "ID of the bundle" @@ -669,6 +829,11 @@ "about": "Inspects a support bundle", "long_about": "Inspects a support bundle\n\nSupport bundles may be inspected before they are downloaded (via\nsmaller HTTP requests), or after the entire zip file has been\ndownloaded.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "id", "help": "ID of the bundle to be inspected, if accessing via API" @@ -689,6 +854,11 @@ "name": "list", "about": "List all support bundles", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -715,6 +885,11 @@ "long": "bundle-id", "help": "ID of the support bundle" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -727,6 +902,11 @@ { "name": "certificate", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -746,6 +926,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -784,6 +969,11 @@ "long": "certificate", "help": "Name or ID of the certificate" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -796,6 +986,11 @@ "about": "List certificates for external endpoints", "long_about": "Returns a list of TLS certificates used for the external API (for the current Silo). These are sorted by creation date, with the most recent certificates appearing first.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -824,6 +1019,11 @@ "long": "certificate", "help": "Name or ID of the certificate" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -838,6 +1038,11 @@ "about": "Generate shell completion scripts for Oxide CLI commands.", "long_about": "Generate shell completion scripts for Oxide CLI commands.\n\nThis command generates scripts for various shells that can be used to\nenable completion.\n\n## Installation\n\n### Bash\n\nAdd this to your `~/.bash_profile`:\n\n```sh\neval \"$(oxide completion -s bash)\"\n```\n\n### Zsh\n\nAdd this to your `~/.zshrc`:\n\n```sh\nautoload -U compinit\ncompinit -i\neval \"$(oxide completion -s zsh)\"\n```\n\n### Fish\n\nAdd the following to the `is-interactive` block in your `~/.config/fish/config.fish`:\n\n```sh\noxide completion -s fish | source\n```\n\n### PowerShell\n\nOpen your profile script with:\n\n```sh\nmkdir -Path (Split-Path -Parent $profile)\nnotepad $profile\n```\n\nAdd the following line and save the file:\n\n```powershell\nInvoke-Expression -Command $(oxide completion -s powershell | Out-String)\n```\n\n### Elvish\n\nAdd this to your `~/.config/elvish/rc.elv`\n\n```sh\neval (oxide completion -s elvish | slurp)\n```", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -860,6 +1065,11 @@ { "name": "current-user", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -870,6 +1080,11 @@ { "name": "access-token", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -882,6 +1097,11 @@ "about": "Delete access token", "long_about": "Delete a device access token for the currently authenticated user.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -898,6 +1118,11 @@ "about": "List access tokens", "long_about": "List device access tokens for the currently authenticated user.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -921,6 +1146,11 @@ "name": "groups", "about": "Fetch current user's groups", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -941,6 +1171,11 @@ { "name": "ssh-key", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -956,6 +1191,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -983,6 +1223,11 @@ "about": "Delete SSH public key", "long_about": "Delete an SSH public key associated with the currently authenticated user.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -999,6 +1244,11 @@ "about": "List SSH public keys", "long_about": "Lists SSH public keys for the currently authenticated user.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -1023,6 +1273,11 @@ "about": "Fetch SSH public key", "long_about": "Fetch SSH public key associated with the currently authenticated user.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -1040,6 +1295,11 @@ "name": "view", "about": "Fetch user for current session", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -1052,6 +1312,11 @@ { "name": "disk", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -1066,6 +1331,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -1100,6 +1370,11 @@ "long": "disk", "help": "Name or ID of the disk" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -1136,6 +1411,11 @@ "long": "disk-size", "help": "The size of the disk to create. If unspecified, the size of the file will be used, rounded up to the nearest GB; unit suffixes are in powers of two (1k = 1024 bytes) for example: 6GiB, 512k, 2048mib" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "image", "help": "If supplied, create an image with the given name. Requires the creation of a snapshot" @@ -1179,6 +1459,11 @@ "long": "disk", "help": "Name or ID of the disk" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -1211,6 +1496,11 @@ "long": "disk", "help": "Name or ID of the disk" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -1231,6 +1521,11 @@ "long": "disk", "help": "Name or ID of the disk" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -1253,6 +1548,11 @@ "long": "disk", "help": "Name or ID of the disk" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -1281,6 +1581,11 @@ "name": "list", "about": "List disks", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -1307,6 +1612,11 @@ { "name": "metrics", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -1325,6 +1635,11 @@ "long": "end-time", "help": "An exclusive end time of metrics." }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -1373,6 +1688,11 @@ "long": "disk", "help": "Name or ID of the disk" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -1390,6 +1710,11 @@ "name": "docs", "about": "Generate CLI docs in JSON format", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -1400,6 +1725,11 @@ { "name": "experimental", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -1410,6 +1740,11 @@ { "name": "instance", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -1420,6 +1755,11 @@ { "name": "affinity", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -1440,6 +1780,11 @@ "sled" ] }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -1477,6 +1822,11 @@ "long": "affinity-group", "help": "Name or ID of the affinity group" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -1492,6 +1842,11 @@ "name": "list", "about": "List affinity groups", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -1518,6 +1873,11 @@ { "name": "member", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -1532,6 +1892,11 @@ { "long": "affinity-group" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance" }, @@ -1554,6 +1919,11 @@ "long": "affinity-group", "help": "Name or ID of the affinity group" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -1584,6 +1954,11 @@ { "long": "affinity-group" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance" }, @@ -1605,6 +1980,11 @@ { "long": "affinity-group" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance" }, @@ -1632,6 +2012,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -1662,6 +2047,11 @@ "long": "affinity-group", "help": "Name or ID of the affinity group" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -1680,6 +2070,11 @@ { "name": "system", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -1690,6 +2085,11 @@ { "name": "probe", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -1704,6 +2104,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "ip-pool" }, @@ -1736,6 +2141,11 @@ "name": "delete", "about": "Delete instrumentation probe", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "probe", "help": "Name or ID of the probe" @@ -1755,6 +2165,11 @@ "name": "list", "about": "List instrumentation probes", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -1782,6 +2197,11 @@ "name": "view", "about": "View instrumentation probe", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "probe", "help": "Name or ID of the probe" @@ -1802,6 +2222,11 @@ { "name": "timeseries", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -1815,7 +2240,12 @@ "long_about": "Queries are written in OxQL.", "args": [ { - "long": "json-body", + "long": "format", + "help": "Format in which to print output", + "global": true + }, + { + "long": "json-body", "help": "Path to a file that contains the full json body." }, { @@ -1836,6 +2266,11 @@ { "name": "schema", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -1847,6 +2282,11 @@ "name": "list", "about": "List timeseries schemas", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -1865,6 +2305,11 @@ { "name": "update", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -1875,6 +2320,11 @@ { "name": "target-release", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -1887,6 +2337,11 @@ "about": "Set the current target release of the rack's system software", "long_about": "The rack reconfigurator will treat the software specified here as a goal state for the rack's software, and attempt to asynchronously update to that release.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -1911,6 +2366,11 @@ "about": "Get the current target release of the rack's system software", "long_about": "This may not correspond to the actual software running on the rack at the time of request; it is instead the release that the rack reconfigurator should be moving towards as a goal state. After some number of planning and execution phases, the software running on the rack should eventually correspond to the release described here.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -1923,6 +2383,11 @@ { "name": "trust-root", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -1934,6 +2399,11 @@ "name": "create", "about": "Add trusted root role to updates trust store", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -1954,6 +2424,11 @@ "about": "Delete trusted root role", "long_about": "Note that this method does not currently check for any uploaded system release repositories that would become untrusted after deleting the root role.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -1970,6 +2445,11 @@ "about": "List root roles in the updates trust store", "long_about": "A root role is a JSON document describing the cryptographic keys that are trusted to sign system release repositories, as described by The Update Framework. Uploading a repository requires its metadata to be signed by keys trusted by the trust store.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -1991,6 +2471,11 @@ "name": "view", "about": "Fetch trusted root role", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -2011,6 +2496,11 @@ { "name": "timeseries", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -2022,6 +2512,11 @@ "name": "dashboard", "about": "Graph the results of an OxQL timeseries query.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "interval", "short": "i", @@ -2043,6 +2538,11 @@ "about": "Run project-scoped timeseries query", "long_about": "Queries are written in OxQL. Project must be specified by name or ID in URL query parameter. The OxQL query will only return timeseries data from the specified project.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -2073,6 +2573,11 @@ { "name": "floating-ip", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -2089,6 +2594,11 @@ "long": "floating-ip", "help": "Name or ID of the floating IP" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -2126,6 +2636,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "ip", "help": "An IP address to reserve for use as a floating IP. This field is optional: when not set, an address will be automatically chosen from `pool`. If set, then the IP must be available in the resolved `pool`." @@ -2164,6 +2679,11 @@ "long": "floating-ip", "help": "Name or ID of the floating IP" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -2183,6 +2703,11 @@ "long": "floating-ip", "help": "Name or ID of the floating IP" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -2198,6 +2723,11 @@ "name": "list", "about": "List floating IPs", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -2232,6 +2762,11 @@ "long": "floating-ip", "help": "Name or ID of the floating IP" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -2262,6 +2797,11 @@ "long": "floating-ip", "help": "Name or ID of the floating IP" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -2278,6 +2818,11 @@ { "name": "group", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -2289,6 +2834,11 @@ "name": "list", "about": "List groups", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -2311,6 +2861,11 @@ { "name": "image", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -2326,6 +2881,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -2361,6 +2921,11 @@ "about": "Delete image", "long_about": "Permanently delete an image from a project. This operation cannot be undone. Any instances in the project using the image will continue to run, however new instances can not be created with this image.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "image", "help": "Name or ID of the image" @@ -2381,6 +2946,11 @@ "about": "Demote silo image", "long_about": "Demote silo image to be visible only to a specified project", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "image", "help": "Name or ID of the image" @@ -2401,6 +2971,11 @@ "about": "List images", "long_about": "List images which are global or scoped to the specified project. The images are returned sorted by creation date, with the most recent images appearing first.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -2429,6 +3004,11 @@ "about": "Promote project image", "long_about": "Promote project image to be visible to all projects in the silo", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "image", "help": "Name or ID of the image" @@ -2449,6 +3029,11 @@ "about": "Fetch image", "long_about": "Fetch the details for a specific image in a project.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "image", "help": "Name or ID of the image" @@ -2469,6 +3054,11 @@ { "name": "instance", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -2479,6 +3069,11 @@ { "name": "anti-affinity", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -2499,6 +3094,11 @@ "sled" ] }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -2536,6 +3136,11 @@ "long": "anti-affinity-group", "help": "Name or ID of the anti affinity group" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -2551,6 +3156,11 @@ "name": "list", "about": "List anti-affinity groups", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -2577,6 +3187,11 @@ { "name": "member", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -2591,6 +3206,11 @@ { "long": "anti-affinity-group" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance" }, @@ -2613,6 +3233,11 @@ "long": "anti-affinity-group", "help": "Name or ID of the anti affinity group" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -2643,6 +3268,11 @@ { "long": "anti-affinity-group" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance" }, @@ -2664,6 +3294,11 @@ { "long": "anti-affinity-group" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance" }, @@ -2691,6 +3326,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -2721,6 +3361,11 @@ "long": "anti-affinity-group", "help": "Name or ID of the anti affinity group" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -2749,6 +3394,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "hostname", "help": "The hostname to be assigned to the instance" @@ -2799,6 +3449,11 @@ "name": "delete", "about": "Delete instance", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance", "help": "Name or ID of the instance" @@ -2817,6 +3472,11 @@ { "name": "disk", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -2832,6 +3492,11 @@ "long": "disk", "help": "Name or ID of the disk" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance", "help": "Name or ID of the instance" @@ -2863,6 +3528,11 @@ "long": "disk", "help": "Name or ID of the disk" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance", "help": "Name or ID of the instance" @@ -2890,6 +3560,11 @@ "name": "list", "about": "List disks for instance", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance", "help": "Name or ID of the instance" @@ -2922,6 +3597,11 @@ { "name": "external-ip", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -2933,6 +3613,11 @@ "name": "attach-ephemeral", "about": "Allocate and attach ephemeral IP to instance", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance", "help": "Name or ID of the instance" @@ -2964,6 +3649,11 @@ "name": "detach-ephemeral", "about": "Detach and deallocate ephemeral IP from instance", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance", "help": "Name or ID of the instance" @@ -2983,6 +3673,11 @@ "name": "list", "about": "List external IP addresses", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance", "help": "Name or ID of the instance" @@ -3008,6 +3703,11 @@ "long": "description", "help": "Description of the instance" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "hostname", "help": "The hostname to be assigned to the instance" @@ -3051,6 +3751,11 @@ "name": "list", "about": "List instances", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -3077,6 +3782,11 @@ { "name": "nic", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -3091,6 +3801,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance", "help": "Name or ID of the instance" @@ -3134,6 +3849,11 @@ "about": "Delete network interface", "long_about": "Note that the primary interface for an instance cannot be deleted if there are any secondary interfaces. A new primary interface must be designated first. The primary interface can be deleted if there are no secondary interfaces.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance", "help": "Name or ID of the instance" @@ -3157,6 +3877,11 @@ "name": "list", "about": "List network interfaces", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance", "help": "Name or ID of the instance" @@ -3191,6 +3916,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance", "help": "Name or ID of the instance" @@ -3233,6 +3963,11 @@ "name": "view", "about": "Fetch network interface", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance", "help": "Name or ID of the instance" @@ -3257,6 +3992,11 @@ { "name": "property", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -3268,6 +4008,11 @@ "name": "affinity", "about": "List affinity groups containing instance", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance", "help": "Name or ID of the instance" @@ -3299,6 +4044,11 @@ "name": "anti-affinity", "about": "List anti-affinity groups containing instance", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance", "help": "Name or ID of the instance" @@ -3331,6 +4081,11 @@ "about": "List SSH public keys for instance", "long_about": "List SSH public keys injected via cloud-init during instance creation. Note that this list is a snapshot in time and will not reflect updates made after the instance is created.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance", "help": "Name or ID of the instance" @@ -3364,6 +4119,11 @@ "name": "reboot", "about": "Reboot an instance", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance", "help": "Name or ID of the instance" @@ -3383,6 +4143,11 @@ "name": "serial", "about": "Connect to or retrieve data from the instance's serial console.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -3404,6 +4169,11 @@ "short": "e", "help": "If this sequence of bytes is typed, the client will exit. Note that the string passed for this argument must be valid UTF-8, and is used verbatim without any parsing; in most shells, if you wish to include a special character (such as Enter or a Ctrl+letter combo), you can insert the character by preceding it with Ctrl+V at the command line. To disable the escape string altogether, provide an empty string to this flag (and to exit in such a case, use pkill or similar).\n\n[default: { Ctrl+], Ctrl+C }]\n-- which would appear in your shell as ^]^C if you provided it manually by typing { Ctrl+V, Ctrl+], Ctrl+V, Ctrl+C } at the command line." }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance", "short": "i", @@ -3441,6 +4211,11 @@ "short": "b", "help": "The offset since boot (or if negative, the current end of the buffered data) from which to retrieve output history. Defaults to the instance's first output from boot" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance", "short": "i", @@ -3474,6 +4249,11 @@ "name": "start", "about": "Boot instance", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance", "help": "Name or ID of the instance" @@ -3493,6 +4273,11 @@ "name": "stop", "about": "Stop instance", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance", "help": "Name or ID of the instance" @@ -3524,6 +4309,11 @@ "long": "boot-disk", "help": "Name or ID of the disk the instance should be instructed to boot from.\n\nIf not provided, unset the instance's boot disk." }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance", "help": "Name or ID of the instance" @@ -3559,6 +4349,11 @@ "name": "view", "about": "Fetch instance", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "instance", "help": "Name or ID of the instance" @@ -3579,6 +4374,11 @@ { "name": "internet-gateway", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -3589,6 +4389,11 @@ { "name": "address", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -3606,6 +4411,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "gateway", "help": "Name or ID of the internet gateway" @@ -3652,6 +4462,11 @@ ], "help": "Also delete routes targeting this gateway element." }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "gateway", "help": "Name or ID of the internet gateway" @@ -3675,6 +4490,11 @@ "name": "list", "about": "List IP addresses attached to internet gateway", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "gateway", "help": "Name or ID of the internet gateway" @@ -3715,6 +4535,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -3753,6 +4578,11 @@ ], "help": "Also delete routes targeting this gateway." }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "gateway", "help": "Name or ID of the gateway" @@ -3775,6 +4605,11 @@ { "name": "ip-pool", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -3789,6 +4624,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "gateway", "help": "Name or ID of the internet gateway" @@ -3834,6 +4674,11 @@ ], "help": "Also delete routes targeting this gateway element." }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "gateway", "help": "Name or ID of the internet gateway" @@ -3861,6 +4706,11 @@ "name": "list", "about": "List IP pools attached to internet gateway", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "gateway", "help": "Name or ID of the internet gateway" @@ -3898,6 +4748,11 @@ "name": "list", "about": "List internet gateways", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -3929,6 +4784,11 @@ "name": "view", "about": "Fetch internet gateway", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "gateway", "help": "Name or ID of the gateway" @@ -3953,6 +4813,11 @@ { "name": "ip-pool", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -3967,6 +4832,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -3989,6 +4859,11 @@ "name": "delete", "about": "Delete IP pool", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "pool", "help": "Name or ID of the IP pool" @@ -4004,6 +4879,11 @@ "name": "list", "about": "List IP pools", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -4026,6 +4906,11 @@ { "name": "range", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -4041,6 +4926,11 @@ { "long": "first" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -4068,6 +4958,11 @@ "about": "List ranges for IP pool", "long_about": "Ranges are ordered by their first address.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -4090,6 +4985,11 @@ { "long": "first" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -4117,6 +5017,11 @@ { "name": "service", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -4127,6 +5032,11 @@ { "name": "range", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -4142,6 +5052,11 @@ { "long": "first" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -4165,6 +5080,11 @@ "about": "List IP ranges for the Oxide service pool", "long_about": "Ranges are ordered by their first address.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -4185,6 +5105,11 @@ { "long": "first" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -4207,6 +5132,11 @@ "name": "view", "about": "Fetch Oxide service IP pool", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -4219,6 +5149,11 @@ { "name": "silo", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -4231,6 +5166,11 @@ "about": "Link IP pool to silo", "long_about": "Users in linked silos can allocate external IPs from this pool for their instances. A silo can have at most one default pool. IPs are allocated from the default pool when users ask for one without specifying a pool.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "is-default", "values": [ @@ -4265,6 +5205,11 @@ "name": "list", "about": "List IP pool's linked silos", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -4291,6 +5236,11 @@ "about": "Unlink IP pool from silo", "long_about": "Will fail if there are any outstanding IPs allocated in the silo.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "pool" }, @@ -4309,6 +5259,11 @@ "about": "Make IP pool default for silo", "long_about": "When a user asks for an IP (e.g., at instance create time) without specifying a pool, the IP comes from the default pool if a default is configured. When a pool is made the default for a silo, any existing default will remain linked to the silo, but will no longer be the default.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "is-default", "values": [ @@ -4347,6 +5302,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -4373,6 +5333,11 @@ "name": "utilization", "about": "Fetch IP pool utilization", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "pool", "help": "Name or ID of the IP pool" @@ -4388,6 +5353,11 @@ "name": "view", "about": "Fetch IP pool", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "pool", "help": "Name or ID of the IP pool" @@ -4406,6 +5376,11 @@ "about": "Ping API", "long_about": "Always responds with Ok if it responds at all.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -4416,6 +5391,11 @@ { "name": "policy", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -4427,6 +5407,11 @@ "name": "update", "about": "Update current silo's IAM policy", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -4446,6 +5431,11 @@ "name": "view", "about": "Fetch current silo's IAM policy", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -4458,6 +5448,11 @@ { "name": "project", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -4472,6 +5467,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -4494,6 +5494,11 @@ "name": "delete", "about": "Delete project", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -4508,6 +5513,11 @@ { "name": "ip-pool", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -4519,6 +5529,11 @@ "name": "list", "about": "List IP pools", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -4542,6 +5557,11 @@ "name": "view", "about": "Fetch IP pool", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "pool", "help": "Name or ID of the IP pool" @@ -4559,6 +5579,11 @@ "name": "list", "about": "List projects", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -4581,6 +5606,11 @@ { "name": "policy", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -4592,6 +5622,11 @@ "name": "update", "about": "Update project's IAM policy", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -4615,6 +5650,11 @@ "name": "view", "about": "Fetch project's IAM policy", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -4635,6 +5675,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -4661,6 +5706,11 @@ "name": "view", "about": "Fetch project", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -4677,6 +5727,11 @@ { "name": "silo", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -4702,6 +5757,11 @@ "false" ] }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "identity-mode", "values": [ @@ -4732,6 +5792,11 @@ "about": "Delete a silo", "long_about": "Delete a silo by name or ID.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -4746,6 +5811,11 @@ { "name": "idp", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -4758,6 +5828,11 @@ "about": "List identity providers for silo", "long_about": "List identity providers for silo by silo name or ID.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -4784,6 +5859,11 @@ { "name": "local", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -4794,6 +5874,11 @@ { "name": "user", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -4810,6 +5895,11 @@ "long": "external-id", "help": "username used to log in" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -4833,6 +5923,11 @@ "name": "delete", "about": "Delete user", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -4853,6 +5948,11 @@ "about": "Set or invalidate user's password", "long_about": "Passwords can only be updated for users in Silos with identity mode `LocalOnly`.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -4883,6 +5983,11 @@ { "name": "saml", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -4901,6 +6006,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "group-attribute-name", "help": "If set, SAML attributes with this name will be considered to denote a user's group membership, where the attribute value(s) should be a comma-separated list of group names." @@ -4963,6 +6073,11 @@ "name": "view", "about": "Fetch SAML identity provider", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -4985,6 +6100,11 @@ { "name": "ip-pool", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -4997,6 +6117,11 @@ "about": "List IP pools linked to silo", "long_about": "Linked IP pools are available to users in the specified silo. A silo can have at most one default pool. IPs are allocated from the default pool when users ask for one without specifying a pool.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -5027,6 +6152,11 @@ "about": "List silos", "long_about": "Lists silos that are discoverable based on the current permissions.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -5049,6 +6179,11 @@ { "name": "policy", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5060,6 +6195,11 @@ "name": "update", "about": "Update silo IAM policy", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -5083,6 +6223,11 @@ "name": "view", "about": "Fetch silo IAM policy", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5099,6 +6244,11 @@ { "name": "quotas", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5110,6 +6260,11 @@ "name": "list", "about": "Lists resource quotas for all silos", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -5136,6 +6291,11 @@ "long": "cpus", "help": "The amount of virtual CPUs available for running instances in the Silo" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -5167,6 +6327,11 @@ "name": "view", "about": "Fetch resource quotas for silo", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5183,6 +6348,11 @@ { "name": "user", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5194,6 +6364,11 @@ "name": "list", "about": "List built-in (system) users in silo", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -5219,6 +6394,11 @@ "name": "view", "about": "Fetch built-in (system) user", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5239,6 +6419,11 @@ { "name": "utilization", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5250,6 +6435,11 @@ "name": "list", "about": "List current utilization state for all silos", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -5273,6 +6463,11 @@ "name": "view", "about": "Fetch current utilization for given silo", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5291,6 +6486,11 @@ "about": "Fetch silo", "long_about": "Fetch silo by name or ID.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5307,6 +6507,11 @@ { "name": "snapshot", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5326,6 +6531,11 @@ "long": "disk", "help": "The disk to be snapshotted" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -5352,6 +6562,11 @@ "name": "delete", "about": "Delete snapshot", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5371,6 +6586,11 @@ "name": "list", "about": "List snapshots", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -5398,6 +6618,11 @@ "name": "view", "about": "Fetch snapshot", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5418,6 +6643,11 @@ { "name": "system", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5428,6 +6658,11 @@ { "name": "hardware", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5438,6 +6673,11 @@ { "name": "disk", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5449,6 +6689,11 @@ "name": "list", "about": "List physical disks", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -5474,6 +6719,11 @@ "long": "disk-id", "help": "ID of the physical disk" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5486,6 +6736,11 @@ { "name": "rack", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5497,6 +6752,11 @@ "name": "list", "about": "List racks", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -5518,6 +6778,11 @@ "name": "view", "about": "Fetch rack", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5534,6 +6799,11 @@ { "name": "sled", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5545,6 +6815,11 @@ "name": "add", "about": "Add sled to initialized rack", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -5570,6 +6845,11 @@ "name": "disk-led", "about": "List physical disks attached to sleds", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -5595,6 +6875,11 @@ "name": "instance-list", "about": "List instances running on given sled", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -5620,6 +6905,11 @@ "name": "list", "about": "List sleds", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -5641,6 +6931,11 @@ "name": "list-uninitialized", "about": "List uninitialized sleds", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -5656,6 +6951,11 @@ "name": "set-provision-policy", "about": "Set sled provision policy", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -5687,6 +6987,11 @@ "name": "view", "about": "Fetch sled", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5703,6 +7008,11 @@ { "name": "switch", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5714,6 +7024,11 @@ "name": "list", "about": "List switches", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -5735,6 +7050,11 @@ "name": "view", "about": "Fetch switch", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5751,6 +7071,11 @@ { "name": "switch-port", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5762,6 +7087,11 @@ "name": "apply-settings", "about": "Apply switch port settings", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -5797,6 +7127,11 @@ "name": "clear-settings", "about": "Clear switch port settings", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "port", "help": "A name to use when selecting switch ports." @@ -5820,6 +7155,11 @@ "name": "list", "about": "List switch ports", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -5845,6 +7185,11 @@ "name": "show-status", "about": "Get the status of switch ports.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5856,6 +7201,11 @@ "name": "status", "about": "Get switch port status", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "port", "help": "A name to use when selecting switch ports." @@ -5882,6 +7232,11 @@ { "name": "networking", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5893,6 +7248,11 @@ "name": "addr", "about": "Manage switch port addresses.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -5908,6 +7268,11 @@ "long": "addr", "help": "Address to add" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "lot", "help": "Address lot to allocate from" @@ -5981,6 +7346,11 @@ "long": "addr", "help": "Address to remove" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "port", "values": [ @@ -6043,6 +7413,11 @@ { "name": "address-lot", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -6053,6 +7428,11 @@ { "name": "block", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -6068,6 +7448,11 @@ "long": "address-lot", "help": "Name or ID of the address lot" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -6094,6 +7479,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -6128,6 +7518,11 @@ "long": "address-lot", "help": "Name or ID of the address lot" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -6139,6 +7534,11 @@ "name": "list", "about": "List address lots", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -6163,6 +7563,11 @@ { "name": "allow-list", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -6177,6 +7582,11 @@ { "long": "any" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "ip" }, @@ -6199,6 +7609,11 @@ "name": "view", "about": "Get user-facing services IP allowlist", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -6211,6 +7626,11 @@ { "name": "bfd", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -6222,6 +7642,11 @@ "name": "disable", "about": "Disable a BFD session", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -6253,6 +7678,11 @@ "long": "detection-threshold", "help": "The negotiated Control packet transmission interval, multiplied by this variable, will be the Detection Time for this session (as seen by the remote system)" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -6296,6 +7726,11 @@ "name": "status", "about": "Get BFD status", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -6308,6 +7743,11 @@ { "name": "bgp", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -6331,6 +7771,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "prefix", "help": "The prefix to announce" @@ -6345,6 +7790,11 @@ { "name": "announce-set", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -6360,6 +7810,11 @@ "long": "announce-set", "help": "Name or ID of the announce set" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -6371,6 +7826,11 @@ "name": "list", "about": "List BGP announce sets", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -6402,6 +7862,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -6425,6 +7890,11 @@ { "name": "announcement", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -6440,6 +7910,11 @@ "long": "announce-set", "help": "Name or ID of the announce set" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -6458,6 +7933,11 @@ "long": "authstring", "help": "Use the given authorization string for TCP-MD5 authentication with the peer" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "peer", "help": "Peer to add the auth config to" @@ -6522,6 +8002,11 @@ { "name": "config", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -6543,6 +8028,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -6569,6 +8059,11 @@ "name": "delete", "about": "Delete BGP configuration", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "name-or-id", "help": "A name or id to use when selecting BGP config." @@ -6584,6 +8079,11 @@ "name": "list", "about": "List BGP configurations", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -6608,6 +8108,11 @@ { "name": "exported", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -6619,6 +8124,11 @@ "name": "ipv4", "about": "Get BGP exported routes", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -6645,6 +8155,11 @@ ], "help": "Whether to apply the filter to imported or exported prefixes" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "no-filtering", "help": "Do not filter" @@ -6718,6 +8233,11 @@ "long": "asn", "help": "The ASN to filter on. Required." }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -6728,6 +8248,11 @@ { "name": "imported", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -6743,6 +8268,11 @@ "long": "asn", "help": "The ASN to filter on. Required." }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -6757,6 +8287,11 @@ "about": "Manage BGP peers.", "long_about": "Manage BGP peers.\n\nThis command provides set and delete subcommands for managing BGP peers.\nBGP peer configuration is a part of a switch port settings configuration.\nThe peer set and delete subcommands perform read-modify-write operations\non switch port settings objects to manage BGP peer configurations.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -6772,6 +8307,11 @@ "long": "addr", "help": "Address of the peer to remove" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "port", "values": [ @@ -6869,6 +8409,11 @@ "long": "enforce-first-as", "help": "Enforce that the first AS in paths received from this peer is the peer's AS" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "hold-time", "help": "How long to hold peer connections between keepalives (seconds)" @@ -6965,6 +8510,11 @@ "about": "Set a local preference for a peer.", "long_about": "Set a local preference for a peer.\n\nThis command associates a local preference for the specified peer. When\nroutes are imported by this peer, they will be installed into the routing\ninformation base (RIB) with the specified preference. This command works\nby performing a read-modify-write on the switch port settings configuration\nidentified by the specified rack/switch/port.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "local-pref", "help": "Apply this local preference to routes received from the peer" @@ -7035,6 +8585,11 @@ "about": "Get the status of BGP on the rack.", "long_about": "Get the status of BGP on the rack.\n\nThis will show the peering status for all peers on all switches.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -7046,6 +8601,11 @@ "name": "status", "about": "Get BGP peer status", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -7062,6 +8622,11 @@ "long": "announce-set", "help": "The announce set to withdraw from" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "prefix", "help": "The prefix to withdraw" @@ -7078,6 +8643,11 @@ { "name": "inbound-icmp", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -7097,6 +8667,11 @@ ], "help": "When enabled, Nexus is able to receive ICMP Destination Unreachable type 3 (port unreachable) and type 4 (fragmentation needed), Redirect, and Time Exceeded messages. These enable Nexus to perform Path MTU discovery and better cope with fragmentation issues. Otherwise all inbound ICMP traffic will be dropped." }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -7116,6 +8691,11 @@ "name": "view", "about": "Return whether API services can receive limited ICMP traffic", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -7130,6 +8710,11 @@ "about": "Manage switch port links.", "long_about": "Manage switch port links.\n\nLinks carry layer-2 Ethernet properties for a lane or set of lanes on a\nswitch port. Lane geometry is defined in physical port settings. At the\npresent time only single lane configurations are supported, and thus only\na single link per physical port is supported.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -7154,6 +8739,11 @@ ], "help": "The forward error correction mode of the link" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "mtu", "help": "Maximum transmission unit for the link" @@ -7234,6 +8824,11 @@ "name": "delete", "about": "Remove a link from a port", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "port", "values": [ @@ -7296,6 +8891,11 @@ { "name": "lldp", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -7307,6 +8907,11 @@ "name": "neighbors", "about": "Fetch the LLDP neighbors seen on a switch port", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -7352,6 +8957,11 @@ ], "help": "Whether or not the LLDP service is enabled." }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "id", "help": "The id of this LLDP service instance." @@ -7407,6 +9017,11 @@ "name": "view", "about": "Fetch the LLDP configuration for a switch port", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "port", "help": "A name to use when selecting switch ports." @@ -7431,6 +9046,11 @@ { "name": "loopback-address", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -7458,6 +9078,11 @@ ], "help": "Address is an anycast address. This allows the address to be assigned to multiple locations simultaneously." }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -7493,6 +9118,11 @@ "long": "address", "help": "The IP address and subnet mask to use when selecting the loopback address." }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -7516,6 +9146,11 @@ "name": "list", "about": "List loopback addresses", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -7539,6 +9174,11 @@ "name": "route", "about": "Manage static switch routes.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -7554,6 +9194,11 @@ "long": "destination", "help": "The route destination" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "nexthop", "help": "The route nexthop" @@ -7627,6 +9272,11 @@ "long": "destination", "help": "The route destination" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "nexthop", "help": "The route nexthop" @@ -7701,6 +9351,11 @@ { "name": "switch-port-settings", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -7715,6 +9370,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -7737,6 +9397,11 @@ "name": "delete", "about": "Delete switch port settings", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "port-settings", "help": "An optional name or id to use when selecting port settings." @@ -7752,6 +9417,11 @@ "name": "list", "about": "List switch port settings", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -7779,6 +9449,11 @@ "name": "show", "about": "Get the configuration of switch ports.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -7790,6 +9465,11 @@ "name": "view", "about": "Get information about switch port", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "port", "help": "A name or id to use when selecting switch port settings info objects." @@ -7808,6 +9488,11 @@ { "name": "policy", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -7819,6 +9504,11 @@ "name": "update", "about": "Update top-level IAM policy", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -7838,6 +9528,11 @@ "name": "view", "about": "Fetch top-level IAM policy", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -7850,6 +9545,11 @@ { "name": "update", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -7860,6 +9560,11 @@ { "name": "repo", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -7870,6 +9575,11 @@ { "name": "upload", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "path", "short": "p", @@ -7891,6 +9601,11 @@ { "name": "user", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -7902,6 +9617,11 @@ "name": "list", "about": "List users", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "group" }, @@ -7928,6 +9648,11 @@ "name": "utilization", "about": "Fetch resource utilization for user's current silo", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -7939,6 +9664,11 @@ "name": "version", "about": "Prints version information about the CLI.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -7949,6 +9679,11 @@ { "name": "vpc", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -7966,6 +9701,11 @@ { "long": "dns-name" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "ipv6-prefix", "help": "The IPv6 prefix for this VPC\n\nAll IPv6 subnets created from this VPC must be taken from this range, which should be a Unique Local Address in the range `fd00::/48`. The default VPC Subnet will have the first `/64` range from this prefix." @@ -7996,6 +9736,11 @@ "name": "delete", "about": "Delete VPC", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -8014,6 +9759,11 @@ { "name": "firewall-rules", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -8026,6 +9776,11 @@ "about": "Replace firewall rules", "long_about": "The maximum number of rules per VPC is 1024.\n\nTargets are used to specify the set of instances to which a firewall rule applies. You can target instances directly by name, or specify a VPC, VPC subnet, IP, or IP subnet, which will apply the rule to traffic going to all matching instances. Targets are additive: the rule applies to instances matching ANY target. The maximum number of targets is 256.\n\nFilters reduce the scope of a firewall rule. Without filters, the rule applies to all packets to the targets (or from the targets, if it's an outbound rule). With multiple filters, the rule applies only to packets matching ALL filters. The maximum number of each type of filter is 256.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -8053,6 +9808,11 @@ "name": "view", "about": "List firewall rules", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -8074,6 +9834,11 @@ "name": "list", "about": "List VPCs", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -8100,6 +9865,11 @@ { "name": "router", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -8114,6 +9884,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -8144,6 +9919,11 @@ "name": "delete", "about": "Delete router", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -8167,6 +9947,11 @@ "name": "list", "about": "List routers", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -8197,6 +9982,11 @@ { "name": "route", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -8211,6 +10001,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -8245,6 +10040,11 @@ "name": "delete", "about": "Delete route", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -8273,6 +10073,11 @@ "about": "List routes", "long_about": "List the routes associated with a router in a particular VPC.", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -8311,6 +10116,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -8349,6 +10159,11 @@ "name": "view", "about": "Fetch route", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -8381,6 +10196,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -8415,6 +10235,11 @@ "name": "view", "about": "Fetch router", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -8439,6 +10264,11 @@ { "name": "subnet", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -8457,6 +10287,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "ipv4-block", "help": "The IPv4 address range for this subnet.\n\nIt must be allocated from an RFC 1918 private address range, and must not overlap with any other existing subnet in the VPC." @@ -8495,6 +10330,11 @@ "name": "delete", "about": "Delete subnet", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -8518,6 +10358,11 @@ "name": "list", "about": "List subnets", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -8548,6 +10393,11 @@ { "name": "nic", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -8559,6 +10409,11 @@ "name": "list", "about": "List network interfaces", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -8603,6 +10458,11 @@ { "long": "description" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -8637,6 +10497,11 @@ "name": "view", "about": "Fetch subnet", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -8668,6 +10533,11 @@ { "long": "dns-name" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "json-body", "help": "Path to a file that contains the full json body." @@ -8698,6 +10568,11 @@ "name": "view", "about": "Fetch VPC", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", diff --git a/cli/src/cli_builder.rs b/cli/src/cli_builder.rs index 30f90bc5..7de790db 100644 --- a/cli/src/cli_builder.rs +++ b/cli/src/cli_builder.rs @@ -4,7 +4,10 @@ // Copyright 2025 Oxide Computer Company -use std::{any::TypeId, collections::BTreeMap, marker::PhantomData, net::IpAddr, path::PathBuf}; +use std::{ + any::TypeId, collections::BTreeMap, marker::PhantomData, net::IpAddr, path::PathBuf, + str::FromStr, +}; use anyhow::{bail, Result}; use async_trait::async_trait; @@ -14,10 +17,48 @@ use tracing_subscriber::EnvFilter; use crate::{ context::Context, generated_cli::{Cli, CliCommand}, - OxideOverride, RunnableCmd, + oxide_override::OxideOverride, + RunnableCmd, }; use oxide::{types::ByteCount, ClientConfig}; +#[derive(Default, Clone, Debug)] +pub enum Format { + /// Output as JSON + #[default] + Json, + /// Output as table with optional columns + Table { + /// Fields to display in the table + fields: Vec, + }, +} + +impl FromStr for Format { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + if s == "json" { + return Ok(Format::Json); + } + + if let Some(fields_str) = s.strip_prefix("table:") { + let fields: Vec = fields_str + .split(',') + // Allow users to pass a quoted string with spaces between column names, + // e.g. `--format "table: name, id"` + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + Ok(Format::Table { fields }) + } else if s == "table" { + Ok(Format::Table { fields: vec![] }) + } else { + Err("available formats are 'json' and 'table'") + } + } +} + /// Control an Oxide environment #[derive(clap::Parser, Debug, Clone)] #[command(name = "oxide", verbatim_doc_comment)] @@ -30,6 +71,25 @@ struct OxideCli { #[clap(long, global = true, help_heading = "Global Options")] pub profile: Option, + /// Format in which to print output + /// + /// Possible values: + /// - json Output as pretty-printed JSON + /// - table Output as table with all columns displayed + /// - table:field1,field2,... Output as table, specifying which columns to display + /// + /// Examples: + /// --format json + /// --format table + /// --format table:name,id,description + #[clap( + long, + global = true, + help_heading = "Global Options", + verbatim_doc_comment + )] + pub format: Option, + /// Directory to use for configuration #[clap(long, value_name = "DIR")] pub config_dir: Option, @@ -248,6 +308,7 @@ impl<'a> NewCli<'a> { let OxideCli { profile, + format, debug, config_dir, resolve, @@ -324,7 +385,7 @@ impl<'a> NewCli<'a> { } } - let ctx = Context::new(client_config)?; + let ctx = Context::new(client_config, format.unwrap_or_default())?; cmd.run_cmd(sm, &ctx).await } @@ -353,7 +414,11 @@ struct GeneratedCmd(CliCommand); impl RunIt for GeneratedCmd { async fn run_cmd(&self, matches: &ArgMatches, ctx: &Context) -> Result<()> { let client = oxide::Client::new_authenticated_config(ctx.client_config())?; - let cli = Cli::new(client, OxideOverride::default()); + let config = match ctx.format() { + Format::Json => OxideOverride::new_json(), + Format::Table { fields } => OxideOverride::new_table(fields), + }; + let cli = Cli::new(client, config); cli.execute(self.0, matches).await } diff --git a/cli/src/cmd_update.rs b/cli/src/cmd_update.rs index 116f87c8..4c473137 100644 --- a/cli/src/cmd_update.rs +++ b/cli/src/cmd_update.rs @@ -14,7 +14,10 @@ use oxide::{Client, ClientExperimentalExt}; use tokio::{fs::File, sync::watch}; use tokio_util::io::ReaderStream; -use crate::{generated_cli::CliConfig, util::start_progress_bar, AuthenticatedCmd, OxideOverride}; +use crate::{ + generated_cli::CliConfig, oxide_override::OxideOverride, util::start_progress_bar, + AuthenticatedCmd, +}; #[derive(Parser, Debug, Clone)] #[command(verbatim_doc_comment)] diff --git a/cli/src/context.rs b/cli/src/context.rs index e1e16d4d..ab1feeb1 100644 --- a/cli/src/context.rs +++ b/cli/src/context.rs @@ -2,7 +2,7 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -// Copyright 2024 Oxide Computer Company +// Copyright 2025 Oxide Computer Company use std::path::{Path, PathBuf}; @@ -10,6 +10,8 @@ use anyhow::Result; use oxide::{BasicConfigFile, ClientConfig, CredentialsFile}; use serde::{de::DeserializeOwned, Deserialize}; +use crate::cli_builder::Format; + /// The Context is what we use to carry globally relevant information around /// to subcommands. This includes configuration information and top-level /// command-line options. This may be used to construct an authenticated @@ -18,6 +20,7 @@ pub struct Context { client_config: ClientConfig, cred_file: CredentialsFile, config_file: ConfigFile, + format: Format, } #[derive(Deserialize, Debug, Default)] @@ -36,7 +39,7 @@ fn read_or_default(path: PathBuf) -> Result { } impl Context { - pub fn new(client_config: ClientConfig) -> Result { + pub fn new(client_config: ClientConfig, format: Format) -> Result { let config_dir = client_config.config_dir(); let cred_file = read_or_default(config_dir.join("credentials.toml"))?; let config_file = read_or_default(config_dir.join("config.toml"))?; @@ -47,6 +50,7 @@ impl Context { client_config, cred_file, config_file, + format, }) } @@ -61,6 +65,10 @@ impl Context { pub fn config_file(&self) -> &ConfigFile { &self.config_file } + + pub fn format(&self) -> &Format { + &self.format + } } fn validate_credentials_permissions(path: &Path) { diff --git a/cli/src/main.rs b/cli/src/main.rs index 99762ac2..cbe1d718 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -8,22 +8,12 @@ #![cfg_attr(not(test), deny(clippy::print_stdout, clippy::print_stderr))] use std::io; -use std::net::IpAddr; -use std::path::PathBuf; -use std::sync::atomic::AtomicBool; -use anyhow::{Context as _, Result}; +use anyhow::Result; use async_trait::async_trait; -use base64::Engine; use cli_builder::NewCli; use context::Context; -use generated_cli::CliConfig; -use oxide::{ - types::{ - AllowedSourceIps, DerEncodedKeyPair, IdpMetadataSource, IpRange, Ipv4Range, Ipv6Range, - }, - Client, -}; +use oxide::Client; use url::Url; mod cmd_api; @@ -40,6 +30,7 @@ mod cmd_version; mod cli_builder; mod context; +mod oxide_override; #[macro_use] mod print; mod util; @@ -135,212 +126,6 @@ async fn main() { } } -#[derive(Default)] -struct OxideOverride { - needs_comma: AtomicBool, -} - -impl OxideOverride { - fn ip_range(matches: &clap::ArgMatches) -> anyhow::Result { - let first = matches.get_one::("first").unwrap(); - let last = matches.get_one::("last").unwrap(); - - match (first, last) { - (IpAddr::V4(first), IpAddr::V4(last)) => { - let range = Ipv4Range::try_from(Ipv4Range::builder().first(*first).last(*last))?; - Ok(range.into()) - } - (IpAddr::V6(first), IpAddr::V6(last)) => { - let range = Ipv6Range::try_from(Ipv6Range::builder().first(*first).last(*last))?; - Ok(range.into()) - } - _ => anyhow::bail!( - "first and last must either both be ipv4 or ipv6 addresses".to_string() - ), - } - } -} - -impl CliConfig for OxideOverride { - fn success_item(&self, value: &oxide::ResponseValue) - where - T: schemars::JsonSchema + serde::Serialize + std::fmt::Debug, - { - let s = serde_json::to_string_pretty(std::ops::Deref::deref(value)) - .expect("failed to serialize return to json"); - println_nopipe!("{}", s); - } - - fn success_no_item(&self, _: &oxide::ResponseValue<()>) {} - - fn error(&self, _value: &oxide::Error) - where - T: schemars::JsonSchema + serde::Serialize + std::fmt::Debug, - { - eprintln_nopipe!("error"); - } - - fn list_start(&self) - where - T: schemars::JsonSchema + serde::Serialize + std::fmt::Debug, - { - self.needs_comma - .store(false, std::sync::atomic::Ordering::Relaxed); - print_nopipe!("["); - } - - fn list_item(&self, value: &T) - where - T: schemars::JsonSchema + serde::Serialize + std::fmt::Debug, - { - let s = serde_json::to_string_pretty(&[value]).expect("failed to serialize result to json"); - if self.needs_comma.load(std::sync::atomic::Ordering::Relaxed) { - print_nopipe!(", {}", &s[4..s.len() - 2]); - } else { - print_nopipe!("\n{}", &s[2..s.len() - 2]); - }; - self.needs_comma - .store(true, std::sync::atomic::Ordering::Relaxed); - } - - fn list_end_success(&self) - where - T: schemars::JsonSchema + serde::Serialize + std::fmt::Debug, - { - if self.needs_comma.load(std::sync::atomic::Ordering::Relaxed) { - println_nopipe!("\n]"); - } else { - println_nopipe!("]"); - } - } - - fn list_end_error(&self, _value: &oxide::Error) - where - T: schemars::JsonSchema + serde::Serialize + std::fmt::Debug, - { - self.list_end_success::() - } - - // Deal with all the operations that require an `IpPool` as input - fn execute_ip_pool_range_add( - &self, - matches: &clap::ArgMatches, - request: &mut oxide::builder::IpPoolRangeAdd, - ) -> anyhow::Result<()> { - *request = request.to_owned().body(Self::ip_range(matches)?); - Ok(()) - } - fn execute_ip_pool_range_remove( - &self, - matches: &clap::ArgMatches, - request: &mut oxide::builder::IpPoolRangeRemove, - ) -> anyhow::Result<()> { - *request = request.to_owned().body(Self::ip_range(matches)?); - Ok(()) - } - fn execute_ip_pool_service_range_add( - &self, - matches: &clap::ArgMatches, - request: &mut oxide::builder::IpPoolServiceRangeAdd, - ) -> anyhow::Result<()> { - *request = request.to_owned().body(Self::ip_range(matches)?); - Ok(()) - } - fn execute_ip_pool_service_range_remove( - &self, - matches: &clap::ArgMatches, - request: &mut oxide::builder::IpPoolServiceRangeRemove, - ) -> anyhow::Result<()> { - *request = request.to_owned().body(Self::ip_range(matches)?); - Ok(()) - } - - fn execute_saml_identity_provider_create( - &self, - matches: &clap::ArgMatches, - request: &mut oxide::builder::SamlIdentityProviderCreate, - ) -> anyhow::Result<()> { - match matches - .get_one::("idp_metadata_source") - .map(clap::Id::as_str) - { - Some("metadata-url") => { - let value = matches.get_one::("metadata-url").unwrap(); - *request = request.to_owned().body_map(|body| { - body.idp_metadata_source(IdpMetadataSource::Url { url: value.clone() }) - }); - Ok::<_, anyhow::Error>(()) - } - Some("metadata-value") => { - let xml_path = matches.get_one::("metadata-value").unwrap(); - let xml_bytes = std::fs::read(xml_path).with_context(|| { - format!("failed to read metadata XML file {}", xml_path.display()) - })?; - let encoded_xml = base64::engine::general_purpose::STANDARD.encode(xml_bytes); - *request = request.to_owned().body_map(|body| { - body.idp_metadata_source(IdpMetadataSource::Base64EncodedXml { - data: encoded_xml, - }) - }); - Ok(()) - } - _ => unreachable!("invalid value for idp_metadata_source group"), - }?; - - if matches.get_one::("signing_keypair").is_some() { - let privkey_path = matches.get_one::("private-key").unwrap(); - let privkey_bytes = std::fs::read(privkey_path).with_context(|| { - format!("failed to read private key file {}", privkey_path.display()) - })?; - let encoded_privkey = base64::engine::general_purpose::STANDARD.encode(&privkey_bytes); - - let cert_path = matches.get_one::("public-cert").unwrap(); - let cert_bytes = std::fs::read(cert_path).with_context(|| { - format!("failed to read public cert file {}", cert_path.display()) - })?; - let encoded_cert = base64::engine::general_purpose::STANDARD.encode(&cert_bytes); - - *request = request.to_owned().body_map(|body| { - body.signing_keypair(DerEncodedKeyPair { - private_key: encoded_privkey, - public_cert: encoded_cert, - }) - }); - } - Ok(()) - } - - fn execute_networking_allow_list_update( - &self, - matches: &clap::ArgMatches, - request: &mut oxide::builder::NetworkingAllowListUpdate, - ) -> anyhow::Result<()> { - match matches - .get_one::("allow-list") - .map(clap::Id::as_str) - { - Some("any") => { - let value = matches.get_one::("any").unwrap(); - assert!(value); - *request = request - .to_owned() - .body_map(|body| body.allowed_ips(AllowedSourceIps::Any)); - } - Some("ips") => { - let values: Vec = matches.get_many("ips").unwrap().cloned().collect(); - *request = request.to_owned().body_map(|body| { - body.allowed_ips(AllowedSourceIps::List( - values.into_iter().map(IpOrNet::into_ip_net).collect(), - )) - }); - } - _ => unreachable!("invalid value for allow-list group"), - } - - Ok(()) - } -} - #[cfg(test)] mod tests { use clap::Command; diff --git a/cli/src/oxide_override.rs b/cli/src/oxide_override.rs new file mode 100644 index 00000000..b63b7934 --- /dev/null +++ b/cli/src/oxide_override.rs @@ -0,0 +1,595 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Copyright 2025 Oxide Computer Company + +use std::collections::HashSet; +use std::net::IpAddr; +use std::path::PathBuf; +use std::sync::atomic::AtomicBool; +use std::sync::Mutex; + +use crate::generated_cli::CliConfig; +use crate::{eprintln_nopipe, print_nopipe, println_nopipe, IpOrNet}; +use anyhow::Context as _; +use base64::Engine; +use comfy_table::{ContentArrangement, Table}; +use indexmap::IndexSet; +use oxide::types::{ + AllowedSourceIps, DerEncodedKeyPair, IdpMetadataSource, IpRange, Ipv4Range, Ipv6Range, +}; +use schemars::schema::{RootSchema, Schema, SingleOrVec}; + +const TABLE_NOT_SUPPORTED: &str = "table formatting is not supported for this command"; + +pub enum OxideOverride { + Json { + needs_comma: AtomicBool, + }, + Table { + fields: Box>>, + table: Box>, + }, +} + +impl Default for OxideOverride { + fn default() -> Self { + Self::new_json() + } +} + +impl CliConfig for OxideOverride { + fn success_item(&self, value: &oxide::ResponseValue) + where + T: schemars::JsonSchema + serde::Serialize + std::fmt::Debug, + { + match &self { + OxideOverride::Json { needs_comma: _ } => { + let s = serde_json::to_string_pretty(std::ops::Deref::deref(value)) + .expect("failed to serialize return to json"); + println_nopipe!("{}", s); + } + OxideOverride::Table { fields, table } => { + let fields = fields.lock().unwrap(); + + let root_schema = schemars::schema_for!(T); + let (available_fields, obj_type) = success_item_fields(&root_schema); + + if available_fields.is_empty() { + println_nopipe!("{TABLE_NOT_SUPPORTED}"); + return; + } + + let mut table = table.lock().unwrap(); + let printable_fields = set_header_fields(&fields, available_fields, &mut table); + + let serde_json::Value::Object(obj) = + serde_json::to_value(std::ops::Deref::deref(value)) + .expect("failed to serialize result to json") + else { + unreachable!("result was not a JSON object"); + }; + + match obj_type { + ReturnType::Array => { + let Some(serde_json::Value::Array(arr)) = obj.values().next() else { + unreachable!("object was not an array"); + }; + + for entry in arr { + let serde_json::Value::Object(obj) = entry else { + let s = serde_json::to_string_pretty(std::ops::Deref::deref(value)) + .expect("failed to serialize return to json"); + println_nopipe!("{}", s); + return; + }; + + let row = create_row(&printable_fields, obj); + table.add_row(row); + } + } + ReturnType::Object => { + let row = create_row(&printable_fields, &obj); + table.add_row(row); + } + } + + println_nopipe!("{table}"); + } + } + } + + fn success_no_item(&self, _: &oxide::ResponseValue<()>) {} + + fn error(&self, _value: &oxide::Error) + where + T: schemars::JsonSchema + serde::Serialize + std::fmt::Debug, + { + eprintln_nopipe!("error"); + } + + fn list_start(&self) + where + T: schemars::JsonSchema + serde::Serialize + std::fmt::Debug, + { + match &self { + OxideOverride::Json { needs_comma } => { + needs_comma.store(false, std::sync::atomic::Ordering::Relaxed); + print_nopipe!("["); + } + OxideOverride::Table { fields, table } => { + let mut fields = fields.lock().unwrap(); + + let root_schema = schemars::schema_for!(T); + let available_fields = list_start_fields(&root_schema); + + let mut table = table.lock().unwrap(); + let mut printable_fields = set_header_fields(&fields, available_fields, &mut table); + + if printable_fields.is_empty() { + println_nopipe!("{TABLE_NOT_SUPPORTED}"); + } + + // Store our list of fields to print. + std::mem::swap(&mut printable_fields, &mut fields); + } + } + } + + fn list_item(&self, value: &T) + where + T: schemars::JsonSchema + serde::Serialize + std::fmt::Debug, + { + match &self { + OxideOverride::Json { needs_comma } => { + let s = serde_json::to_string_pretty(&[value]) + .expect("failed to serialize result to json"); + if needs_comma.load(std::sync::atomic::Ordering::Relaxed) { + print_nopipe!(", {}", &s[4..s.len() - 2]); + } else { + print_nopipe!("\n{}", &s[2..s.len() - 2]); + }; + needs_comma.store(true, std::sync::atomic::Ordering::Relaxed); + } + OxideOverride::Table { fields, table } => { + let s = serde_json::to_value(value).expect("failed to serialize result to json"); + if let serde_json::Value::Object(obj) = s { + let fields = fields.lock().unwrap(); + let mut table = table.lock().unwrap(); + + let row = create_row(&fields, &obj); + table.add_row(row); + } + } + } + } + + fn list_end_success(&self) + where + T: schemars::JsonSchema + serde::Serialize + std::fmt::Debug, + { + match &self { + OxideOverride::Json { needs_comma } => { + if needs_comma.load(std::sync::atomic::Ordering::Relaxed) { + println_nopipe!("\n]"); + } else { + println_nopipe!("]"); + } + } + OxideOverride::Table { fields: _, table } => { + let table = table.lock().unwrap(); + println_nopipe!("{table}"); + } + } + } + + fn list_end_error(&self, _value: &oxide::Error) + where + T: schemars::JsonSchema + serde::Serialize + std::fmt::Debug, + { + self.list_end_success::() + } + + // Deal with all the operations that require an `IpPool` as input + fn execute_ip_pool_range_add( + &self, + matches: &clap::ArgMatches, + request: &mut oxide::builder::IpPoolRangeAdd, + ) -> anyhow::Result<()> { + *request = request.to_owned().body(Self::ip_range(matches)?); + Ok(()) + } + fn execute_ip_pool_range_remove( + &self, + matches: &clap::ArgMatches, + request: &mut oxide::builder::IpPoolRangeRemove, + ) -> anyhow::Result<()> { + *request = request.to_owned().body(Self::ip_range(matches)?); + Ok(()) + } + fn execute_ip_pool_service_range_add( + &self, + matches: &clap::ArgMatches, + request: &mut oxide::builder::IpPoolServiceRangeAdd, + ) -> anyhow::Result<()> { + *request = request.to_owned().body(Self::ip_range(matches)?); + Ok(()) + } + fn execute_ip_pool_service_range_remove( + &self, + matches: &clap::ArgMatches, + request: &mut oxide::builder::IpPoolServiceRangeRemove, + ) -> anyhow::Result<()> { + *request = request.to_owned().body(Self::ip_range(matches)?); + Ok(()) + } + + fn execute_saml_identity_provider_create( + &self, + matches: &clap::ArgMatches, + request: &mut oxide::builder::SamlIdentityProviderCreate, + ) -> anyhow::Result<()> { + match matches + .get_one::("idp_metadata_source") + .map(clap::Id::as_str) + { + Some("metadata-url") => { + let value = matches.get_one::("metadata-url").unwrap(); + *request = request.to_owned().body_map(|body| { + body.idp_metadata_source(IdpMetadataSource::Url { url: value.clone() }) + }); + Ok::<_, anyhow::Error>(()) + } + Some("metadata-value") => { + let xml_path = matches.get_one::("metadata-value").unwrap(); + let xml_bytes = std::fs::read(xml_path).with_context(|| { + format!("failed to read metadata XML file {}", xml_path.display()) + })?; + let encoded_xml = base64::engine::general_purpose::STANDARD.encode(xml_bytes); + *request = request.to_owned().body_map(|body| { + body.idp_metadata_source(IdpMetadataSource::Base64EncodedXml { + data: encoded_xml, + }) + }); + Ok(()) + } + _ => unreachable!("invalid value for idp_metadata_source group"), + }?; + + if matches.get_one::("signing_keypair").is_some() { + let privkey_path = matches.get_one::("private-key").unwrap(); + let privkey_bytes = std::fs::read(privkey_path).with_context(|| { + format!("failed to read private key file {}", privkey_path.display()) + })?; + let encoded_privkey = base64::engine::general_purpose::STANDARD.encode(&privkey_bytes); + + let cert_path = matches.get_one::("public-cert").unwrap(); + let cert_bytes = std::fs::read(cert_path).with_context(|| { + format!("failed to read public cert file {}", cert_path.display()) + })?; + let encoded_cert = base64::engine::general_purpose::STANDARD.encode(&cert_bytes); + + *request = request.to_owned().body_map(|body| { + body.signing_keypair(DerEncodedKeyPair { + private_key: encoded_privkey, + public_cert: encoded_cert, + }) + }); + } + Ok(()) + } + + fn execute_networking_allow_list_update( + &self, + matches: &clap::ArgMatches, + request: &mut oxide::builder::NetworkingAllowListUpdate, + ) -> anyhow::Result<()> { + match matches + .get_one::("allow-list") + .map(clap::Id::as_str) + { + Some("any") => { + let value = matches.get_one::("any").unwrap(); + assert!(value); + *request = request + .to_owned() + .body_map(|body| body.allowed_ips(AllowedSourceIps::Any)); + } + Some("ips") => { + let values: Vec = matches.get_many("ips").unwrap().cloned().collect(); + *request = request.to_owned().body_map(|body| { + body.allowed_ips(AllowedSourceIps::List( + values.into_iter().map(IpOrNet::into_ip_net).collect(), + )) + }); + } + _ => unreachable!("invalid value for allow-list group"), + } + + Ok(()) + } +} + +impl OxideOverride { + /// Construct a new OxideOverride for JSON output. + pub fn new_json() -> Self { + OxideOverride::Json { + needs_comma: AtomicBool::new(false), + } + } + + /// Construct a new OxideOverride for tabular output. + pub fn new_table(fields: &[String]) -> Self { + let mut table = Table::new(); + + // Downcase user-requested fields to better match the schema. + let lowercase_fields = fields.iter().map(|f| f.to_lowercase()).collect(); + table + .load_preset(comfy_table::presets::NOTHING) + .set_content_arrangement(ContentArrangement::Disabled); + + OxideOverride::Table { + fields: Box::new(Mutex::new(lowercase_fields)), + table: Box::new(Mutex::new(table)), + } + } + + fn ip_range(matches: &clap::ArgMatches) -> anyhow::Result { + let first = matches.get_one::("first").unwrap(); + let last = matches.get_one::("last").unwrap(); + + match (first, last) { + (IpAddr::V4(first), IpAddr::V4(last)) => { + let range = Ipv4Range::try_from(Ipv4Range::builder().first(*first).last(*last))?; + Ok(range.into()) + } + (IpAddr::V6(first), IpAddr::V6(last)) => { + let range = Ipv6Range::try_from(Ipv6Range::builder().first(*first).last(*last))?; + Ok(range.into()) + } + _ => anyhow::bail!( + "first and last must either both be ipv4 or ipv6 addresses".to_string() + ), + } + } +} + +enum ReturnType { + Array, + Object, +} + +/// Find the fields present on a the object returned by `success_item`. +fn success_item_fields(root_schema: &RootSchema) -> (Vec, ReturnType) { + let props = match ( + root_schema.schema.object.as_ref().map(|o| &o.properties), + root_schema + .schema + .subschemas + .as_ref() + .and_then(|s| s.one_of.as_ref()), + ) { + // Generally the array items are a single type. + (Some(props), _) => props, + // Some endpoints return an enum, e.g. + // `/v1/anti-affinity-groups/{anti_affinity_group}/members/instance`. + // Collect the fields from all of the variants. + (_, Some(variants)) => { + return (collect_variant_fields(variants), ReturnType::Object); + } + // Some endpoints, e.g. `/v1/system/hardware/switch-port/{port}/status` will + // return an untyped JSON value. Just bail out when we hit these. + _ => { + return (Vec::new(), ReturnType::Object); + } + }; + + // Check if we're receiving an array. + let arr = match props + .iter() + .next() + .and_then(|(_k, v)| v.clone().into_object().array) + { + Some(arr) => arr, + None => { + let available_fields = root_schema + .schema + .object + .as_ref() + .expect("schema missing object") + .properties + .keys() + .cloned() + .collect(); + return (available_fields, ReturnType::Object); + } + }; + + // We have an array, determine the type of object it contains. + let item_type = match arr.items.unwrap() { + SingleOrVec::Single(s) => { + let Some(item_name) = s.into_object().reference else { + // `/v1/instances/{instance}/serial-console` is unique in returning a byte stream + // instead of a referenced type. We can't display this, so bail out. + return (Vec::new(), ReturnType::Object); + }; + let Some(name) = item_name.strip_prefix("#/definitions/") else { + unimplemented!("returned array type was not a reference"); + }; + name.to_string() + } + SingleOrVec::Vec(_) => { + unimplemented!("nested array of items") + } + }; + + let ref_fields = get_referenced_type_fields(root_schema, &item_type); + (ref_fields, ReturnType::Array) +} + +/// Find the fields present on individual items in the array type of a `list_start`. +fn list_start_fields(root_schema: &RootSchema) -> Vec { + let props = &root_schema.schema.object.as_ref().unwrap().properties; + + let items = props + .get("items") + .expect("list response type does not have items") + .clone() + .into_object() + .array + .unwrap() + .items + .clone() + .unwrap(); + + let item_type = match items { + schemars::schema::SingleOrVec::Single(s) => { + let item_name = s.into_object().reference.clone().unwrap(); + item_name + .strip_prefix("#/definitions/") + .unwrap() + .to_string() + } + schemars::schema::SingleOrVec::Vec(_) => { + unimplemented!("nested array of items") + } + }; + + get_referenced_type_fields(root_schema, &item_type) +} + +/// Get the field names of a type using its name. +fn get_referenced_type_fields(root_schema: &RootSchema, type_name: &str) -> Vec { + let item_schema = root_schema + .definitions + .get(type_name) + .unwrap() + .clone() + .into_object(); + + // Generally child items are a single type, but a few APIs return an enum, e.g. + // `/v1/anti-affinity-groups/{anti_affinity_group}/members`. In this case we collect all of the + // fields present on the variants. + match ( + item_schema.object.as_ref(), + item_schema.subschemas.and_then(|s| s.one_of), + ) { + (Some(obj), _) => obj.properties.keys().cloned().collect(), + (_, Some(variants)) => collect_variant_fields(&variants), + _ => unimplemented!("item was neither an object nor one_of subschema"), + } +} + +/// Gather the fields present on each variant, removing duplicates and retaining their original order. +fn collect_variant_fields(variants: &[Schema]) -> Vec { + let mut fields = IndexSet::new(); + for variant in variants { + fields.extend( + variant + .clone() + .into_object() + .object + .as_ref() + .expect("variant was not an object") + .properties + .keys() + .cloned(), + ); + } + fields.into_iter().collect() +} + +/// Set a table's header to the fields available to be printed. +fn set_header_fields( + requested_fields: &[String], + schema_fields: Vec, + table: &mut Table, +) -> Vec { + let printable_fields = if !requested_fields.is_empty() { + let requested: HashSet<_> = requested_fields.iter().collect(); + let available: HashSet<_> = schema_fields.iter().collect(); + let invalid = requested.difference(&available); + + for field in invalid { + eprintln_nopipe!("WARNING: '{field}' is not a valid field"); + } + + let mut fields = requested_fields.to_vec(); + fields.retain(|f| available.contains(f)); + fields + } else { + schema_fields + }; + + let upcased: Vec<_> = printable_fields.iter().map(|f| f.to_uppercase()).collect(); + + table.set_header(upcased); + printable_fields +} + +/// Format an object's fields for printing in a table. +fn create_row(fields: &[String], obj: &serde_json::Map) -> Vec { + let mut row = Vec::with_capacity(fields.len()); + + for field in fields { + let s = obj.get(field).map(|f| f.to_string()).unwrap_or_default(); + + // `to_string` encloses values in double quotes. + if s.contains(' ') { + row.push(s); + } else { + row.push(s.trim_matches('"').to_string()); + } + } + row +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Return the type name and RootSchema for the given types. + macro_rules! generate_returned_schemas { + ($($ty:ty),* $(,)?) => { + { + vec![ + $( + (stringify!($ty), schemars::schema_for!($ty)), + )* + ] + } + }; + } + + #[test] + fn test_table_schema_parsing() { + // The return types that we don't attempt to print in tabular form. + let expected_empty = HashSet::from([ + // Unstructured binary data. + "oxide::types::InstanceSerialConsoleData", + // No defined return type in the schema, and it contains a number of deeply nested + // fields. + "oxide::types::SwitchLinkState", + ]); + + // We want to confirm that all API return types can be succesfully parsed, but to do this + // we need their schemas, which requires knowing the type names at compile time. The `xtask + // --return-types` task will write the file below, which uses the `generate_returned_schemas!` + // macro above to generate the schemas. + let schemas: Vec<_> = include!("../tests/data/api_return_types.rs"); + for (type_name, schema) in schemas { + let fields = if type_name.ends_with("Page") { + super::list_start_fields(&schema) + } else { + let (fields, _) = super::success_item_fields(&schema); + fields + }; + + if !expected_empty.contains(type_name) { + // Any successfully parsed schema will return at least one field name. + assert!(!fields.is_empty()); + } + } + } +} diff --git a/cli/tests/data/api_return_types.rs b/cli/tests/data/api_return_types.rs new file mode 100644 index 00000000..cdd82c12 --- /dev/null +++ b/cli/tests/data/api_return_types.rs @@ -0,0 +1,136 @@ +// The contents of this file are generated; do not modify them. + +generate_returned_schemas!( + oxide::types::AddressLotBlockResultsPage, + oxide::types::AddressLotCreateResponse, + oxide::types::AddressLotResultsPage, + oxide::types::AffinityGroup, + oxide::types::AffinityGroupMember, + oxide::types::AffinityGroupMemberResultsPage, + oxide::types::AffinityGroupResultsPage, + oxide::types::AggregateBgpMessageHistory, + oxide::types::AlertClassResultsPage, + oxide::types::AlertDeliveryId, + oxide::types::AlertDeliveryResultsPage, + oxide::types::AlertProbeResult, + oxide::types::AlertReceiver, + oxide::types::AlertReceiverResultsPage, + oxide::types::AlertSubscriptionCreated, + oxide::types::AllowList, + oxide::types::AntiAffinityGroup, + oxide::types::AntiAffinityGroupMember, + oxide::types::AntiAffinityGroupMemberResultsPage, + oxide::types::AntiAffinityGroupResultsPage, + oxide::types::BfdStatus, + oxide::types::BgpAnnounceSet, + oxide::types::BgpAnnouncement, + oxide::types::BgpConfig, + oxide::types::BgpConfigResultsPage, + oxide::types::BgpExported, + oxide::types::BgpImportedRouteIpv4, + oxide::types::BgpPeerStatus, + oxide::types::Certificate, + oxide::types::CertificateResultsPage, + oxide::types::CurrentUser, + oxide::types::DeviceAccessTokenResultsPage, + oxide::types::Disk, + oxide::types::DiskResultsPage, + oxide::types::ExternalIp, + oxide::types::ExternalIpResultsPage, + oxide::types::FleetRolePolicy, + oxide::types::FloatingIp, + oxide::types::FloatingIpResultsPage, + oxide::types::Group, + oxide::types::GroupResultsPage, + oxide::types::IdentityProviderResultsPage, + oxide::types::Image, + oxide::types::ImageResultsPage, + oxide::types::Instance, + oxide::types::InstanceNetworkInterface, + oxide::types::InstanceNetworkInterfaceResultsPage, + oxide::types::InstanceResultsPage, + oxide::types::InstanceSerialConsoleData, + oxide::types::InternetGateway, + oxide::types::InternetGatewayIpAddress, + oxide::types::InternetGatewayIpAddressResultsPage, + oxide::types::InternetGatewayIpPool, + oxide::types::InternetGatewayIpPoolResultsPage, + oxide::types::InternetGatewayResultsPage, + oxide::types::IpPool, + oxide::types::IpPoolRange, + oxide::types::IpPoolRangeResultsPage, + oxide::types::IpPoolResultsPage, + oxide::types::IpPoolSiloLink, + oxide::types::IpPoolSiloLinkResultsPage, + oxide::types::IpPoolUtilization, + oxide::types::LldpLinkConfig, + oxide::types::LldpNeighborResultsPage, + oxide::types::LoopbackAddress, + oxide::types::LoopbackAddressResultsPage, + oxide::types::MeasurementResultsPage, + oxide::types::OxqlQueryResult, + oxide::types::PhysicalDisk, + oxide::types::PhysicalDiskResultsPage, + oxide::types::Ping, + oxide::types::Probe, + oxide::types::ProbeInfo, + oxide::types::ProbeInfoResultsPage, + oxide::types::Project, + oxide::types::ProjectResultsPage, + oxide::types::ProjectRolePolicy, + oxide::types::Rack, + oxide::types::RackResultsPage, + oxide::types::RouterRoute, + oxide::types::RouterRouteResultsPage, + oxide::types::SamlIdentityProvider, + oxide::types::ServiceIcmpConfig, + oxide::types::Silo, + oxide::types::SiloAuthSettings, + oxide::types::SiloIpPool, + oxide::types::SiloIpPoolResultsPage, + oxide::types::SiloQuotas, + oxide::types::SiloQuotasResultsPage, + oxide::types::SiloResultsPage, + oxide::types::SiloRolePolicy, + oxide::types::SiloUtilization, + oxide::types::SiloUtilizationResultsPage, + oxide::types::Sled, + oxide::types::SledId, + oxide::types::SledInstanceResultsPage, + oxide::types::SledProvisionPolicyResponse, + oxide::types::SledResultsPage, + oxide::types::Snapshot, + oxide::types::SnapshotResultsPage, + oxide::types::SshKey, + oxide::types::SshKeyResultsPage, + oxide::types::SupportBundleInfo, + oxide::types::SupportBundleInfoResultsPage, + oxide::types::Switch, + oxide::types::SwitchLinkState, + oxide::types::SwitchPortResultsPage, + oxide::types::SwitchPortSettings, + oxide::types::SwitchPortSettingsIdentityResultsPage, + oxide::types::SwitchResultsPage, + oxide::types::TargetRelease, + oxide::types::TimeseriesSchemaResultsPage, + oxide::types::TufRepoGetResponse, + oxide::types::TufRepoInsertResponse, + oxide::types::UninitializedSledResultsPage, + oxide::types::UpdatesTrustRoot, + oxide::types::UpdatesTrustRootResultsPage, + oxide::types::User, + oxide::types::UserBuiltin, + oxide::types::UserBuiltinResultsPage, + oxide::types::UserResultsPage, + oxide::types::Utilization, + oxide::types::Vpc, + oxide::types::VpcFirewallRules, + oxide::types::VpcResultsPage, + oxide::types::VpcRouter, + oxide::types::VpcRouterResultsPage, + oxide::types::VpcSubnet, + oxide::types::VpcSubnetResultsPage, + oxide::types::WebhookReceiver, + oxide::types::WebhookSecret, + oxide::types::WebhookSecrets, +) diff --git a/cli/tests/data/test_table_project_list_basic_table.stdout b/cli/tests/data/test_table_project_list_basic_table.stdout new file mode 100644 index 00000000..cb7c5d2b --- /dev/null +++ b/cli/tests/data/test_table_project_list_basic_table.stdout @@ -0,0 +1,5 @@ + DESCRIPTION ID NAME TIME_CREATED TIME_MODIFIED + qats fb603800-ff07-da32-9800-70bbd45865ea qiviuts 1983-04-06T03:52:51Z 2015-10-10T15:39:40Z + qintars e272d3bf-c637-7d5d-3a7d-ab4481968f52 suqs 1958-10-29T11:12:56Z 1993-07-07T01:08:41Z + qindarkas 254a50c5-7c80-8369-2d37-f41d8ab55a50 suqs 2023-12-07T23:07:16Z 1997-01-29T14:17:53Z + suq ac274e97-15bf-8098-8119-595a2344c36d buqsha 1976-06-06T06:59:52Z 1973-08-11T14:45:56Z diff --git a/cli/tests/data/test_table_project_list_json.stdout b/cli/tests/data/test_table_project_list_json.stdout new file mode 100644 index 00000000..10194583 --- /dev/null +++ b/cli/tests/data/test_table_project_list_json.stdout @@ -0,0 +1,27 @@ +[ + { + "description": "qats", + "id": "fb603800-ff07-da32-9800-70bbd45865ea", + "name": "qiviuts", + "time_created": "1983-04-06T03:52:51Z", + "time_modified": "2015-10-10T15:39:40Z" + }, { + "description": "qintars", + "id": "e272d3bf-c637-7d5d-3a7d-ab4481968f52", + "name": "suqs", + "time_created": "1958-10-29T11:12:56Z", + "time_modified": "1993-07-07T01:08:41Z" + }, { + "description": "qindarkas", + "id": "254a50c5-7c80-8369-2d37-f41d8ab55a50", + "name": "suqs", + "time_created": "2023-12-07T23:07:16Z", + "time_modified": "1997-01-29T14:17:53Z" + }, { + "description": "suq", + "id": "ac274e97-15bf-8098-8119-595a2344c36d", + "name": "buqsha", + "time_created": "1976-06-06T06:59:52Z", + "time_modified": "1973-08-11T14:45:56Z" + } +] diff --git a/cli/tests/data/test_table_project_list_table_with_fields.stdout b/cli/tests/data/test_table_project_list_table_with_fields.stdout new file mode 100644 index 00000000..da19a018 --- /dev/null +++ b/cli/tests/data/test_table_project_list_table_with_fields.stdout @@ -0,0 +1,5 @@ + NAME ID + qiviuts fb603800-ff07-da32-9800-70bbd45865ea + suqs e272d3bf-c637-7d5d-3a7d-ab4481968f52 + suqs 254a50c5-7c80-8369-2d37-f41d8ab55a50 + buqsha ac274e97-15bf-8098-8119-595a2344c36d diff --git a/cli/tests/test_table.rs b/cli/tests/test_table.rs new file mode 100644 index 00000000..89b41e47 --- /dev/null +++ b/cli/tests/test_table.rs @@ -0,0 +1,125 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Copyright 2025 Oxide Computer Company + +use assert_cmd::Command; +use httpmock::MockServer; +use oxide::types::{Project, ProjectResultsPage}; +use oxide_httpmock::MockServerExt; +use rand::SeedableRng; +use test_common::JsonMock; + +#[test] +fn test_table_arg_parse() { + let mut src = rand::rngs::SmallRng::seed_from_u64(42); + let server = MockServer::start(); + + let results = ProjectResultsPage { + items: Vec::::mock_value(&mut src).unwrap(), + next_page: None, + }; + + let mock = server.project_list(|when, then| { + when.into_inner().any_request(); + then.ok(&results); + }); + + let format_args = [ + // JSON + ["--format", "json"], + // Simple table + ["--format", "table"], + // Table with column names specified + ["--format", "table:name,id"], + // Table with spaces between column names + ["--format", "table: name, id "], + ]; + + for args in format_args { + Command::cargo_bin("oxide") + .unwrap() + .env("RUST_BACKTRACE", "1") + .env("OXIDE_HOST", server.url("")) + .env("OXIDE_TOKEN", "fake-token") + .arg("project") + .arg("list") + .arg("--sort-by") + .arg("name_ascending") + .args(args) + .assert() + .success(); + } + + mock.assert_hits(format_args.len()); +} + +#[test] +fn test_table_project_list() { + let mut src = rand::rngs::SmallRng::seed_from_u64(42); + let server = MockServer::start(); + + let results = ProjectResultsPage { + items: Vec::::mock_value(&mut src).unwrap(), + next_page: None, + }; + + let mock = server.project_list(|when, then| { + when.into_inner().any_request(); + then.ok(&results); + }); + + Command::cargo_bin("oxide") + .unwrap() + .env("RUST_BACKTRACE", "1") + .env("OXIDE_HOST", server.url("")) + .env("OXIDE_TOKEN", "fake-token") + .arg("project") + .arg("list") + .arg("--sort-by") + .arg("name_ascending") + .arg("--format") + .arg("json") + .assert() + .success() + .stdout(expectorate::eq_file_or_panic( + "tests/data/test_table_project_list_json.stdout", + )); + + Command::cargo_bin("oxide") + .unwrap() + .env("RUST_BACKTRACE", "1") + .env("OXIDE_HOST", server.url("")) + .env("OXIDE_TOKEN", "fake-token") + .arg("project") + .arg("list") + .arg("--sort-by") + .arg("name_ascending") + .arg("--format") + .arg("table") + .assert() + .success() + .stdout(expectorate::eq_file_or_panic( + "tests/data/test_table_project_list_basic_table.stdout", + )); + + Command::cargo_bin("oxide") + .unwrap() + .env("RUST_BACKTRACE", "1") + .env("OXIDE_HOST", server.url("")) + .env("OXIDE_TOKEN", "fake-token") + .arg("project") + .arg("list") + .arg("--sort-by") + .arg("name_ascending") + .arg("--format") + .arg("table:name,id") + .assert() + .success() + .stdout(expectorate::eq_file_or_panic( + "tests/data/test_table_project_list_table_with_fields.stdout", + )); + + mock.assert_hits(3); +} diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index 81b4e64f..fc91f6cc 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -7,6 +7,7 @@ publish = false [dependencies] clap = { workspace = true } newline-converter = { workspace = true } +openapiv3 = { workspace = true } progenitor = { workspace = true, default-features = false } regex = { workspace = true } rustc_version = { workspace = true } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 882af172..3d54f9fc 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -2,14 +2,15 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. -// Copyright 2023 Oxide Computer Company +// Copyright 2025 Oxide Computer Company #![forbid(unsafe_code)] -use std::{fs::File, io::Write, path::PathBuf, time::Instant}; +use std::{collections::BTreeSet, fs::File, io::Write, path::PathBuf, time::Instant}; use clap::Parser; use newline_converter::dos2unix; +use openapiv3::{ReferenceOr, SchemaKind, StatusCode, Type}; use progenitor::{GenerationSettings, Generator, TagStyle}; use similar::{Algorithm, ChangeTag, TextDiff}; @@ -30,6 +31,8 @@ enum Xtask { cli: bool, #[clap(long)] httpmock: bool, + #[clap(long)] + return_types: bool, }, } @@ -43,7 +46,8 @@ fn main() -> Result<(), String> { sdk, cli, httpmock, - } => generate(check, verbose, sdk, cli, httpmock), + return_types, + } => generate(check, verbose, sdk, cli, httpmock, return_types), } } @@ -55,9 +59,10 @@ fn generate( mut sdk: bool, mut cli: bool, mut httpmock: bool, + mut return_types: bool, ) -> Result<(), String> { - if !(sdk || cli || httpmock) { - (sdk, cli, httpmock) = (true, true, true); + if !(sdk || cli || httpmock || return_types) { + (sdk, cli, httpmock, return_types) = (true, true, true, true); } let start = Instant::now(); @@ -122,7 +127,7 @@ fn generate( let contents = format_code(code); loc += contents.matches('\n').count(); - let mut out_path = root_path; + let mut out_path = root_path.clone(); out_path.push("cli"); out_path.push("src"); out_path.push("generated_cli.rs"); @@ -130,6 +135,89 @@ fn generate( error |= output_contents(check, out_path, contents, verbose).is_err(); } + if return_types { + print!("generating return types ... "); + std::io::stdout().flush().unwrap(); + let mut ret_types = BTreeSet::new(); + + for path in spec.paths.paths.values().cloned() { + let Some(path) = path.into_item() else { + unimplemented!("path was a reference"); + }; + + for operation in [ + &path.get, + &path.put, + &path.options, + &path.post, + &path.delete, + &path.head, + &path.patch, + &path.trace, + ] { + let Some(operation) = operation else { + continue; + }; + + for response in operation + .responses + .responses + .iter() + .filter(|(k, _v)| matches!(k, StatusCode::Code(_))) + .map(|(_k, v)| v) + .cloned() + { + let Some(response) = response.into_item() else { + unimplemented!("response was not an item"); + }; + + // Skip items that don't have a JSON response. + let Some(schema) = response + .content + .get("application/json") + .and_then(|j| j.schema.as_ref()) + else { + continue; + }; + let reference = match schema { + ReferenceOr::Reference { reference } => reference, + ReferenceOr::Item(item) => match &item.schema_kind { + SchemaKind::Type(Type::Array(a)) => { + let Some(ReferenceOr::Reference { reference }) = &a.items else { + unimplemented!("returned array type was not a reference"); + }; + reference + } + _ => unimplemented!("direct return type was not an array"), + }, + }; + let Some(type_name) = reference.strip_prefix("#/components/schemas/") else { + unimplemented!("reference was not to a schema"); + }; + + ret_types.insert(type_name.to_owned()); + } + } + } + + let mut contents = + "// The contents of this file are generated; do not modify them.\n\n".to_string(); + contents.push_str("generate_returned_schemas!(\n"); + for ty in ret_types { + contents.push_str(&format!(" oxide::types::{ty},\n")); + } + contents.push_str(")\n"); + loc += contents.matches('\n').count(); + + let mut out_path = root_path; + out_path.push("cli"); + out_path.push("tests"); + out_path.push("data"); + out_path.push("api_return_types.rs"); + + error |= output_contents(check, out_path, contents, verbose).is_err(); + } + let duration = Instant::now().duration_since(start).as_micros(); println!( "generation took {:.3}s ({}us per line)", From d1817ffc297982bc25f7ee1fe9a8bada486b7fe1 Mon Sep 17 00:00:00 2001 From: Will Chandler Date: Wed, 13 Aug 2025 13:04:14 -0400 Subject: [PATCH 2/6] Get return types with syn --- Cargo.lock | 4 +- Cargo.toml | 4 +- cli/src/oxide_override.rs | 23 +- cli/tests/data/api_return_types.rs | 666 +++++++++++++++++++++++------ xtask/Cargo.toml | 4 +- xtask/src/main.rs | 249 +++++++---- 6 files changed, 703 insertions(+), 247 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8d803230..b255f6d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4938,13 +4938,15 @@ version = "0.0.0" dependencies = [ "clap", "newline-converter", - "openapiv3", + "proc-macro2", "progenitor", + "quote", "regex", "rustc_version", "rustfmt-wrapper", "serde_json", "similar", + "syn 2.0.104", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b3d67287..58b44467 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,14 +41,15 @@ md5 = "0.7.0" newline-converter = "0.3.0" oauth2 = "5.0.0" open = "5.3.2" -openapiv3 = "2.2.0" oxide = { path = "sdk", version = "0.12.0" } oxide-httpmock = { path = "sdk-httpmock", version = "0.12.0" } oxnet = "0.1.2" predicates = "3.1.3" pretty_assertions = "1.4.1" +proc-macro2 = "1.0.95" progenitor = { git = "https://github.com/oxidecomputer/progenitor", default-features = false } progenitor-client = "0.11.0" +quote = "1.0.40" rand = "0.9.1" ratatui = "0.29.0" rcgen = { version = "0.13.2", features = ["pem"] } @@ -62,6 +63,7 @@ serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.139" similar = "2.7.0" support-bundle-viewer = "0.1.2" +syn = "2.0.104" tabwriter = "1.4.1" thiserror = "2.0.11" tempfile = "3.20.0" diff --git a/cli/src/oxide_override.rs b/cli/src/oxide_override.rs index b63b7934..7d674866 100644 --- a/cli/src/oxide_override.rs +++ b/cli/src/oxide_override.rs @@ -549,19 +549,6 @@ fn create_row(fields: &[String], obj: &serde_json::Map { - { - vec![ - $( - (stringify!($ty), schemars::schema_for!($ty)), - )* - ] - } - }; - } - #[test] fn test_table_schema_parsing() { // The return types that we don't attempt to print in tabular form. @@ -575,10 +562,12 @@ mod tests { // We want to confirm that all API return types can be succesfully parsed, but to do this // we need their schemas, which requires knowing the type names at compile time. The `xtask - // --return-types` task will write the file below, which uses the `generate_returned_schemas!` - // macro above to generate the schemas. - let schemas: Vec<_> = include!("../tests/data/api_return_types.rs"); - for (type_name, schema) in schemas { + // --sdk` task will write the file below, which contains the type names and their + // associated schema. + mod schemas { + include!("../tests/data/api_return_types.rs"); + } + for (type_name, schema) in schemas::schemas() { let fields = if type_name.ends_with("Page") { super::list_start_fields(&schema) } else { diff --git a/cli/tests/data/api_return_types.rs b/cli/tests/data/api_return_types.rs index cdd82c12..efb71c5a 100644 --- a/cli/tests/data/api_return_types.rs +++ b/cli/tests/data/api_return_types.rs @@ -1,136 +1,534 @@ // The contents of this file are generated; do not modify them. -generate_returned_schemas!( - oxide::types::AddressLotBlockResultsPage, - oxide::types::AddressLotCreateResponse, - oxide::types::AddressLotResultsPage, - oxide::types::AffinityGroup, - oxide::types::AffinityGroupMember, - oxide::types::AffinityGroupMemberResultsPage, - oxide::types::AffinityGroupResultsPage, - oxide::types::AggregateBgpMessageHistory, - oxide::types::AlertClassResultsPage, - oxide::types::AlertDeliveryId, - oxide::types::AlertDeliveryResultsPage, - oxide::types::AlertProbeResult, - oxide::types::AlertReceiver, - oxide::types::AlertReceiverResultsPage, - oxide::types::AlertSubscriptionCreated, - oxide::types::AllowList, - oxide::types::AntiAffinityGroup, - oxide::types::AntiAffinityGroupMember, - oxide::types::AntiAffinityGroupMemberResultsPage, - oxide::types::AntiAffinityGroupResultsPage, - oxide::types::BfdStatus, - oxide::types::BgpAnnounceSet, - oxide::types::BgpAnnouncement, - oxide::types::BgpConfig, - oxide::types::BgpConfigResultsPage, - oxide::types::BgpExported, - oxide::types::BgpImportedRouteIpv4, - oxide::types::BgpPeerStatus, - oxide::types::Certificate, - oxide::types::CertificateResultsPage, - oxide::types::CurrentUser, - oxide::types::DeviceAccessTokenResultsPage, - oxide::types::Disk, - oxide::types::DiskResultsPage, - oxide::types::ExternalIp, - oxide::types::ExternalIpResultsPage, - oxide::types::FleetRolePolicy, - oxide::types::FloatingIp, - oxide::types::FloatingIpResultsPage, - oxide::types::Group, - oxide::types::GroupResultsPage, - oxide::types::IdentityProviderResultsPage, - oxide::types::Image, - oxide::types::ImageResultsPage, - oxide::types::Instance, - oxide::types::InstanceNetworkInterface, - oxide::types::InstanceNetworkInterfaceResultsPage, - oxide::types::InstanceResultsPage, - oxide::types::InstanceSerialConsoleData, - oxide::types::InternetGateway, - oxide::types::InternetGatewayIpAddress, - oxide::types::InternetGatewayIpAddressResultsPage, - oxide::types::InternetGatewayIpPool, - oxide::types::InternetGatewayIpPoolResultsPage, - oxide::types::InternetGatewayResultsPage, - oxide::types::IpPool, - oxide::types::IpPoolRange, - oxide::types::IpPoolRangeResultsPage, - oxide::types::IpPoolResultsPage, - oxide::types::IpPoolSiloLink, - oxide::types::IpPoolSiloLinkResultsPage, - oxide::types::IpPoolUtilization, - oxide::types::LldpLinkConfig, - oxide::types::LldpNeighborResultsPage, - oxide::types::LoopbackAddress, - oxide::types::LoopbackAddressResultsPage, - oxide::types::MeasurementResultsPage, - oxide::types::OxqlQueryResult, - oxide::types::PhysicalDisk, - oxide::types::PhysicalDiskResultsPage, - oxide::types::Ping, - oxide::types::Probe, - oxide::types::ProbeInfo, - oxide::types::ProbeInfoResultsPage, - oxide::types::Project, - oxide::types::ProjectResultsPage, - oxide::types::ProjectRolePolicy, - oxide::types::Rack, - oxide::types::RackResultsPage, - oxide::types::RouterRoute, - oxide::types::RouterRouteResultsPage, - oxide::types::SamlIdentityProvider, - oxide::types::ServiceIcmpConfig, - oxide::types::Silo, - oxide::types::SiloAuthSettings, - oxide::types::SiloIpPool, - oxide::types::SiloIpPoolResultsPage, - oxide::types::SiloQuotas, - oxide::types::SiloQuotasResultsPage, - oxide::types::SiloResultsPage, - oxide::types::SiloRolePolicy, - oxide::types::SiloUtilization, - oxide::types::SiloUtilizationResultsPage, - oxide::types::Sled, - oxide::types::SledId, - oxide::types::SledInstanceResultsPage, - oxide::types::SledProvisionPolicyResponse, - oxide::types::SledResultsPage, - oxide::types::Snapshot, - oxide::types::SnapshotResultsPage, - oxide::types::SshKey, - oxide::types::SshKeyResultsPage, - oxide::types::SupportBundleInfo, - oxide::types::SupportBundleInfoResultsPage, - oxide::types::Switch, - oxide::types::SwitchLinkState, - oxide::types::SwitchPortResultsPage, - oxide::types::SwitchPortSettings, - oxide::types::SwitchPortSettingsIdentityResultsPage, - oxide::types::SwitchResultsPage, - oxide::types::TargetRelease, - oxide::types::TimeseriesSchemaResultsPage, - oxide::types::TufRepoGetResponse, - oxide::types::TufRepoInsertResponse, - oxide::types::UninitializedSledResultsPage, - oxide::types::UpdatesTrustRoot, - oxide::types::UpdatesTrustRootResultsPage, - oxide::types::User, - oxide::types::UserBuiltin, - oxide::types::UserBuiltinResultsPage, - oxide::types::UserResultsPage, - oxide::types::Utilization, - oxide::types::Vpc, - oxide::types::VpcFirewallRules, - oxide::types::VpcResultsPage, - oxide::types::VpcRouter, - oxide::types::VpcRouterResultsPage, - oxide::types::VpcSubnet, - oxide::types::VpcSubnetResultsPage, - oxide::types::WebhookReceiver, - oxide::types::WebhookSecret, - oxide::types::WebhookSecrets, -) +pub fn schemas() -> Vec<(&'static str, schemars::schema::RootSchema)> { + vec![ + ( + stringify!(oxide::types::ProbeInfoResultsPage), + schemars::schema_for!(oxide::types::ProbeInfoResultsPage), + ), + ( + stringify!(oxide::types::Probe), + schemars::schema_for!(oxide::types::Probe), + ), + ( + stringify!(oxide::types::ProbeInfo), + schemars::schema_for!(oxide::types::ProbeInfo), + ), + ( + stringify!(oxide::types::SupportBundleInfoResultsPage), + schemars::schema_for!(oxide::types::SupportBundleInfoResultsPage), + ), + ( + stringify!(oxide::types::SupportBundleInfo), + schemars::schema_for!(oxide::types::SupportBundleInfo), + ), + ( + stringify!(oxide::types::AffinityGroupResultsPage), + schemars::schema_for!(oxide::types::AffinityGroupResultsPage), + ), + ( + stringify!(oxide::types::AffinityGroup), + schemars::schema_for!(oxide::types::AffinityGroup), + ), + ( + stringify!(oxide::types::AffinityGroupMemberResultsPage), + schemars::schema_for!(oxide::types::AffinityGroupMemberResultsPage), + ), + ( + stringify!(oxide::types::AffinityGroupMember), + schemars::schema_for!(oxide::types::AffinityGroupMember), + ), + ( + stringify!(oxide::types::AlertClassResultsPage), + schemars::schema_for!(oxide::types::AlertClassResultsPage), + ), + ( + stringify!(oxide::types::AlertReceiverResultsPage), + schemars::schema_for!(oxide::types::AlertReceiverResultsPage), + ), + ( + stringify!(oxide::types::AlertReceiver), + schemars::schema_for!(oxide::types::AlertReceiver), + ), + ( + stringify!(oxide::types::AlertDeliveryResultsPage), + schemars::schema_for!(oxide::types::AlertDeliveryResultsPage), + ), + ( + stringify!(oxide::types::AlertProbeResult), + schemars::schema_for!(oxide::types::AlertProbeResult), + ), + ( + stringify!(oxide::types::AlertSubscriptionCreated), + schemars::schema_for!(oxide::types::AlertSubscriptionCreated), + ), + ( + stringify!(oxide::types::AlertDeliveryId), + schemars::schema_for!(oxide::types::AlertDeliveryId), + ), + ( + stringify!(oxide::types::AntiAffinityGroupResultsPage), + schemars::schema_for!(oxide::types::AntiAffinityGroupResultsPage), + ), + ( + stringify!(oxide::types::AntiAffinityGroup), + schemars::schema_for!(oxide::types::AntiAffinityGroup), + ), + ( + stringify!(oxide::types::AntiAffinityGroupMemberResultsPage), + schemars::schema_for!(oxide::types::AntiAffinityGroupMemberResultsPage), + ), + ( + stringify!(oxide::types::AntiAffinityGroupMember), + schemars::schema_for!(oxide::types::AntiAffinityGroupMember), + ), + ( + stringify!(oxide::types::SiloAuthSettings), + schemars::schema_for!(oxide::types::SiloAuthSettings), + ), + ( + stringify!(oxide::types::CertificateResultsPage), + schemars::schema_for!(oxide::types::CertificateResultsPage), + ), + ( + stringify!(oxide::types::Certificate), + schemars::schema_for!(oxide::types::Certificate), + ), + ( + stringify!(oxide::types::DiskResultsPage), + schemars::schema_for!(oxide::types::DiskResultsPage), + ), + ( + stringify!(oxide::types::Disk), + schemars::schema_for!(oxide::types::Disk), + ), + ( + stringify!(oxide::types::MeasurementResultsPage), + schemars::schema_for!(oxide::types::MeasurementResultsPage), + ), + ( + stringify!(oxide::types::FloatingIpResultsPage), + schemars::schema_for!(oxide::types::FloatingIpResultsPage), + ), + ( + stringify!(oxide::types::FloatingIp), + schemars::schema_for!(oxide::types::FloatingIp), + ), + ( + stringify!(oxide::types::GroupResultsPage), + schemars::schema_for!(oxide::types::GroupResultsPage), + ), + ( + stringify!(oxide::types::Group), + schemars::schema_for!(oxide::types::Group), + ), + ( + stringify!(oxide::types::ImageResultsPage), + schemars::schema_for!(oxide::types::ImageResultsPage), + ), + ( + stringify!(oxide::types::Image), + schemars::schema_for!(oxide::types::Image), + ), + ( + stringify!(oxide::types::InstanceResultsPage), + schemars::schema_for!(oxide::types::InstanceResultsPage), + ), + ( + stringify!(oxide::types::Instance), + schemars::schema_for!(oxide::types::Instance), + ), + ( + stringify!(oxide::types::ExternalIpResultsPage), + schemars::schema_for!(oxide::types::ExternalIpResultsPage), + ), + ( + stringify!(oxide::types::ExternalIp), + schemars::schema_for!(oxide::types::ExternalIp), + ), + ( + stringify!(oxide::types::InstanceSerialConsoleData), + schemars::schema_for!(oxide::types::InstanceSerialConsoleData), + ), + ( + stringify!(oxide::types::SshKeyResultsPage), + schemars::schema_for!(oxide::types::SshKeyResultsPage), + ), + ( + stringify!(oxide::types::InternetGatewayIpAddressResultsPage), + schemars::schema_for!(oxide::types::InternetGatewayIpAddressResultsPage), + ), + ( + stringify!(oxide::types::InternetGatewayIpAddress), + schemars::schema_for!(oxide::types::InternetGatewayIpAddress), + ), + ( + stringify!(oxide::types::InternetGatewayIpPoolResultsPage), + schemars::schema_for!(oxide::types::InternetGatewayIpPoolResultsPage), + ), + ( + stringify!(oxide::types::InternetGatewayIpPool), + schemars::schema_for!(oxide::types::InternetGatewayIpPool), + ), + ( + stringify!(oxide::types::InternetGatewayResultsPage), + schemars::schema_for!(oxide::types::InternetGatewayResultsPage), + ), + ( + stringify!(oxide::types::InternetGateway), + schemars::schema_for!(oxide::types::InternetGateway), + ), + ( + stringify!(oxide::types::SiloIpPoolResultsPage), + schemars::schema_for!(oxide::types::SiloIpPoolResultsPage), + ), + ( + stringify!(oxide::types::SiloIpPool), + schemars::schema_for!(oxide::types::SiloIpPool), + ), + ( + stringify!(oxide::types::CurrentUser), + schemars::schema_for!(oxide::types::CurrentUser), + ), + ( + stringify!(oxide::types::DeviceAccessTokenResultsPage), + schemars::schema_for!(oxide::types::DeviceAccessTokenResultsPage), + ), + ( + stringify!(oxide::types::SshKey), + schemars::schema_for!(oxide::types::SshKey), + ), + ( + stringify!(oxide::types::InstanceNetworkInterfaceResultsPage), + schemars::schema_for!(oxide::types::InstanceNetworkInterfaceResultsPage), + ), + ( + stringify!(oxide::types::InstanceNetworkInterface), + schemars::schema_for!(oxide::types::InstanceNetworkInterface), + ), + ( + stringify!(oxide::types::Ping), + schemars::schema_for!(oxide::types::Ping), + ), + ( + stringify!(oxide::types::SiloRolePolicy), + schemars::schema_for!(oxide::types::SiloRolePolicy), + ), + ( + stringify!(oxide::types::ProjectResultsPage), + schemars::schema_for!(oxide::types::ProjectResultsPage), + ), + ( + stringify!(oxide::types::Project), + schemars::schema_for!(oxide::types::Project), + ), + ( + stringify!(oxide::types::ProjectRolePolicy), + schemars::schema_for!(oxide::types::ProjectRolePolicy), + ), + ( + stringify!(oxide::types::SnapshotResultsPage), + schemars::schema_for!(oxide::types::SnapshotResultsPage), + ), + ( + stringify!(oxide::types::Snapshot), + schemars::schema_for!(oxide::types::Snapshot), + ), + ( + stringify!(oxide::types::PhysicalDiskResultsPage), + schemars::schema_for!(oxide::types::PhysicalDiskResultsPage), + ), + ( + stringify!(oxide::types::PhysicalDisk), + schemars::schema_for!(oxide::types::PhysicalDisk), + ), + ( + stringify!(oxide::types::LldpNeighborResultsPage), + schemars::schema_for!(oxide::types::LldpNeighborResultsPage), + ), + ( + stringify!(oxide::types::RackResultsPage), + schemars::schema_for!(oxide::types::RackResultsPage), + ), + ( + stringify!(oxide::types::Rack), + schemars::schema_for!(oxide::types::Rack), + ), + ( + stringify!(oxide::types::SledResultsPage), + schemars::schema_for!(oxide::types::SledResultsPage), + ), + ( + stringify!(oxide::types::SledId), + schemars::schema_for!(oxide::types::SledId), + ), + ( + stringify!(oxide::types::Sled), + schemars::schema_for!(oxide::types::Sled), + ), + ( + stringify!(oxide::types::SledInstanceResultsPage), + schemars::schema_for!(oxide::types::SledInstanceResultsPage), + ), + ( + stringify!(oxide::types::SledProvisionPolicyResponse), + schemars::schema_for!(oxide::types::SledProvisionPolicyResponse), + ), + ( + stringify!(oxide::types::UninitializedSledResultsPage), + schemars::schema_for!(oxide::types::UninitializedSledResultsPage), + ), + ( + stringify!(oxide::types::SwitchPortResultsPage), + schemars::schema_for!(oxide::types::SwitchPortResultsPage), + ), + ( + stringify!(oxide::types::LldpLinkConfig), + schemars::schema_for!(oxide::types::LldpLinkConfig), + ), + ( + stringify!(oxide::types::SwitchLinkState), + schemars::schema_for!(oxide::types::SwitchLinkState), + ), + ( + stringify!(oxide::types::SwitchResultsPage), + schemars::schema_for!(oxide::types::SwitchResultsPage), + ), + ( + stringify!(oxide::types::Switch), + schemars::schema_for!(oxide::types::Switch), + ), + ( + stringify!(oxide::types::IdentityProviderResultsPage), + schemars::schema_for!(oxide::types::IdentityProviderResultsPage), + ), + ( + stringify!(oxide::types::User), + schemars::schema_for!(oxide::types::User), + ), + ( + stringify!(oxide::types::SamlIdentityProvider), + schemars::schema_for!(oxide::types::SamlIdentityProvider), + ), + ( + stringify!(oxide::types::IpPoolResultsPage), + schemars::schema_for!(oxide::types::IpPoolResultsPage), + ), + ( + stringify!(oxide::types::IpPool), + schemars::schema_for!(oxide::types::IpPool), + ), + ( + stringify!(oxide::types::IpPoolRangeResultsPage), + schemars::schema_for!(oxide::types::IpPoolRangeResultsPage), + ), + ( + stringify!(oxide::types::IpPoolRange), + schemars::schema_for!(oxide::types::IpPoolRange), + ), + ( + stringify!(oxide::types::IpPoolSiloLinkResultsPage), + schemars::schema_for!(oxide::types::IpPoolSiloLinkResultsPage), + ), + ( + stringify!(oxide::types::IpPoolSiloLink), + schemars::schema_for!(oxide::types::IpPoolSiloLink), + ), + ( + stringify!(oxide::types::IpPoolUtilization), + schemars::schema_for!(oxide::types::IpPoolUtilization), + ), + ( + stringify!(oxide::types::AddressLotResultsPage), + schemars::schema_for!(oxide::types::AddressLotResultsPage), + ), + ( + stringify!(oxide::types::AddressLotCreateResponse), + schemars::schema_for!(oxide::types::AddressLotCreateResponse), + ), + ( + stringify!(oxide::types::AddressLotBlockResultsPage), + schemars::schema_for!(oxide::types::AddressLotBlockResultsPage), + ), + ( + stringify!(oxide::types::AllowList), + schemars::schema_for!(oxide::types::AllowList), + ), + ( + stringify!(oxide::types::BfdStatus), + schemars::schema_for!(oxide::types::BfdStatus), + ), + ( + stringify!(oxide::types::BgpConfigResultsPage), + schemars::schema_for!(oxide::types::BgpConfigResultsPage), + ), + ( + stringify!(oxide::types::BgpConfig), + schemars::schema_for!(oxide::types::BgpConfig), + ), + ( + stringify!(oxide::types::BgpAnnounceSet), + schemars::schema_for!(oxide::types::BgpAnnounceSet), + ), + ( + stringify!(oxide::types::BgpAnnouncement), + schemars::schema_for!(oxide::types::BgpAnnouncement), + ), + ( + stringify!(oxide::types::BgpExported), + schemars::schema_for!(oxide::types::BgpExported), + ), + ( + stringify!(oxide::types::AggregateBgpMessageHistory), + schemars::schema_for!(oxide::types::AggregateBgpMessageHistory), + ), + ( + stringify!(oxide::types::BgpImportedRouteIpv4), + schemars::schema_for!(oxide::types::BgpImportedRouteIpv4), + ), + ( + stringify!(oxide::types::BgpPeerStatus), + schemars::schema_for!(oxide::types::BgpPeerStatus), + ), + ( + stringify!(oxide::types::ServiceIcmpConfig), + schemars::schema_for!(oxide::types::ServiceIcmpConfig), + ), + ( + stringify!(oxide::types::LoopbackAddressResultsPage), + schemars::schema_for!(oxide::types::LoopbackAddressResultsPage), + ), + ( + stringify!(oxide::types::LoopbackAddress), + schemars::schema_for!(oxide::types::LoopbackAddress), + ), + ( + stringify!(oxide::types::SwitchPortSettingsIdentityResultsPage), + schemars::schema_for!(oxide::types::SwitchPortSettingsIdentityResultsPage), + ), + ( + stringify!(oxide::types::SwitchPortSettings), + schemars::schema_for!(oxide::types::SwitchPortSettings), + ), + ( + stringify!(oxide::types::FleetRolePolicy), + schemars::schema_for!(oxide::types::FleetRolePolicy), + ), + ( + stringify!(oxide::types::SiloQuotasResultsPage), + schemars::schema_for!(oxide::types::SiloQuotasResultsPage), + ), + ( + stringify!(oxide::types::SiloResultsPage), + schemars::schema_for!(oxide::types::SiloResultsPage), + ), + ( + stringify!(oxide::types::Silo), + schemars::schema_for!(oxide::types::Silo), + ), + ( + stringify!(oxide::types::SiloQuotas), + schemars::schema_for!(oxide::types::SiloQuotas), + ), + ( + stringify!(oxide::types::OxqlQueryResult), + schemars::schema_for!(oxide::types::OxqlQueryResult), + ), + ( + stringify!(oxide::types::TimeseriesSchemaResultsPage), + schemars::schema_for!(oxide::types::TimeseriesSchemaResultsPage), + ), + ( + stringify!(oxide::types::TufRepoInsertResponse), + schemars::schema_for!(oxide::types::TufRepoInsertResponse), + ), + ( + stringify!(oxide::types::TufRepoGetResponse), + schemars::schema_for!(oxide::types::TufRepoGetResponse), + ), + ( + stringify!(oxide::types::TargetRelease), + schemars::schema_for!(oxide::types::TargetRelease), + ), + ( + stringify!(oxide::types::UpdatesTrustRootResultsPage), + schemars::schema_for!(oxide::types::UpdatesTrustRootResultsPage), + ), + ( + stringify!(oxide::types::UpdatesTrustRoot), + schemars::schema_for!(oxide::types::UpdatesTrustRoot), + ), + ( + stringify!(oxide::types::UserResultsPage), + schemars::schema_for!(oxide::types::UserResultsPage), + ), + ( + stringify!(oxide::types::UserBuiltinResultsPage), + schemars::schema_for!(oxide::types::UserBuiltinResultsPage), + ), + ( + stringify!(oxide::types::UserBuiltin), + schemars::schema_for!(oxide::types::UserBuiltin), + ), + ( + stringify!(oxide::types::SiloUtilizationResultsPage), + schemars::schema_for!(oxide::types::SiloUtilizationResultsPage), + ), + ( + stringify!(oxide::types::SiloUtilization), + schemars::schema_for!(oxide::types::SiloUtilization), + ), + ( + stringify!(oxide::types::Utilization), + schemars::schema_for!(oxide::types::Utilization), + ), + ( + stringify!(oxide::types::VpcFirewallRules), + schemars::schema_for!(oxide::types::VpcFirewallRules), + ), + ( + stringify!(oxide::types::RouterRouteResultsPage), + schemars::schema_for!(oxide::types::RouterRouteResultsPage), + ), + ( + stringify!(oxide::types::RouterRoute), + schemars::schema_for!(oxide::types::RouterRoute), + ), + ( + stringify!(oxide::types::VpcRouterResultsPage), + schemars::schema_for!(oxide::types::VpcRouterResultsPage), + ), + ( + stringify!(oxide::types::VpcRouter), + schemars::schema_for!(oxide::types::VpcRouter), + ), + ( + stringify!(oxide::types::VpcSubnetResultsPage), + schemars::schema_for!(oxide::types::VpcSubnetResultsPage), + ), + ( + stringify!(oxide::types::VpcSubnet), + schemars::schema_for!(oxide::types::VpcSubnet), + ), + ( + stringify!(oxide::types::VpcResultsPage), + schemars::schema_for!(oxide::types::VpcResultsPage), + ), + ( + stringify!(oxide::types::Vpc), + schemars::schema_for!(oxide::types::Vpc), + ), + ( + stringify!(oxide::types::WebhookReceiver), + schemars::schema_for!(oxide::types::WebhookReceiver), + ), + ( + stringify!(oxide::types::WebhookSecrets), + schemars::schema_for!(oxide::types::WebhookSecrets), + ), + ( + stringify!(oxide::types::WebhookSecret), + schemars::schema_for!(oxide::types::WebhookSecret), + ), + ] +} diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml index fc91f6cc..b4ee17d0 100644 --- a/xtask/Cargo.toml +++ b/xtask/Cargo.toml @@ -7,10 +7,12 @@ publish = false [dependencies] clap = { workspace = true } newline-converter = { workspace = true } -openapiv3 = { workspace = true } +proc-macro2 = { workspace = true } progenitor = { workspace = true, default-features = false } +quote = { workspace = true } regex = { workspace = true } rustc_version = { workspace = true } rustfmt-wrapper = { workspace = true } serde_json = { workspace = true } similar = { workspace = true } +syn = { workspace = true } diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 3d54f9fc..e68b25fa 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -6,13 +6,17 @@ #![forbid(unsafe_code)] -use std::{collections::BTreeSet, fs::File, io::Write, path::PathBuf, time::Instant}; +use std::{collections::HashSet, fs::File, io::Write, path::PathBuf, time::Instant}; use clap::Parser; use newline_converter::dos2unix; -use openapiv3::{ReferenceOr, SchemaKind, StatusCode, Type}; +use proc_macro2::TokenStream; use progenitor::{GenerationSettings, Generator, TagStyle}; +use quote::{quote, ToTokens}; use similar::{Algorithm, ChangeTag, TextDiff}; +use syn::{ + GenericArgument, ImplItem, Item, ItemImpl, PathArguments, PathSegment, ReturnType, Type, +}; #[derive(Parser)] #[command(name = "xtask")] @@ -31,8 +35,6 @@ enum Xtask { cli: bool, #[clap(long)] httpmock: bool, - #[clap(long)] - return_types: bool, }, } @@ -46,8 +48,7 @@ fn main() -> Result<(), String> { sdk, cli, httpmock, - return_types, - } => generate(check, verbose, sdk, cli, httpmock, return_types), + } => generate(check, verbose, sdk, cli, httpmock), } } @@ -59,10 +60,9 @@ fn generate( mut sdk: bool, mut cli: bool, mut httpmock: bool, - mut return_types: bool, ) -> Result<(), String> { - if !(sdk || cli || httpmock || return_types) { - (sdk, cli, httpmock, return_types) = (true, true, true, true); + if !(sdk || cli || httpmock) { + (sdk, cli, httpmock) = (true, true, true); } let start = Instant::now(); @@ -92,7 +92,7 @@ fn generate( std::io::stdout().flush().unwrap(); let code = generator.generate_tokens(&spec).unwrap(); - let contents = format_code(code.to_string()); + let contents = format_code(code.clone().to_string()); loc += contents.matches('\n').count(); let mut out_path = root_path.clone(); @@ -101,6 +101,18 @@ fn generate( out_path.push("generated_sdk.rs"); error |= output_contents(check, out_path, contents, verbose).is_err(); + + let ret_types = generate_return_types(code); + let ret_contents = format_code(ret_types.to_string()); + loc += ret_contents.matches('\n').count(); + + let mut ret_out_path = root_path.clone(); + ret_out_path.push("cli"); + ret_out_path.push("tests"); + ret_out_path.push("data"); + ret_out_path.push("api_return_types.rs"); + + error |= output_contents(check, ret_out_path, ret_contents, verbose).is_err(); } // SDK httpmock library @@ -135,89 +147,6 @@ fn generate( error |= output_contents(check, out_path, contents, verbose).is_err(); } - if return_types { - print!("generating return types ... "); - std::io::stdout().flush().unwrap(); - let mut ret_types = BTreeSet::new(); - - for path in spec.paths.paths.values().cloned() { - let Some(path) = path.into_item() else { - unimplemented!("path was a reference"); - }; - - for operation in [ - &path.get, - &path.put, - &path.options, - &path.post, - &path.delete, - &path.head, - &path.patch, - &path.trace, - ] { - let Some(operation) = operation else { - continue; - }; - - for response in operation - .responses - .responses - .iter() - .filter(|(k, _v)| matches!(k, StatusCode::Code(_))) - .map(|(_k, v)| v) - .cloned() - { - let Some(response) = response.into_item() else { - unimplemented!("response was not an item"); - }; - - // Skip items that don't have a JSON response. - let Some(schema) = response - .content - .get("application/json") - .and_then(|j| j.schema.as_ref()) - else { - continue; - }; - let reference = match schema { - ReferenceOr::Reference { reference } => reference, - ReferenceOr::Item(item) => match &item.schema_kind { - SchemaKind::Type(Type::Array(a)) => { - let Some(ReferenceOr::Reference { reference }) = &a.items else { - unimplemented!("returned array type was not a reference"); - }; - reference - } - _ => unimplemented!("direct return type was not an array"), - }, - }; - let Some(type_name) = reference.strip_prefix("#/components/schemas/") else { - unimplemented!("reference was not to a schema"); - }; - - ret_types.insert(type_name.to_owned()); - } - } - } - - let mut contents = - "// The contents of this file are generated; do not modify them.\n\n".to_string(); - contents.push_str("generate_returned_schemas!(\n"); - for ty in ret_types { - contents.push_str(&format!(" oxide::types::{ty},\n")); - } - contents.push_str(")\n"); - loc += contents.matches('\n').count(); - - let mut out_path = root_path; - out_path.push("cli"); - out_path.push("tests"); - out_path.push("data"); - out_path.push("api_return_types.rs"); - - error |= output_contents(check, out_path, contents, verbose).is_err(); - } - let duration = Instant::now().duration_since(start).as_micros(); println!( "generation took {:.3}s ({}us per line)", @@ -232,6 +161,140 @@ fn generate( } } +/// For testing purposes, generate the schemas for all unique types returned by the +/// `send` method. +fn generate_return_types(tokens: TokenStream) -> TokenStream { + let file: syn::File = syn::parse2(tokens).unwrap(); + + // Get the `builder` mod. + let builder = file + .items + .iter() + .filter_map(|i| match i { + Item::Mod(module) if module.ident == "builder" => Some(module), + _ => None, + }) + .next() + .unwrap(); + + let Some((_, builder_content)) = &builder.content else { + unreachable!("no items in mod 'builder'"); + }; + + let mut return_types = Vec::new(); + let mut types_as_string = HashSet::new(); + + // Find inherent impl blocks for structs, then find the return types from `fn send`. + for impl_item in builder_content.iter().filter_map(|i| match i { + Item::Impl(ItemImpl { + items, + trait_: None, + .. + }) => Some(items), + _ => None, + }) { + for method in impl_item.iter().filter_map(|i| match i { + ImplItem::Fn(method) if method.sig.ident == "send" => Some(method), + _ => None, + }) { + if let ReturnType::Type(_, ty) = &method.sig.output { + if let Some(return_type) = extract_response_value_inner_type(ty) { + // We want to remove duplcates, but `TokenStream` cannot be inserted + // into a `HashSet`. Convert to a `String`. + if types_as_string.insert(return_type.to_string()) { + return_types.push(return_type); + } + } + } + } + } + + let schemas = return_types.into_iter().map(|ty| { + quote! { + (stringify!(oxide::#ty), schemars::schema_for!(oxide::#ty)) + } + }); + + quote! { + pub fn schemas() -> Vec<(&'static str, schemars::schema::RootSchema)> { + vec![ + #(#schemas),* + ] + } + } +} + +/// Extract the Oxide success type returned by `send`. +/// For example, `types::Vpc` in `Result, Error>`. +fn extract_response_value_inner_type(ty: &Type) -> Option { + let Type::Path(type_path) = ty else { + return None; + }; + + // Extract `Result, _>`. + let result_args = type_path + .path + .segments + .last() + .filter(|s| s.ident == "Result") + .and_then(|s| match &s.arguments { + PathArguments::AngleBracketed(args) => Some(args), + _ => None, + })?; + + // Extract `ResponseValue`. + let GenericArgument::Type(Type::Path(ok_path)) = result_args.args.first()? else { + return None; + }; + let response_args = ok_path + .path + .segments + .last() + .filter(|s| s.ident == "ResponseValue") + .and_then(|s| match &s.arguments { + PathArguments::AngleBracketed(args) => Some(args), + _ => None, + })?; + + // Extract inner type `T`. + let GenericArgument::Type(Type::Path(inner_path)) = response_args.args.first()? else { + return None; + }; + + // Handle container types (e.g., `Vec`) or direct `types::Item`. + match inner_path.path.segments.last()? { + PathSegment { + arguments: PathArguments::AngleBracketed(args), + .. + } => { + // Container type - extract the inner type. + let GenericArgument::Type(Type::Path(contained)) = args.args.first()? else { + return None; + }; + // Verify it starts with "types::". + contained + .path + .segments + .first() + .filter(|s| s.ident == "types") + .map(|_| contained.path.to_token_stream()) + } + PathSegment { + arguments: PathArguments::None, + .. + } => { + // Direct type - verify it starts with "types::". + inner_path + .path + .segments + .first() + .filter(|s| s.ident == "types") + .map(|_| inner_path.path.to_token_stream()) + } + _ => None, + } +} + fn format_code(code: String) -> String { let contents = format!( "// The contents of this file are generated; do not modify them.\n\n{}", From f7eb71938951bf1a9c8c1104041afc9551919160 Mon Sep 17 00:00:00 2001 From: Will Chandler Date: Wed, 13 Aug 2025 13:17:58 -0400 Subject: [PATCH 3/6] Refactor table printing to struct --- cli/src/oxide_override.rs | 170 ++++++++++++++++++-------------------- 1 file changed, 82 insertions(+), 88 deletions(-) diff --git a/cli/src/oxide_override.rs b/cli/src/oxide_override.rs index 7d674866..f9f81558 100644 --- a/cli/src/oxide_override.rs +++ b/cli/src/oxide_override.rs @@ -24,13 +24,70 @@ use schemars::schema::{RootSchema, Schema, SingleOrVec}; const TABLE_NOT_SUPPORTED: &str = "table formatting is not supported for this command"; pub enum OxideOverride { - Json { - needs_comma: AtomicBool, - }, - Table { - fields: Box>>, - table: Box>, - }, + Json { needs_comma: AtomicBool }, + Table { table: Box> }, +} + +pub struct TableFormatter { + requested_fields: Vec, + fields_to_print: Vec, + table: Table, +} + +impl TableFormatter { + fn new(requested_fields: &[String]) -> Self { + let mut table = Table::new(); + + table + .load_preset(comfy_table::presets::NOTHING) + .set_content_arrangement(ContentArrangement::Disabled); + + Self { + // Downcase user-requested fields to better match the schema. + requested_fields: requested_fields.iter().map(|f| f.to_lowercase()).collect(), + fields_to_print: Vec::new(), + table, + } + } + + fn set_header_fields(&mut self, schema_fields: Vec) { + let fields_to_print = if !self.requested_fields.is_empty() { + let requested: HashSet<_> = self.requested_fields.iter().collect(); + let available: HashSet<_> = schema_fields.iter().collect(); + let invalid = requested.difference(&available); + + for field in invalid { + eprintln_nopipe!("WARNING: '{field}' is not a valid field"); + } + + let mut fields = self.requested_fields.to_vec(); + fields.retain(|f| available.contains(f)); + fields + } else { + schema_fields + }; + + let upcased: Vec<_> = fields_to_print.iter().map(|f| f.to_uppercase()).collect(); + + self.table.set_header(upcased); + self.fields_to_print = fields_to_print; + } + + fn add_row(&mut self, obj: &serde_json::Map) { + let mut row = Vec::with_capacity(self.fields_to_print.len()); + + for field in &self.fields_to_print { + let s = obj.get(field).map(|f| f.to_string()).unwrap_or_default(); + + // `to_string` encloses values in double quotes. + if s.contains(' ') { + row.push(s); + } else { + row.push(s.trim_matches('"').to_string()); + } + } + self.table.add_row(row); + } } impl Default for OxideOverride { @@ -50,8 +107,8 @@ impl CliConfig for OxideOverride { .expect("failed to serialize return to json"); println_nopipe!("{}", s); } - OxideOverride::Table { fields, table } => { - let fields = fields.lock().unwrap(); + OxideOverride::Table { table: t } => { + let mut t = t.lock().unwrap(); let root_schema = schemars::schema_for!(T); let (available_fields, obj_type) = success_item_fields(&root_schema); @@ -61,8 +118,7 @@ impl CliConfig for OxideOverride { return; } - let mut table = table.lock().unwrap(); - let printable_fields = set_header_fields(&fields, available_fields, &mut table); + t.set_header_fields(available_fields); let serde_json::Value::Object(obj) = serde_json::to_value(std::ops::Deref::deref(value)) @@ -80,22 +136,20 @@ impl CliConfig for OxideOverride { for entry in arr { let serde_json::Value::Object(obj) = entry else { let s = serde_json::to_string_pretty(std::ops::Deref::deref(value)) - .expect("failed to serialize return to json"); + .expect("failed to serialize result array member to json"); println_nopipe!("{}", s); return; }; - let row = create_row(&printable_fields, obj); - table.add_row(row); + t.add_row(obj); } } ReturnType::Object => { - let row = create_row(&printable_fields, &obj); - table.add_row(row); + t.add_row(&obj); } } - println_nopipe!("{table}"); + println_nopipe!("{}", t.table); } } } @@ -118,21 +172,17 @@ impl CliConfig for OxideOverride { needs_comma.store(false, std::sync::atomic::Ordering::Relaxed); print_nopipe!("["); } - OxideOverride::Table { fields, table } => { - let mut fields = fields.lock().unwrap(); + OxideOverride::Table { table: t } => { + let mut t = t.lock().unwrap(); let root_schema = schemars::schema_for!(T); let available_fields = list_start_fields(&root_schema); - let mut table = table.lock().unwrap(); - let mut printable_fields = set_header_fields(&fields, available_fields, &mut table); + t.set_header_fields(available_fields); - if printable_fields.is_empty() { + if t.fields_to_print.is_empty() { println_nopipe!("{TABLE_NOT_SUPPORTED}"); } - - // Store our list of fields to print. - std::mem::swap(&mut printable_fields, &mut fields); } } } @@ -152,14 +202,12 @@ impl CliConfig for OxideOverride { }; needs_comma.store(true, std::sync::atomic::Ordering::Relaxed); } - OxideOverride::Table { fields, table } => { + OxideOverride::Table { table: t } => { let s = serde_json::to_value(value).expect("failed to serialize result to json"); if let serde_json::Value::Object(obj) = s { - let fields = fields.lock().unwrap(); - let mut table = table.lock().unwrap(); + let mut t = t.lock().unwrap(); - let row = create_row(&fields, &obj); - table.add_row(row); + t.add_row(&obj); } } } @@ -177,9 +225,9 @@ impl CliConfig for OxideOverride { println_nopipe!("]"); } } - OxideOverride::Table { fields: _, table } => { - let table = table.lock().unwrap(); - println_nopipe!("{table}"); + OxideOverride::Table { table: t } => { + let t = t.lock().unwrap(); + println_nopipe!("{}", t.table); } } } @@ -321,17 +369,8 @@ impl OxideOverride { /// Construct a new OxideOverride for tabular output. pub fn new_table(fields: &[String]) -> Self { - let mut table = Table::new(); - - // Downcase user-requested fields to better match the schema. - let lowercase_fields = fields.iter().map(|f| f.to_lowercase()).collect(); - table - .load_preset(comfy_table::presets::NOTHING) - .set_content_arrangement(ContentArrangement::Disabled); - OxideOverride::Table { - fields: Box::new(Mutex::new(lowercase_fields)), - table: Box::new(Mutex::new(table)), + table: Box::new(Mutex::new(TableFormatter::new(fields))), } } @@ -500,51 +539,6 @@ fn collect_variant_fields(variants: &[Schema]) -> Vec { fields.into_iter().collect() } -/// Set a table's header to the fields available to be printed. -fn set_header_fields( - requested_fields: &[String], - schema_fields: Vec, - table: &mut Table, -) -> Vec { - let printable_fields = if !requested_fields.is_empty() { - let requested: HashSet<_> = requested_fields.iter().collect(); - let available: HashSet<_> = schema_fields.iter().collect(); - let invalid = requested.difference(&available); - - for field in invalid { - eprintln_nopipe!("WARNING: '{field}' is not a valid field"); - } - - let mut fields = requested_fields.to_vec(); - fields.retain(|f| available.contains(f)); - fields - } else { - schema_fields - }; - - let upcased: Vec<_> = printable_fields.iter().map(|f| f.to_uppercase()).collect(); - - table.set_header(upcased); - printable_fields -} - -/// Format an object's fields for printing in a table. -fn create_row(fields: &[String], obj: &serde_json::Map) -> Vec { - let mut row = Vec::with_capacity(fields.len()); - - for field in fields { - let s = obj.get(field).map(|f| f.to_string()).unwrap_or_default(); - - // `to_string` encloses values in double quotes. - if s.contains(' ') { - row.push(s); - } else { - row.push(s.trim_matches('"').to_string()); - } - } - row -} - #[cfg(test)] mod tests { use super::*; From 0dd91376203b3b3ad705369e27eee97a2e93a7bc Mon Sep 17 00:00:00 2001 From: Will Chandler Date: Wed, 13 Aug 2025 14:14:47 -0400 Subject: [PATCH 4/6] Comment on how table formatting works --- cli/src/oxide_override.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/cli/src/oxide_override.rs b/cli/src/oxide_override.rs index f9f81558..c3c9ee24 100644 --- a/cli/src/oxide_override.rs +++ b/cli/src/oxide_override.rs @@ -28,6 +28,29 @@ pub enum OxideOverride { Table { table: Box> }, } +/// Format response values into a table. +/// +/// This works as follows: +/// +/// 1. Get the `schemars` schema of the endpoint's return type. For the endpoints we support +/// tabular output for, this will always be a JSON object. +/// +/// 2. Parse the schema and determine the parameters of the object being returned. The column +/// names displayed will be the parameters of the return object. +/// +/// 3. Handle different return types: +/// - **Non-paginated scalar objects**: Use directly. +/// - **Non-paginated array object**: Extract the inner type of the array. +/// - **Paginated output**: Extract the inner type of the collection. +/// - **Enums**: Take the union of parameter names across all variants. Fields not present +/// for a given variant will be blank. +/// +/// 4. Apply field filtering if requested by the user. Take the intersection of their list +/// and the available fields. +/// +/// 5. Format values for display: +/// - Non-scalar objects are printed as compact JSON. +/// - Strings without any spaces have their quotes stripped. pub struct TableFormatter { requested_fields: Vec, fields_to_print: Vec, From 085cc7caf01454324860eac17e5055409f430786 Mon Sep 17 00:00:00 2001 From: Will Chandler Date: Thu, 14 Aug 2025 14:14:39 -0400 Subject: [PATCH 5/6] Use as_ref instead of deref --- cli/src/oxide_override.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cli/src/oxide_override.rs b/cli/src/oxide_override.rs index c3c9ee24..95ac73d5 100644 --- a/cli/src/oxide_override.rs +++ b/cli/src/oxide_override.rs @@ -143,9 +143,8 @@ impl CliConfig for OxideOverride { t.set_header_fields(available_fields); - let serde_json::Value::Object(obj) = - serde_json::to_value(std::ops::Deref::deref(value)) - .expect("failed to serialize result to json") + let serde_json::Value::Object(obj) = serde_json::to_value(value.as_ref()) + .expect("failed to serialize result to json") else { unreachable!("result was not a JSON object"); }; From 26ca4df61130f13d3cee7e6682dd57e3fbbf237a Mon Sep 17 00:00:00 2001 From: Will Chandler Date: Thu, 14 Aug 2025 14:36:15 -0400 Subject: [PATCH 6/6] Update docs and codegen --- cli/docs/cli.json | 15 +++++++++++++++ cli/tests/data/api_return_types.rs | 8 ++++++++ 2 files changed, 23 insertions(+) diff --git a/cli/docs/cli.json b/cli/docs/cli.json index 7caa9953..30a61e42 100644 --- a/cli/docs/cli.json +++ b/cli/docs/cli.json @@ -587,6 +587,11 @@ { "name": "audit-log", "args": [ + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", @@ -603,6 +608,11 @@ "long": "end-time", "help": "Exclusive" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "limit", "help": "Maximum number of items returned by a single call" @@ -7582,6 +7592,11 @@ "long": "address-lot", "help": "Name or ID of the address lot" }, + { + "long": "format", + "help": "Format in which to print output", + "global": true + }, { "long": "profile", "help": "Configuration profile to use for commands", diff --git a/cli/tests/data/api_return_types.rs b/cli/tests/data/api_return_types.rs index 3f123c97..2c216111 100644 --- a/cli/tests/data/api_return_types.rs +++ b/cli/tests/data/api_return_types.rs @@ -234,6 +234,10 @@ pub fn schemas() -> Vec<(&'static str, schemars::schema::RootSchema)> { stringify!(oxide::types::Snapshot), schemars::schema_for!(oxide::types::Snapshot), ), + ( + stringify!(oxide::types::AuditLogEntryResultsPage), + schemars::schema_for!(oxide::types::AuditLogEntryResultsPage), + ), ( stringify!(oxide::types::PhysicalDiskResultsPage), schemars::schema_for!(oxide::types::PhysicalDiskResultsPage), @@ -346,6 +350,10 @@ pub fn schemas() -> Vec<(&'static str, schemars::schema::RootSchema)> { stringify!(oxide::types::AddressLotCreateResponse), schemars::schema_for!(oxide::types::AddressLotCreateResponse), ), + ( + stringify!(oxide::types::AddressLotViewResponse), + schemars::schema_for!(oxide::types::AddressLotViewResponse), + ), ( stringify!(oxide::types::AddressLotBlockResultsPage), schemars::schema_for!(oxide::types::AddressLotBlockResultsPage),