diff --git a/Cargo.lock b/Cargo.lock index db8b2db6d..94f74b5bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -564,6 +564,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037e2a1a92236d0aff7e845093f64661d6df4c02c9fcc61a60e9e1d736fa392f" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.4" @@ -2607,6 +2616,7 @@ version = "0.1.0" dependencies = [ "anstream", "clap", + "clap_complete", "futures-util", "http", "indicatif", diff --git a/Cargo.toml b/Cargo.toml index 170693fe0..f783a0ac4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/apps/piquelctl/Cargo.toml b/apps/piquelctl/Cargo.toml index 19b534c83..89bc0fb11 100644 --- a/apps/piquelctl/Cargo.toml +++ b/apps/piquelctl/Cargo.toml @@ -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 diff --git a/apps/piquelctl/src/cli.rs b/apps/piquelctl/src/cli.rs index 946527575..44f086ddb 100644 --- a/apps/piquelctl/src/cli.rs +++ b/apps/piquelctl/src/cli.rs @@ -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)] @@ -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 @@ -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, - /// Only show events of the environment with this ID, even a deleted one. - #[arg(long, value_name = "ENVIRONMENT_ID")] - environment: Option, + #[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. @@ -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, - /// Only show the builds of the environment with this ID. - #[arg(long, value_name = "ENVIRONMENT_ID")] - environment: Option, - /// Read the page after this cursor, which the previous page printed. - #[arg(long)] - cursor: Option, + #[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")] diff --git a/apps/piquelctl/src/commands.rs b/apps/piquelctl/src/commands.rs index ad93d523e..e32f302cf 100644 --- a/apps/piquelctl/src/commands.rs +++ b/apps/piquelctl/src/commands.rs @@ -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"), @@ -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, @@ -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?, + ) + } } } @@ -549,6 +555,55 @@ pub(crate) async fn fold_applications( } } +/// 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, + /// 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, +} + +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, Option)> { + 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 @@ -572,22 +627,27 @@ pub(crate) async fn resolve_application( client: &Client, name_or_id: &str, ) -> Result { + 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> { 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, diff --git a/apps/piquelctl/src/error.rs b/apps/piquelctl/src/error.rs index 971d7793d..bde1558d3 100644 --- a/apps/piquelctl/src/error.rs +++ b/apps/piquelctl/src/error.rs @@ -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) -> Self { Self::new(ErrorKind::Input, message).code(CliCode::Usage) diff --git a/apps/piquelctl/src/lib.rs b/apps/piquelctl/src/lib.rs index 8865fb1de..e6fc85f2e 100644 --- a/apps/piquelctl/src/lib.rs +++ b/apps/piquelctl/src/lib.rs @@ -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 diff --git a/apps/piquelctl/tests/cli.rs b/apps/piquelctl/tests/cli.rs index a7b565f9a..701105fbf 100644 --- a/apps/piquelctl/tests/cli.rs +++ b/apps/piquelctl/tests/cli.rs @@ -1817,12 +1817,13 @@ fn runtime_commands_retry_transport() { } #[test] -fn events_cli_reads_a_filtered_page() { +fn events_cli_reads_a_filtered_page_newest_first() { let server = start_server(false, 1, |request| { assert_eq!(request.method, "GET"); + // An environment ID alone is sent as is, so deleted ones still match. assert_eq!( request.path, - "/api/v1/events?application_id=app-notes&cursor=v1%3A7&environment_id=app-notes-01&limit=2" + "/api/v1/events?cursor=v1%3A7&descending=true&environment_id=app-notes-01&limit=2" ); Reply::json( json!({"items":[{"id":8,"environment_id":"app-notes-01","operation_id":"operation-01","generation":1,"attempt":2,"kind":"operation_succeeded","message":null,"error_code":null,"phase":null,"resource":null,"created_at_ms":123}],"next_cursor":null}), @@ -1831,10 +1832,8 @@ fn events_cli_reads_a_filtered_page() { let output = run( &server, &[ - "events", - "--application", - "app-notes", - "--environment", + "event", + "--env", "app-notes-01", "--cursor", "v1:7", @@ -1853,29 +1852,89 @@ fn events_cli_reads_a_filtered_page() { } #[test] -fn builds_list_sends_its_filters() { - let server = start_server(false, 1, |request| { +fn builds_list_resolves_names_and_sends_its_filters() { + for (arguments, filter) in [ + // The plural noun and `--application` stay as hidden aliases. + ( + &["builds", "list", "--application", "app-notes-01"][..], + "application_id=app-notes-01&cursor=v1%3A7&limit=50", + ), + ( + &["build", "list", "--app", "notes", "--limit", "5"], + "application_id=app-notes-01&cursor=v1%3A7&limit=5", + ), + // A name of ID length is looked up as a name once no ID matches. + ( + &["build", "list", "--app", "welcome-to-docker"], + "application_id=app-welcome-01&cursor=v1%3A7&limit=50", + ), + ( + &["build", "list", "--app", "notes", "--env", "production"], + "application_id=app-notes-01&cursor=v1%3A7&environment_id=app-notes-01&limit=50", + ), + // Deleted applications and environments are found by their IDs. + ( + &[ + "build", + "list", + "--app", + "app-gone-01", + "--env", + "env-gone-01", + ], + "application_id=app-gone-01&cursor=v1%3A7&environment_id=env-gone-01&limit=50", + ), + ] { + let server = start_server(false, usize::MAX, |request| match request.path.as_str() { + // Names are looked up with one filtered request. + "/api/v1/applications?name=notes" => { + Reply::json(page(vec![app_summary("app-notes-01", "notes")], None)) + } + "/api/v1/applications?name=welcome-to-docker" => Reply::json(page( + vec![app_summary("app-welcome-01", "welcome-to-docker")], + None, + )), + "/api/v1/applications?name=app-gone-01" => Reply::json(page(Vec::new(), None)), + "/api/v1/applications/app-notes-01" => Reply::json(app_view("app-notes-01", "notes")), + "/api/v1/applications/app-welcome-01" => { + Reply::json(app_view("app-welcome-01", "welcome-to-docker")) + } + "/api/v1/applications/welcome-to-docker" | "/api/v1/applications/app-gone-01" => { + Reply::status( + "404 Not Found", + &json!({"code": "not_found", "message": "not found"}), + ) + } + path if path.starts_with("/api/v1/builds") => Reply::json(page(Vec::new(), None)), + path => panic!("unexpected path {path}"), + }); + assert_json_success(&run(&server, &[arguments, &["--cursor", "v1:7"]].concat())); + let requests = server.stop(); + let last = requests.last().expect("a builds request"); assert_eq!( - request.path, - "/api/v1/builds?application_id=app-notes&cursor=v1%3A7&environment_id=app-notes-01" + last.path, + format!("/api/v1/builds?{filter}"), + "{arguments:?}" ); - Reply::json(page(Vec::new(), None)) + } + // A name that finds nothing, and isn't shaped like an ID, is an error. + let server = start_server(false, usize::MAX, |request| match request.path.as_str() { + "/api/v1/applications?name=blog" => Reply::json(page(Vec::new(), None)), + path => panic!("unexpected path {path}"), }); - let output = run( - &server, - &[ - "builds", - "list", - "--application", - "app-notes", - "--environment", - "app-notes-01", - "--cursor", - "v1:7", - ], - ); - assert_json_success(&output); - assert_eq!(server.finish().len(), 1); + let output = run(&server, &["build", "list", "--app", "blog"]); + server.stop(); + assert_eq!(output.status.code(), Some(2), "{output:?}"); +} + +#[test] +fn completions_print_a_shell_script() { + let output = support::command() + .args(["completions", "bash"]) + .output() + .expect("piquelctl process"); + assert!(output.status.success(), "{output:?}"); + assert!(String::from_utf8_lossy(&output.stdout).contains("_piquelctl")); } #[test] diff --git a/apps/piqueld-ui/src/browser/builds.rs b/apps/piqueld-ui/src/browser/builds.rs index ce4962ce4..d1555524f 100644 --- a/apps/piqueld-ui/src/browser/builds.rs +++ b/apps/piqueld-ui/src/browser/builds.rs @@ -12,7 +12,8 @@ use leptos::prelude::*; use leptos::task::spawn_local; use leptos_router::components::A; use piqueld_client::{ - BuildRecord, BuildState, Client, ValidatedBuild as Build, ValidatedSource as Source, + BuildRecord, BuildState, Client, ClientError, Page, ValidatedBuild as Build, + ValidatedSource as Source, access::AppPermission, observability::{Bytes, Elapsed}, }; @@ -29,6 +30,22 @@ pub(super) fn BuildsPage() -> impl IntoView { } } +/// One page of the builds of `scope`, an application and environment ID +/// that each narrow it when given, newest first, after `cursor`. +async fn builds_page( + (application, environment): (Option, Option), + cursor: Option, +) -> Result, ClientError> { + Client::browser() + .builds( + application.as_deref(), + environment.as_deref(), + cursor.as_deref(), + None, + ) + .await +} + /// Build list, optionally scoped to one `application`'s environments or to one /// `environment`. A 3-second loop refetches the first page on demand or while /// any build is running (skipped while the tab is hidden). Once older pages @@ -62,10 +79,7 @@ pub(super) fn BuildHistory( { refresh.set(false); loading.set(true); - let (application, environment) = scope.get_value(); - let result = Client::browser() - .builds(application.as_deref(), environment.as_deref(), None) - .await; + let result = builds_page(scope.get_value(), None).await; if !alive.get() { break; } @@ -88,17 +102,9 @@ pub(super) fn BuildHistory( }); let older = move || { loading.set(true); - let (application, environment) = scope.get_value(); - let next = cursor.get_untracked(); + let (scope, next) = (scope.get_value(), cursor.get_untracked()); spawn_local(async move { - match Client::browser() - .builds( - application.as_deref(), - environment.as_deref(), - next.as_deref(), - ) - .await - { + match builds_page(scope, next).await { Ok(page) => { records.update(|items| items.extend(page.items)); cursor.set(page.next_cursor); diff --git a/apps/piqueld/tests/api_contract/transport.rs b/apps/piqueld/tests/api_contract/transport.rs index 9a98b4cf7..d3cc20482 100644 --- a/apps/piqueld/tests/api_contract/transport.rs +++ b/apps/piqueld/tests/api_contract/transport.rs @@ -19,7 +19,7 @@ async fn typed_client_exercises_the_lifecycle_over_tcp_and_a_unix_socket() { assert_eq!(status.api_version, "v1"); assert!( client - .builds(None, None, None) + .builds(None, None, None, None) .await .unwrap() .items diff --git a/crates/piqueld-client/src/builds.rs b/crates/piqueld-client/src/builds.rs index 39cbc8058..b8eecf6cd 100644 --- a/crates/piqueld-client/src/builds.rs +++ b/crates/piqueld-client/src/builds.rs @@ -2,7 +2,8 @@ use crate::{BuildLogPage, BuildRecord, Client, ClientError, Page, client::EnvelopedCall}; impl Client { /// Reads one page of build attempts, newest first, optionally of one - /// application's environments or of one environment. + /// application's environments or of one environment, of at most `limit` + /// builds, or the daemon's default. /// # Errors /// Returns transport, decoding, or API errors. pub async fn builds( @@ -10,9 +11,13 @@ impl Client { application: Option<&str>, environment: Option<&str>, cursor: Option<&str>, + limit: Option, ) -> Result, ClientError> { + if let Some(limit) = limit { + crate::client::check_page_limit("build", limit)?; + } self.generated - .list_builds(application, cursor, environment, None) + .list_builds(application, cursor, environment, limit.map(i64::from)) .data() .await } diff --git a/docs/dev/publishing.md b/docs/dev/publishing.md index 49ca30bcc..44b1cd6ea 100644 --- a/docs/dev/publishing.md +++ b/docs/dev/publishing.md @@ -265,9 +265,8 @@ set `DOCKER_HOST` to that engine's socket first. - **The deployment fails with `git_build_failed`.** The build's output ends with the error. List the builds with - `piquelctl builds list --application `, where `` is the ID - `piquelctl app show piqueld-dev` prints, then read one with - `piquelctl builds logs `. + `piquelctl build list --app piqueld-dev`, then read one with + `piquelctl build logs `. - **The first deployment fails with `preparation_timeout`.** The build took longer than [`reconciliation.prepare_timeout_seconds`](../user/reference/configuration-settings.md#reconciliationprepare_timeout_seconds), diff --git a/docs/dev/quickstart.md b/docs/dev/quickstart.md index 55e3fea5b..464ac9449 100644 --- a/docs/dev/quickstart.md +++ b/docs/dev/quickstart.md @@ -53,7 +53,7 @@ work. Use the same development socket for repair, source refresh, and history: ```console just run --socket /tmp/piqueld-dev-run/piqueld.sock app reconcile basic-website --yes just run --socket /tmp/piqueld-dev-run/piqueld.sock app deploy basic-website --yes -just run --socket /tmp/piqueld-dev-run/piqueld.sock events --application +just run --socket /tmp/piqueld-dev-run/piqueld.sock event --app basic-website ``` The application got one environment, `production`, with its own `env-…` ID. Add diff --git a/docs/user/applications/deploy-on-push.md b/docs/user/applications/deploy-on-push.md index edc716176..0bfbaaab3 100644 --- a/docs/user/applications/deploy-on-push.md +++ b/docs/user/applications/deploy-on-push.md @@ -355,7 +355,7 @@ deployment found on `main`. `piquelctl app show blog` also prints Each deployment that follows a push records a `branch_synced` event, such as `branch main moved to 44928ef…`, requested by `sync:poll` or `sync:webhook`. -They are on the application's **Events** tab, and in `piquelctl events`. +They are on the application's **Events** tab, and in `piquelctl event`. With a webhook, GitHub's **Recent Deliveries**, on the webhook's settings, shows each delivery and piqueld's response: `204` when it was accepted. diff --git a/docs/user/applications/git-builds.md b/docs/user/applications/git-builds.md index 46876eaaa..daf571690 100644 --- a/docs/user/applications/git-builds.md +++ b/docs/user/applications/git-builds.md @@ -280,10 +280,10 @@ Service: web Source: git https://github.com/docker/welcome-to-docker.git (main) ``` -List the application's builds by its ID, which `app show` prints: +List the application's builds: ```console -$ piquelctl builds list --application app-01a16d91fb2c7b93bfe138a7d95dc56f +$ piquelctl build list --app welcome 1 env-01a16d91fb2c7b93bfe138a7d95dc560 web Succeeded 1792839646134 ``` @@ -292,7 +292,7 @@ state, and when it started, in Unix milliseconds. Read a build's output by its number: ```console -piquelctl builds logs 1 +piquelctl build logs 1 ``` It prints the latest output. When there is more, its last line, @@ -303,7 +303,7 @@ prints the earlier part. - **The deployment fails with `git_build_failed`.** The checkout or the build failed. Its output ends with the error: open the build on the **Builds** - page, or run `piquelctl builds logs`. Fix the repository or the source's + page, or run `piquelctl build logs`. Fix the repository or the source's settings, then deploy again. `piquelctl` suggests `env reconcile`, which retries the same deployment: it only helps when the cause was temporary, such as the network. diff --git a/docs/user/applications/jobs.md b/docs/user/applications/jobs.md index 4df9d11e3..52d7bf5f7 100644 --- a/docs/user/applications/jobs.md +++ b/docs/user/applications/jobs.md @@ -330,24 +330,24 @@ run to see its output. ![The Builds tab of reader, with run #1 of job migrate expanded: it succeeded with exit code 0 in 1.1 seconds, in the image miniflux/miniflux:2.3.4](../../images/applications/job-run.png) -`piquelctl builds list` lists the latest runs and builds, newest first: +`piquelctl build list` lists the latest runs and builds, newest first: ```console -$ piquelctl builds list +$ piquelctl build list 1 env-01a16da791427563adb574452b389f93 job migrate (web) Succeeded 1792841094238 ``` The first column is the run's ID. Read its output: ```console -$ piquelctl builds logs 1 +$ piquelctl build logs 1 level=INFO msg="Running database migrations" current_version=0 latest_version=135 ``` ## Troubleshooting - **The deployment fails with `job_failed`.** Read the job's output on the - **Builds** tab, or with `piquelctl builds logs`. Its last line says how the container ended, such as + **Builds** tab, or with `piquelctl build logs`. Its last line says how the container ended, such as `piqueld: task: non-zero exit (1)`. - **The deployment fails with `job_timeout`.** The job ran longer than its `timeout_seconds`. Raise it, or find out what the job waits for. diff --git a/docs/user/operations/history.md b/docs/user/operations/history.md index 92adb2556..554a57b46 100644 --- a/docs/user/operations/history.md +++ b/docs/user/operations/history.md @@ -13,19 +13,21 @@ are never part of them. - In the dashboard, the **Events** page lists every event you can read, and each environment's **Events** tab lists that environment's. -- With `piquelctl`, give an application's ID, which `piquelctl app show` - prints: +- With `piquelctl`, name the application, and optionally one of its + environments with `--env`: ```console - $ piquelctl events --application app-01a16b1282cf755287920502b5793cd1 --limit 3 - 11 1792797737680 environment_created - attempt - created environment production by host operator (uid 1000) - 12 1792797737682 refresh_requested operation-01a16b1282d27232a446f3dbc4a19808 attempt 0 by host operator (uid 1000) + $ piquelctl event --app hello --limit 3 13 1792797737713 operation_started operation-01a16b1282d27232a446f3dbc4a19808 attempt 1 by host operator (uid 1000) - Next cursor: v1:13 + 12 1792797737682 refresh_requested operation-01a16b1282d27232a446f3dbc4a19808 attempt 0 by host operator (uid 1000) + 11 1792797737680 environment_created - attempt - created environment production by host operator (uid 1000) + Next cursor: v1:11 ``` - Events come oldest first, 50 at a time unless you pass `--limit`, up to - 100. Pass the printed cursor with `--cursor` to read the next page. + Events come newest first, 50 at a time unless you pass `--limit`, up to + 100. Pass the printed cursor with `--cursor` to read the next page. To + read a deleted application's or environment's history, pass its ID + instead of its name: `--app ID`, or `--env ID` on its own. Each line has the event's number, its time in Unix milliseconds, its kind, and then, where they apply, its operation, attempt, phase, resource, error code, @@ -74,8 +76,8 @@ message, with a hint for what to do next. These are recent lines read from Docker; for a lasting record, [export them](telemetry.md). - A build's output: in the dashboard, see the **Builds** page and each - application's **Builds** tab. With `piquelctl`, `piquelctl builds list` - lists recent builds, and `piquelctl builds logs ` prints one's output. + application's **Builds** tab. With `piquelctl`, `piquelctl build list` + lists recent builds, and `piquelctl build logs ` prints one's output. ## Watch the daemon itself @@ -148,5 +150,6 @@ Deleting an application deletes its history. the `events:read` permission on, and daemon events need `system:read`; see [permissions](../accounts/permissions.md). It may also be older than the retention period. -- **`piquelctl events` rejects the application.** It takes the application's - ID, not its name. +- **`piquelctl event` finds no history of a deleted application.** A name + can only find an application that still exists: pass the deleted one's ID + with `--app`. diff --git a/docs/user/reference/cli-commands.md b/docs/user/reference/cli-commands.md index a2d1abd11..1bae0db5a 100644 --- a/docs/user/reference/cli-commands.md +++ b/docs/user/reference/cli-commands.md @@ -69,6 +69,7 @@ Commands: - [`piquelctl sign-in-link`](#piquelctl-sign-in-link): Print a one-time link that signs a browser in as the host operator. - [`piquelctl setup-link`](#piquelctl-setup-link): Print the link that creates the first account. - [`piquelctl profiles`](#piquelctl-profiles): List your connection profiles and their endpoints. +- [`piquelctl completions`](#piquelctl-completions): Print the completion script of a shell. - [`piquelctl secrets`](#piquelctl-secrets): Manage the daemon's encryption key for secrets. - [`piquelctl notification`](#piquelctl-notification): Manage notification destinations and their deliveries. - [`piquelctl status`](#piquelctl-status): Show whether the daemon responds, its version, and the health of its subsystems. @@ -79,10 +80,10 @@ Commands: - [`piquelctl app`](#piquelctl-app): Create, inspect, edit and deploy applications. - [`piquelctl env`](#piquelctl-env): Create, inspect, deploy and delete an application's environments. - [`piquelctl preview`](#piquelctl-preview): Create, inspect, deploy and delete previews: disposable deployments of a branch. -- [`piquelctl builds`](#piquelctl-builds): Inspect Git source builds and job runs, and read their output. +- [`piquelctl build`](#piquelctl-build): Inspect Git source builds and job runs, and read their output. - [`piquelctl operation`](#piquelctl-operation): Show an operation, waiting until it ends. - [`piquelctl analytics`](#piquelctl-analytics): Summarize deployment outcomes, retries, durations, and failure codes over a time window, for every readable application or one environment. -- [`piquelctl events`](#piquelctl-events): Read one page of the event history, oldest first. +- [`piquelctl event`](#piquelctl-event): Read one page of the event history, newest first. ## `piquelctl login` @@ -608,6 +609,20 @@ It reads the profiles files without contacting a daemon, and ignores `--profile` piquelctl profiles [OPTIONS] ``` +## `piquelctl completions` + +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. + +```text +piquelctl completions [OPTIONS] +``` + +Arguments: + +- ``: The shell to complete in. Values: `bash`, `elvish`, `fish`, `powershell`, `zsh`. + ## `piquelctl secrets` Manage the daemon's encryption key for secrets. @@ -3909,46 +3924,47 @@ Options: - `--yes`: Skip the confirmation prompt. You need it when not on a terminal. - `--no-wait`: Return once the daemon accepts the deletion, without waiting for it to finish. -## `piquelctl builds` +## `piquelctl build` Inspect Git source builds and job runs, and read their output. ```text -piquelctl builds [OPTIONS] +piquelctl build [OPTIONS] ``` Commands: -- [`piquelctl builds list`](#piquelctl-builds-list): List Git source builds and job runs, newest first. -- [`piquelctl builds logs`](#piquelctl-builds-logs): Read the newest page of a build's output. +- [`piquelctl build list`](#piquelctl-build-list): List Git source builds and job runs, newest first. +- [`piquelctl build logs`](#piquelctl-build-logs): Read the newest page of a build's output. -## `piquelctl builds list` +## `piquelctl build list` List Git source builds and job runs, newest first. ```text -piquelctl builds list [OPTIONS] +piquelctl build list [OPTIONS] ``` Options: -- `--application `: Only show the builds of the environments of the application with this ID. -- `--environment `: Only show the builds of the environment with this ID. +- `--app `: Only this application and its environments, by name or ID. An ID also finds a deleted application's history. +- `--env `: Only this environment: with `--app`, by name or ID; alone, by ID, which also finds a deleted environment's history. - `--cursor `: Read the page after this cursor, which the previous page printed. +- `--limit `: Show at most this many items, from 1 to 100. Default: `50`. -## `piquelctl builds logs` +## `piquelctl build logs` Read the newest page of a build's output. To read older output, pass the `--before` offset it prints. Needs `logs:read`. ```text -piquelctl builds logs [OPTIONS] +piquelctl build logs [OPTIONS] ``` Arguments: -- ``: Build ID, from `builds list`. +- ``: Build ID, from `build list`. Options: @@ -3987,20 +4003,20 @@ Options: - `--since `: Start this long ago, such as 24h or 7d; defaults to 30 days before the end. - `--until `: End this long ago; defaults to now. -## `piquelctl events` +## `piquelctl event` -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. ```text -piquelctl events [OPTIONS] +piquelctl event [OPTIONS] ``` Options: -- `--application `: Only show events of the application with this ID, and of all its environments, including deleted ones. -- `--environment `: Only show events of the environment with this ID, even a deleted one. +- `--app `: Only this application and its environments, by name or ID. An ID also finds a deleted application's history. +- `--env `: Only this environment: with `--app`, by name or ID; alone, by ID, which also finds a deleted environment's history. - `--kind `: Only show events of this kind, such as `operation_failed`. The dashboard's Events page lists the kinds recorded so far. diff --git a/docs/user/reference/cli.md b/docs/user/reference/cli.md index e34a711c1..ca77cb152 100644 --- a/docs/user/reference/cli.md +++ b/docs/user/reference/cli.md @@ -21,6 +21,18 @@ Rust with [rustup](https://rustup.rs), then install `piquelctl` from cargo install --locked piquelctl ``` +### Shell completions + +The Nix package installs completions for bash, fish and zsh. Elsewhere, save +the script `piquelctl completions` prints for your shell where it loads +completions, for example for bash: + +```console +piquelctl completions bash > ~/.local/share/bash-completion/completions/piquelctl +``` + +It also prints `elvish` and `powershell` scripts. + ## macOS support On Apple Silicon, Nix users can run `nix build .#cli` or @@ -180,7 +192,7 @@ follows pushes (`following`, `off`, `off (pinned)`, `off (not opted in)`, configure in GitHub, once the daemon sets `ingress.webhook_hostname`, and whether a secret exists; `app repository webhook rotate APP` confirms, then generates a new secret, replacing the previous one, and prints it: it is never -shown again. `events` names sync deployments' actor, `sync:poll` or +shown again. `event` names sync deployments' actor, `sync:poll` or `sync:webhook`. The manifest is shared; values that differ between environments come from @@ -485,8 +497,8 @@ written to stderr, so stdout remains valid JSON. | `notification delete` | `{ "deleted": string }` | | `notification deliveries` | `{ "items": [NotificationDelivery], "next_cursor": string or null }` | | `notification retry` | None | -| `builds list` | `{ "items": [BuildRecord], "next_cursor": string or null }` | -| `builds logs` | `BuildLogPage` | +| `build list` | `{ "items": [BuildRecord], "next_cursor": string or null }` | +| `build logs` | `BuildLogPage` | | `app create` / `app rename` / field edits | `SavedApplication`; `--deploy` uses the same output as `app apply --deploy` | | `app manifest` | Saved TOML as a JSON string | | `app releases` | `{ "items": [ReleaseView], "next_cursor": string or null }` | @@ -512,7 +524,7 @@ written to stderr, so stdout remains valid JSON. | `operation` | `Operation` | | `app`/`env` `reconcile` / `start` / `deploy` | `{ "accepted": AcceptedOperation, "operation": Operation }` and the fields of `Deployment`, with `outcome` and `urls` | | `app`/`env` `reconcile --no-wait` / `stop --no-wait` / `start --no-wait` / `deploy --no-wait` | `AcceptedOperation` (`operation_id`, `environment_id`, `generation`) and the fields of `Deployment` | -| `events` | `{ "items": [Event], "next_cursor": string or null }` | +| `event` | `{ "items": [Event], "next_cursor": string or null }` | | `analytics` | `DeploymentAnalytics` | `Deployment` is the CLI's summary of one deployment of an environment or @@ -966,10 +978,11 @@ networks, and volumes. It saves without deployment unless `--deploy` is given. A advances generation and records an event. Update `metadata.name` in your manifest file afterward; the CLI does not edit files automatically. -`events` reads one page, oldest first. `--application ID` optionally filters by -stable application ID: application-wide events such as edits and renames, and the -events of all its environments, including deleted ones. `--environment ID` filters -by stable environment ID. Deleting an application removes its history. Use `--cursor CURSOR` for subsequent +`event` reads one page, newest first. `--app APP` optionally filters by +application name or ID: application-wide events such as edits and renames, and the +events of all its environments, including deleted ones. `--env ENV` filters by +environment: by name or ID with `--app`, or by ID alone. An ID also finds the +history of a deleted application or environment, which a name can't. Deleting an application removes its history. Use `--cursor CURSOR` for subsequent pages and `--limit N` (1–100, default 50). JSON includes the next cursor. `analytics` summarizes [deployment analytics](../operations/history.md#watch-the-daemon-itself), @@ -1160,9 +1173,9 @@ An empty list prints `No profiles configured.` in human mode, or `--quiet` suppresses human results, information, and progress, but preserves JSON, warnings, errors, and authorized prompts. -`piquelctl builds list [--application ID] [--environment ID] [--cursor CURSOR]` lists one -page of build attempts and job runs, optionally of one application's environments or -of one environment. `piquelctl builds logs ID [--before BYTE_OFFSET]` reads the newest bounded +`piquelctl build list [--app APP] [--env ENV] [--cursor CURSOR] [--limit N]` lists one +page of build attempts and job runs, newest first, optionally of one application's +environments or of one environment, named like `event`'s. `piquelctl build logs ID [--before BYTE_OFFSET]` reads the newest bounded output page, or an older page before the supplied cursor. It prints the cursor for loading older output when available. Both support `--json`. diff --git a/flake.nix b/flake.nix index 63b354aff..b7c4825e5 100644 --- a/flake.nix +++ b/flake.nix @@ -186,7 +186,9 @@ meta.mainProgram = builtins.head binaries; cargoBuildExtraArgs = lib.concatMapStringsSep " " (binary: "--bin ${binary}") binaries; nativeBuildInputs = - commonArgs.nativeBuildInputs ++ lib.optional (builtins.elem "piqueld" binaries) pkgs.makeWrapper; + commonArgs.nativeBuildInputs + ++ lib.optional (builtins.elem "piqueld" binaries) pkgs.makeWrapper + ++ lib.optional (builtins.elem "piquelctl" binaries) pkgs.installShellFiles; installPhaseCommand = '' ${lib.concatMapStringsSep "\n" ( binary: @@ -197,14 +199,27 @@ "$out/share/piqueld/piqueld.example.toml" ''} ''; - postInstall = lib.optionalString (builtins.elem "piqueld" binaries) '' - wrapProgram "$out/bin/piqueld" --prefix PATH : ${ - lib.makeBinPath [ - pkgs.git - pkgs.docker-client - ] - } - ''; + postInstall = + lib.optionalString (builtins.elem "piqueld" binaries) '' + wrapProgram "$out/bin/piqueld" --prefix PATH : ${ + lib.makeBinPath [ + pkgs.git + pkgs.docker-client + ] + } + '' + # The scripts come from running piquelctl, so only a native build makes them. + + + lib.optionalString + ( + builtins.elem "piquelctl" binaries && pkgs.stdenv.buildPlatform.canExecute pkgs.stdenv.hostPlatform + ) + '' + installShellCompletion --cmd piquelctl \ + --bash <("$out/bin/piquelctl" completions bash) \ + --fish <("$out/bin/piquelctl" completions fish) \ + --zsh <("$out/bin/piquelctl" completions zsh) + ''; } ); in