Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

re-organize CLI config files #727

Merged
merged 15 commits into from
Jul 15, 2024
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ published to crates.io. All are derived from the Oxide API OpenAPI spec.

## Generation

Generation of the CLI, SDK, and mocking library
Generation of the CLI, SDK, and mocking library use
https://github.com/oxidecomputer/progenitor[`progenitor`] for code generation
from the OpenAPI description of the Oxide API. Typically `progenitor` is used
via a macro or `build.rs`, here we use an
Expand Down Expand Up @@ -67,4 +67,4 @@ Use `cargo publish -p oxide` and `cargo publish -p oxide-httpmock`.

Tag the repo with the version number (`vMAJOR.MINOR.PATCH+API_VERSION`) and
push change and tags. This will kick off the `cargo-dist` workflow to generate
a release with binaries.
a release with binaries.
2 changes: 2 additions & 0 deletions cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ serde = { workspace = true }
serde_json = { workspace = true }
tabwriter = { workspace = true }
thouart = { workspace = true }
toml = { workspace = true }
toml_edit = { workspace = true }
tokio = { workspace = true }
url = { workspace = true }
uuid = { workspace = true }
Expand Down
19 changes: 15 additions & 4 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,23 @@

## Installation

Build with `cargo build --bin oxide` and add the executable in `target/debug` to your `PATH`.
You can find pre-built binaries for various platforms
[here](https://github.com/oxidecomputer/oxide.rs/releases). Look for the most
recent release whose version suffix matches the version of Oxide software
currently deployed. For example, if you're running the `20240502.0` release,
use `0.5.0+20240502.0`.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There might not be anything to change here about this, but 20240502.0 is the API version but "v8" is the primary release version. Travis just asked me the other day whether there was a way to know in the console what release you're on. Not only is there no way to do that, but even if you did know you were on v8, there isn't a way to know that that corresponds to 20240502.0, or vice versa. It's worth noting that there is a note in the v8 release notes about 0.5.0 being the relevant CLI version.

Like I said, probably a bigger problem we can't fix in the readme for the CLI, but this reminded me of the problem — something to mull over as we keep tweaking this stuff.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My hope is that our docs would send you to the right CLI version and that the CLI itself will be able to point you to the right version based on a response from the server. We're going to have the generated client send the version as a header. The server response can include an indication if you're on a mismatched version (ie with work in dropshot / omicron)


You can build the CLI yourself using `cargo build --bin oxide`; then add the
executable in `target/debug` to your `PATH` or copy it to another location.

## Authentication

There are two ways to authenticate against the Oxide rack using the CLI:
To authenticate, use `oxide auth login --host my.oxide.host`. This will add a
new profile to `$HOME/.config/oxide/credentials.toml` (and create the file if
necessary). The profile will derive its name from the name of the Oxide Silo.

- Environment variables: You can set the `OXIDE_HOST` and `OXIDE_TOKEN` environment variables. This method is useful for service accounts.
ahl marked this conversation as resolved.
Show resolved Hide resolved
The first time you authenticate, the `default-profile` will be set in
`$HOME/.config/oxide/config.toml`. Edit that file to change the default later.

- Configuration file: When running the `oxide auth login` command, a `$HOME/.config/oxide/hosts.toml` file is generated. This file contains sensitive information like your token and user ID.
If you have a token value, you may set the `OXIDE_HOST` and `OXIDE_TOKEN`
environment variables. This method can be useful for service accounts.
38 changes: 11 additions & 27 deletions cli/docs/cli.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"name": "oxide",
"version": "0.5.0+20240502.0",
"about": "Control an Oxide environment",
ahl marked this conversation as resolved.
Show resolved Hide resolved
"args": [
{
"long": "cacert",
Expand All @@ -18,6 +19,10 @@
"long": "insecure",
"help": "Disable certificate validation and hostname verification"
},
{
"long": "profile",
"help": "Configuration profile to use for commands"
},
{
"long": "resolve",
"help": "Modify name resolution"
Expand Down Expand Up @@ -79,8 +84,8 @@
"subcommands": [
{
"name": "login",
"about": "Authenticate with an Oxide host.",
"long_about": "Authenticate with an Oxide host.\n\nAlternatively, pass in a token on standard input by using `--with-token`.\n\n # start interactive setup\n $ oxide auth login\n\n # authenticate against a specific Oxide instance by reading the token\n # from a file\n $ oxide auth login --with-token --host oxide.internal < mytoken.txt\n\n # authenticate with a specific Oxide instance\n $ oxide auth login --host oxide.internal\n\n # authenticate with an insecure Oxide instance (not recommended)\n $ oxide auth login --host http://oxide.internal",
"about": "Authenticate with an Oxide Silo",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will the average dev/SRE user know what a silo is? I was under the impression the term "silo" was mostly hidden from them and only really relevant to operators.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Silo is a term that we're educating users about. Certainly it's more precise than a "host" ... which is a term of whose definition I'm very unsure.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this is a tough one. In the web console, we generally say that for the developer user without fleet permissions, the silo is their universe and we try to avoid talking about the silo. However, we do say it right in the top left corner, and (as of recently) we call silo images "silo images". So it's not completely hidden.

image

Notably silos are also the first thing in the key concepts guide. So I think we've definitely softened the "first rule of silos is never talk about silos" idea.

https://docs.oxide.computer/guides/key-entities-and-concepts

"long_about": "Authenticate with an Oxide Silo\n\n # authenticate with a specific Oxide silo\n $ oxide auth login --host oxide.internal\n\n # authenticate with an insecure Oxide silo (not recommended)\n $ oxide auth login --host http://oxide.internal",
"args": [
{
"long": "browser",
Expand All @@ -94,51 +99,30 @@
{
"long": "no-browser",
"help": "Print the authentication URL rather than opening a browser window"
},
{
"long": "with-token",
"help": "Read token from standard input"
}
]
},
{
"name": "logout",
"about": "Removes saved authentication information.",
"long_about": "Removes saved authentication information.\n\nThis command does not invalidate any tokens from the hosts.",
"about": "Removes saved authentication information from profiles.",
"long_about": "Removes saved authentication information from profiles.\n\nThis command does not invalidate any tokens.",
"args": [
{
"long": "all",
"short": "a",
"help": "If set, all known hosts and authentication information will be deleted"
"help": "If set, authentication information from all profiles will be deleted"
},
{
"long": "force",
"short": "f",
"help": "Skip confirmation prompt"
},
{
"long": "host",
"short": "H",
"help": "Remove authentication information for a single host"
}
]
},
{
"name": "status",
"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 Oxide environment\nin the current configuration. These hosts may be from your hosts.toml file\nand/or $OXIDE_HOST environment variable.",
"args": [
{
"long": "host",
"short": "H",
"help": "Specific hostname to validate"
},
{
"long": "show-token",
"short": "t",
"help": "Display the auth token"
}
]
"long_about": "Verifies and displays information about your authentication state.\n\nThis command validates the authentication state for each profile in the\ncurrent configuration."
}
]
},
Expand Down
98 changes: 66 additions & 32 deletions cli/src/cli_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,32 @@

// Copyright 2024 Oxide Computer Company

use std::{collections::BTreeMap, marker::PhantomData, path::PathBuf};
use std::{collections::BTreeMap, marker::PhantomData, net::IpAddr, path::PathBuf};

use anyhow::{bail, Result};
use async_trait::async_trait;
use clap::{ArgMatches, Command, CommandFactory, FromArgMatches};
use log::LevelFilter;

use crate::{
context::Context,
generated_cli::{Cli, CliCommand},
OxideOverride, RunnableCmd,
};
use oxide::{
config::{Config, ResolveValue},
context::Context,
};
use oxide::ClientConfig;

/// Control an Oxide environment
#[derive(clap::Parser, Debug, Clone)]
#[command(name = "oxide")]
#[command(name = "oxide", verbatim_doc_comment)]
struct OxideCli {
/// Enable debug output
#[clap(long)]
pub debug: bool,

/// Configuration profile to use for commands
#[clap(long)]
pub profile: Option<String>,

/// Directory to use for configuration
#[clap(long, value_name = "DIR")]
pub config_dir: Option<PathBuf>,
Expand All @@ -48,6 +51,41 @@ struct OxideCli {
pub timeout: Option<u64>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct ResolveValue {
pub host: String,
pub port: u16,
pub addr: IpAddr,
}

impl std::str::FromStr for ResolveValue {
type Err = String;

fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
let values = s.splitn(3, ':').collect::<Vec<_>>();
let [host, port, addr] = values.as_slice() else {
return Err(r#"value must be "host:port:addr"#.to_string());
};

let host = host.to_string();
let port = port
.parse()
.map_err(|_| format!("error parsing port '{}'", port))?;

// `IpAddr::parse()` does not accept enclosing brackets on IPv6
// addresses; strip them off if they exist.
let addr = addr
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or(addr);
let addr = addr
.parse()
.map_err(|_| format!("error parsing address '{}'", addr))?;

Ok(Self { host, port, addr })
}
}

#[async_trait]
trait RunIt: Send + Sync {
async fn run_cmd(&self, matches: &ArgMatches, ctx: &Context) -> Result<()>;
Expand Down Expand Up @@ -182,6 +220,7 @@ impl<'a> NewCli<'a> {
let matches = parser.get_matches();

let OxideCli {
profile,
debug,
config_dir,
resolve,
Expand All @@ -194,42 +233,36 @@ impl<'a> NewCli<'a> {
env_logger::builder().filter_level(LevelFilter::Debug);
}

let mut config = if let Some(dir) = config_dir {
Config::new_with_config_dir(dir)
} else {
Config::default()
};
let mut client_config = ClientConfig::default();

if let Some(profile_name) = profile {
client_config = client_config.with_profile(profile_name);
}
if let Some(config_dir) = config_dir {
client_config = client_config.with_config_dir(config_dir);
}
if let Some(resolve) = resolve {
config = config.with_resolve(resolve);
client_config = client_config.with_resolve(resolve.host, resolve.addr);
}
if let Some(cacert_path) = cacert {
enum CertType {
Pem,
Der,
}

let extension = cacert_path
.extension()
.map(std::ffi::OsStr::to_ascii_lowercase);
let ct = match extension.as_ref().and_then(|ex| ex.to_str()) {
Some("pem") => CertType::Pem,
Some("der") => CertType::Der,
_ => bail!("--cacert path must be a 'pem' or 'der' file".to_string()),
};

let contents = std::fs::read(cacert_path)?;

let cert = match ct {
CertType::Pem => reqwest::tls::Certificate::from_pem(&contents),
CertType::Der => reqwest::tls::Certificate::from_der(&contents),
let cert = match extension.as_ref().and_then(|ex| ex.to_str()) {
Some("pem") => reqwest::tls::Certificate::from_pem(&contents),
Some("der") => reqwest::tls::Certificate::from_der(&contents),
_ => bail!("--cacert path must be a 'pem' or 'der' file".to_string()),
}?;
config = config.with_cert(cert);

client_config = client_config.with_cert(cert);
}
config = config.with_insecure(insecure);
client_config = client_config.with_insecure(insecure);
if let Some(timeout) = timeout {
config = config.with_timeout(timeout);
client_config = client_config.with_timeout(timeout);
}
let ctx = Context::new(config)?;

let ctx = Context::new(client_config)?;

let mut node = &runner;
let mut sm = &matches;
Expand Down Expand Up @@ -267,7 +300,8 @@ struct GeneratedCmd(CliCommand);
#[async_trait]
impl RunIt for GeneratedCmd {
async fn run_cmd(&self, matches: &ArgMatches, ctx: &Context) -> Result<()> {
let cli = Cli::new(ctx.client()?.clone(), OxideOverride::default());
let client = oxide::Client::new_authenticated_config(ctx.client_config())?;
let cli = Cli::new(client, OxideOverride::default());
cli.execute(self.0, matches).await
}

Expand Down
22 changes: 7 additions & 15 deletions cli/src/cmd_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,9 @@ use anyhow::{anyhow, Result};
use async_trait::async_trait;
use clap::Parser;
use futures::{StreamExt, TryStreamExt};
use oxide::Client;
use serde::Deserialize;

use crate::RunnableCmd;

/// Makes an authenticated HTTP request to the Oxide API and prints the response.
///
/// The endpoint argument should be a path of a Oxide API endpoint.
Expand Down Expand Up @@ -97,8 +96,8 @@ pub struct PaginatedResponse {
}

#[async_trait]
impl RunnableCmd for CmdApi {
async fn run(&self, ctx: &oxide::context::Context) -> Result<()> {
impl crate::AuthenticatedCmd for CmdApi {
async fn run(&self, client: &Client) -> Result<()> {
// Make sure the endpoint starts with a slash.
let endpoint = if self.endpoint.starts_with('/') {
self.endpoint.clone()
Expand All @@ -107,7 +106,7 @@ impl RunnableCmd for CmdApi {
};

// Parse the fields.
let params = self.parse_fields(ctx)?;
let params = self.parse_fields()?;

// Set them as our body if they exist.
let mut b = String::new();
Expand Down Expand Up @@ -164,7 +163,6 @@ impl RunnableCmd for CmdApi {
}
}

let client = ctx.client()?;
let rclient = client.client();
let uri = format!("{}{}", client.baseurl(), endpoint_with_query);

Expand Down Expand Up @@ -197,7 +195,7 @@ impl RunnableCmd for CmdApi {
// Print the response headers if requested.
if self.include {
println!("{:?} {}", resp.version(), resp.status());
print_headers(ctx, resp.headers())?;
print_headers(resp.headers())?;
}

if resp.status() == reqwest::StatusCode::NO_CONTENT {
Expand Down Expand Up @@ -292,10 +290,7 @@ impl CmdApi {
Ok(headers)
}

fn parse_fields(
&self,
_ctx: &oxide::context::Context,
) -> Result<HashMap<String, serde_json::Value>> {
fn parse_fields(&self) -> Result<HashMap<String, serde_json::Value>> {
let mut params: HashMap<String, serde_json::Value> = HashMap::new();

// Parse the raw fields.
Expand Down Expand Up @@ -371,10 +366,7 @@ impl CmdApi {
}
}

fn print_headers(
_ctx: &oxide::context::Context,
headers: &reqwest::header::HeaderMap,
) -> Result<()> {
fn print_headers(headers: &reqwest::header::HeaderMap) -> Result<()> {
let mut names: Vec<String> = headers.keys().map(|k| k.as_str().to_string()).collect();
names.sort_by_key(|a| a.to_lowercase());

Expand Down
Loading
Loading