Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ webauthn-rs-core = "=0.5.5"
bytes = "1.10.1"
bollard = "0.19.1"
clap = { version = "4.5.40", features = ["derive"] }
clap_complete = "4.6.11"
futures-channel = "0.3.31"
futures-util = "0.3.31"
http = "1.3.1"
Expand Down
2 changes: 2 additions & 0 deletions apps/piquelctl/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ tempfile.workspace = true
indicatif.workspace = true
parking_lot.workspace = true
clap.workspace = true
# `piquelctl completions` shell scripts.
clap_complete.workspace = true
futures-util.workspace = true
http.workspace = true
piqueld-client.workspace = true
Expand Down
41 changes: 21 additions & 20 deletions apps/piquelctl/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,15 @@ pub(crate) enum Command {
/// It reads the profiles files without contacting a daemon, and ignores
/// `--profile` and the endpoint options.
Profiles,
/// Print the completion script of a shell.
///
/// Save it where your shell loads completions, such as
/// `piquelctl completions bash > ~/.local/share/bash-completion/completions/piquelctl`.
/// It contacts no daemon.
Completions {
/// The shell to complete in.
shell: clap_complete::Shell,
},
/// Manage the daemon's encryption key for secrets.
Secrets {
#[command(subcommand)]
Expand Down Expand Up @@ -318,7 +327,8 @@ pub(crate) enum Command {
command: crate::previews::PreviewCommand,
},
/// Inspect Git source builds and job runs, and read their output.
Builds(BuildArgs),
#[command(alias = "builds")]
Build(BuildArgs),
/// Show an operation, waiting until it ends.
///
/// Deployments, deletions and other long tasks run as operations, whose IDs
Expand All @@ -328,18 +338,14 @@ pub(crate) enum Command {
/// Summarize deployment outcomes, retries, durations, and failure codes
/// over a time window, for every readable application or one environment.
Analytics(crate::analytics::AnalyticsArgs),
/// Read one page of the event history, oldest first.
/// Read one page of the event history, newest first.
///
/// Events record what happened to applications, environments and the
/// daemon, such as edits, deployments and failures.
Events {
/// Only show events of the application with this ID, and of all its
/// environments, including deleted ones.
#[arg(long, value_name = "APPLICATION_ID")]
application: Option<String>,
/// Only show events of the environment with this ID, even a deleted one.
#[arg(long, value_name = "ENVIRONMENT_ID")]
environment: Option<String>,
#[command(alias = "events")]
Event {
#[command(flatten)]
scope: crate::commands::HistoryScope,
/// Only show events of this kind, such as `operation_failed`.
///
/// The dashboard's Events page lists the kinds recorded so far.
Expand Down Expand Up @@ -547,22 +553,17 @@ pub(crate) struct BuildArgs {
pub(crate) enum BuildCommand {
/// List Git source builds and job runs, newest first.
List {
/// Only show the builds of the environments of the application with this ID.
#[arg(long, value_name = "APPLICATION_ID")]
application: Option<String>,
/// Only show the builds of the environment with this ID.
#[arg(long, value_name = "ENVIRONMENT_ID")]
environment: Option<String>,
/// Read the page after this cursor, which the previous page printed.
#[arg(long)]
cursor: Option<String>,
#[command(flatten)]
scope: crate::commands::HistoryScope,
#[command(flatten)]
page: crate::support::PageArgs,
},
/// Read the newest page of a build's output.
///
/// To read older output, pass the `--before` offset it prints. Needs
/// `logs:read`.
Logs {
/// Build ID, from `builds list`.
/// Build ID, from `build list`.
id: i64,
/// Read the output before this byte offset, which the previous page printed.
#[arg(long, value_name = "OFFSET")]
Expand Down
140 changes: 100 additions & 40 deletions apps/piquelctl/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,16 @@ use crate::{
use futures_util::StreamExt;
use piqueld_client::{
ApplicationSummary, ApplicationView, ApplyOptions, Client, ClientError,
ListApplicationsOptions, Operation, OperationState, Page, observability::DiagnosticCode,
ListApplicationsOptions, Operation, OperationState, Page,
observability::{DiagnosticCode, EventFilter},
};
use serde_json::json;
use std::{cell::Cell, collections::BTreeSet, path::Path, time::Duration};
use tokio::time;

/// Dispatches every connected command. `login` and `profiles` never reach here:
/// `main` handles them before the timeout supervisor starts.
/// Dispatches every connected command. `login`, `profiles` and
/// `completions` never reach here: `main` handles them before the timeout
/// supervisor starts.
pub(crate) async fn run(cli: &Cli, client: &Client, console: &mut Console) -> Result<()> {
match &cli.command {
Command::Login { .. } => unreachable!("login has its own interactive deadline"),
Expand All @@ -44,6 +46,7 @@ pub(crate) async fn run(cli: &Cli, client: &Client, console: &mut Console) -> Re
Command::RecoverAdmin => crate::auth::recover_admin(cli, client, console).await,
Command::SignInLink => crate::auth::sign_in_link(cli, client, console).await,
Command::Profiles => unreachable!("profiles are listed before connecting"),
Command::Completions { .. } => unreachable!("completions are printed before connecting"),
Command::Secrets { action } => action.run(client, console).await,
Command::Notification { command } => command.run(client, console).await,
Command::Status => status(cli, client, console).await,
Expand All @@ -56,42 +59,45 @@ pub(crate) async fn run(cli: &Cli, client: &Client, console: &mut Console) -> Re
Command::App { command } => app(cli, client, console, command).await,
Command::Env { command } => command.run(cli, client, console).await,
Command::Preview { command } => command.run(client, console).await,
Command::Builds(args) => match &args.command {
BuildCommand::List {
application,
environment,
cursor,
} => console.emit(
&client
.builds(
application.as_deref(),
environment.as_deref(),
cursor.as_deref(),
)
.await?,
),
Command::Build(args) => match &args.command {
BuildCommand::List { scope, page } => {
let (application, environment) = scope.ids(client).await?;
console.emit(
&client
.builds(
application.as_deref(),
environment.as_deref(),
page.cursor.as_deref(),
Some(page.limit),
)
.await?,
)
}
BuildCommand::Logs { id, before } => build_logs(client, console, *id, *before).await,
},
Command::Operation(args) => args.run(client, console).await,
Command::Analytics(args) => args.run(client, console).await,
Command::Events {
application,
environment,
Command::Event {
scope,
kind,
errors_only,
page,
} => console.emit(
&client
.events(
application.as_deref(),
environment.as_deref(),
kind.as_deref(),
*errors_only,
page.cursor.as_deref(),
page.limit,
)
.await?,
),
} => {
let (application_id, environment_id) = scope.ids(client).await?;
let filter = EventFilter {
application_id,
environment_id,
kind: kind.clone(),
errors_only: *errors_only,
descending: true,
..EventFilter::default()
};
console.emit(
&client
.filtered_events(&filter, page.cursor.as_deref(), page.limit)
.await?,
)
}
}
}

Expand Down Expand Up @@ -549,6 +555,55 @@ pub(crate) async fn fold_applications<T>(
}
}

/// The application and environment `build list` and `event` read the
/// history of; `///` on fields is user-facing help.
#[derive(Debug, clap::Args)]
pub(crate) struct HistoryScope {
/// Only this application and its environments, by name or ID. An ID
/// also finds a deleted application's history.
#[arg(long, alias = "application", value_name = "APP")]
app: Option<String>,
/// Only this environment: with `--app`, by name or ID; alone, by ID,
/// which also finds a deleted environment's history.
#[arg(long, alias = "environment", value_name = "ENV")]
env: Option<String>,
}

impl HistoryScope {
/// The application and environment IDs to filter by. Names and IDs are
/// looked up; a value neither finds is used as an ID when it is shaped
/// like one, since a deleted application's or environment's history is
/// only reachable by its ID. `--env` alone is always an ID.
async fn ids(&self, client: &Client) -> Result<(Option<String>, Option<String>)> {
let Some(name_or_id) = &self.app else {
return Ok((None, self.env.clone()));
};
let Some(application) = find_application(client, name_or_id).await? else {
if looks_like_application_id(name_or_id) {
return Ok((Some(name_or_id.clone()), self.env.clone()));
}
return Err(CliError::application_not_found(name_or_id));
};
let environment = match &self.env {
None => None,
Some(environment) => Some(match application.environment(environment) {
Some(found) => found.id.to_string(),
None if looks_like_application_id(environment) => environment.clone(),
None => {
return Err(CliError::new(
ErrorKind::Input,
format!(
"environment {environment:?} of application {:?} was not found",
application.application.metadata().name
),
));
}
}),
};
Ok((Some(application.application.id().to_string()), environment))
}
}

/// Finds the application with exactly this name, which only one live
/// application can have. The name is checked here too: a daemon that
/// predates the filter ignores it and lists its first page, so a missing
Expand All @@ -572,22 +627,27 @@ pub(crate) async fn resolve_application(
client: &Client,
name_or_id: &str,
) -> Result<ApplicationView> {
find_application(client, name_or_id)
.await?
.ok_or_else(|| CliError::application_not_found(name_or_id))
}

/// Loads an application like [`resolve_application`], or `None` when
/// neither its ID nor its name finds one.
async fn find_application(client: &Client, name_or_id: &str) -> Result<Option<ApplicationView>> {
if looks_like_application_id(name_or_id) {
match client.application(name_or_id).await {
Ok(application) => return Ok(application),
Ok(application) => return Ok(Some(application)),
// The value may still be an application name, so fall through to
// the name lookup.
Err(error) if error.is_not_found() => {}
Err(error) => return Err(error.into()),
}
}
let summary = find_by_name(client, name_or_id).await?.ok_or_else(|| {
CliError::new(
ErrorKind::Input,
format!("application {name_or_id:?} was not found"),
)
})?;
Ok(client.application(summary.id.as_str()).await?)
match find_by_name(client, name_or_id).await? {
Some(summary) => Ok(Some(client.application(summary.id.as_str()).await?)),
None => Ok(None),
}
}

/// One wait for the daemon, with its progress task. While it lives,
Expand Down
8 changes: 8 additions & 0 deletions apps/piquelctl/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,14 @@ impl CliError {
}
}

/// An application that neither its ID nor its name finds.
pub(crate) fn application_not_found(name_or_id: &str) -> Self {
Self::new(
ErrorKind::Input,
format!("application {name_or_id:?} was not found"),
)
}

/// Arguments the command line parser rejected.
pub(crate) fn usage(message: impl Into<String>) -> Self {
Self::new(ErrorKind::Input, message).code(CliCode::Usage)
Expand Down
10 changes: 9 additions & 1 deletion apps/piquelctl/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,15 @@ pub async fn run() -> ExitCode {
.expect("matches were validated by the same Cli command definition");
let mut console = Console::new(&cli);
let result = async {
// Validation is purely local, so it needs neither profiles nor a daemon.
// Completions and validation are purely local, so they need neither
// profiles nor a daemon.
if let cli::Command::Completions { shell } = &cli.command {
// `generate` panics on a write failure, such as a closed pipe.
let mut script = Vec::new();
clap_complete::generate(*shell, &mut command(), "piquelctl", &mut script);
std::io::Write::write_all(&mut std::io::stdout(), &script)?;
return Ok(ExitCode::SUCCESS);
}
if let cli::Command::App {
command: cli::AppCommand::Validate { file },
} = &cli.command
Expand Down
Loading
Loading