init
This commit is contained in:
commit
fd80fbab7e
48 changed files with 16775 additions and 0 deletions
18
crates/jupiter-cli/Cargo.toml
Normal file
18
crates/jupiter-cli/Cargo.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[package]
|
||||
name = "jupiter-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "jupiter-ctl"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
jupiter-api-types = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
761
crates/jupiter-cli/src/main.rs
Normal file
761
crates/jupiter-cli/src/main.rs
Normal file
|
|
@ -0,0 +1,761 @@
|
|||
//! # jupiter-ctl -- Admin CLI for the Jupiter CI Server
|
||||
//!
|
||||
//! `jupiter-ctl` is the administrative command-line interface for
|
||||
//! [Jupiter](https://github.com/example/jupiter), a self-hosted,
|
||||
//! wire-compatible replacement for [hercules-ci.com](https://hercules-ci.com).
|
||||
//! It is analogous to the upstream `hci` CLI provided by Hercules CI, but
|
||||
//! targets the Jupiter server's REST API instead.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! The CLI is built with [clap 4](https://docs.rs/clap/4) using derive macros.
|
||||
//! Every top-level subcommand maps directly to a resource in the Jupiter REST
|
||||
//! API (`/api/v1/...`):
|
||||
//!
|
||||
//! | Subcommand | API resource | Purpose |
|
||||
//! |-------------|---------------------------------------|------------------------------------------|
|
||||
//! | `account` | `/api/v1/accounts` | Create, list, inspect accounts |
|
||||
//! | `agent` | `/api/v1/agents` | List and inspect connected build agents |
|
||||
//! | `project` | `/api/v1/projects` | CRUD and enable/disable projects |
|
||||
//! | `job` | `/api/v1/projects/{id}/jobs`, `/jobs` | List, inspect, rerun, cancel CI jobs |
|
||||
//! | `state` | `/api/v1/projects/{id}/state` | Binary upload/download of state files |
|
||||
//! | `token` | `/api/v1/.../clusterJoinTokens` | Manage cluster join tokens for agents |
|
||||
//! | `health` | `/api/v1/health` | Quick server liveness/readiness check |
|
||||
//!
|
||||
//! ## Authentication
|
||||
//!
|
||||
//! All requests are authenticated with a bearer token. The token can be
|
||||
//! supplied via the `--token` flag or the `JUPITER_TOKEN` environment
|
||||
//! variable. Tokens are issued by the Jupiter server through
|
||||
//! `POST /api/v1/auth/token` (or stored from a previous session).
|
||||
//!
|
||||
//! ## Intended audience
|
||||
//!
|
||||
//! This tool is designed for **server administrators**, not end users. It
|
||||
//! provides unrestricted access to every server management operation
|
||||
//! exposed by the Jupiter REST API.
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
use reqwest::Client;
|
||||
use std::io::Write;
|
||||
|
||||
/// Top-level CLI definition for `jupiter-ctl`.
|
||||
///
|
||||
/// Parsed by clap from command-line arguments. The two global options --
|
||||
/// `--server` and `--token` -- configure the [`ApiClient`] that every
|
||||
/// subcommand uses to talk to the Jupiter server.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```bash
|
||||
/// # Check server health (uses defaults: localhost:3000, no token)
|
||||
/// jupiter-ctl health
|
||||
///
|
||||
/// # List all projects with an explicit server and token
|
||||
/// jupiter-ctl --server https://ci.example.com --token $TOK project list
|
||||
/// ```
|
||||
#[derive(Parser)]
|
||||
#[command(name = "jupiter-ctl", about = "Jupiter CI admin CLI")]
|
||||
struct Cli {
|
||||
/// Base URL of the Jupiter server (scheme + host + port).
|
||||
///
|
||||
/// Defaults to `http://localhost:3000`. Can also be set via the
|
||||
/// `JUPITER_URL` environment variable.
|
||||
#[arg(long, env = "JUPITER_URL", default_value = "http://localhost:3000")]
|
||||
server: String,
|
||||
|
||||
/// Bearer token used to authenticate API requests.
|
||||
///
|
||||
/// Obtained from `POST /api/v1/auth/token` or stored from a previous
|
||||
/// session. Can also be set via the `JUPITER_TOKEN` environment
|
||||
/// variable. If omitted, requests are sent without authentication
|
||||
/// (only useful for unauthenticated endpoints such as `health`).
|
||||
#[arg(long, env = "JUPITER_TOKEN")]
|
||||
token: Option<String>,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
/// Top-level subcommands, each corresponding to a major REST API resource.
|
||||
///
|
||||
/// The structure mirrors the Jupiter server's REST API so that administrators
|
||||
/// can perform any server-side operation from the command line.
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Account management -- create, list, and inspect accounts.
|
||||
///
|
||||
/// Accounts are the top-level organizational unit in Hercules CI (and
|
||||
/// therefore Jupiter). Projects, agents, and cluster join tokens all
|
||||
/// belong to an account. Maps to `GET/POST /api/v1/accounts`.
|
||||
Account {
|
||||
#[command(subcommand)]
|
||||
action: AccountAction,
|
||||
},
|
||||
|
||||
/// Agent management -- list and inspect connected build agents.
|
||||
///
|
||||
/// Agents are the hercules-ci-agent processes that connect to the
|
||||
/// Jupiter server to pick up and execute CI jobs. This subcommand is
|
||||
/// read-only; agent lifecycle is managed by the agents themselves.
|
||||
/// Maps to `GET /api/v1/agents`.
|
||||
Agent {
|
||||
#[command(subcommand)]
|
||||
action: AgentAction,
|
||||
},
|
||||
|
||||
/// Project management -- full CRUD plus enable/disable toggle.
|
||||
///
|
||||
/// A project links an account to a source repository. When enabled,
|
||||
/// pushes to the repository trigger evaluation and build jobs.
|
||||
/// Maps to `GET/POST /api/v1/projects`.
|
||||
Project {
|
||||
#[command(subcommand)]
|
||||
action: ProjectAction,
|
||||
},
|
||||
|
||||
/// Job management -- list, inspect, rerun, and cancel CI jobs.
|
||||
///
|
||||
/// Jobs represent individual evaluation or build tasks dispatched to
|
||||
/// agents. They belong to a project and are created automatically on
|
||||
/// push events. Maps to `GET/POST /api/v1/jobs` and
|
||||
/// `GET /api/v1/projects/{id}/jobs`.
|
||||
Job {
|
||||
#[command(subcommand)]
|
||||
action: JobAction,
|
||||
},
|
||||
|
||||
/// State file management -- list, download, and upload binary state.
|
||||
///
|
||||
/// Hercules CI effects can persist arbitrary binary data between runs
|
||||
/// using "state files". This subcommand exposes the upload/download
|
||||
/// endpoints so administrators can inspect or seed state data.
|
||||
/// Maps to `GET/PUT /api/v1/projects/{id}/state/{name}/data`.
|
||||
State {
|
||||
#[command(subcommand)]
|
||||
action: StateAction,
|
||||
},
|
||||
|
||||
/// Cluster join token management -- create, list, and revoke tokens.
|
||||
///
|
||||
/// Cluster join tokens authorize new hercules-ci-agent instances to
|
||||
/// connect to the Jupiter server under a specific account. They are
|
||||
/// analogous to the tokens generated in the Hercules CI dashboard.
|
||||
/// Maps to `GET/POST /api/v1/accounts/{id}/clusterJoinTokens` and
|
||||
/// `DELETE /api/v1/cluster-join-tokens/{id}`.
|
||||
Token {
|
||||
#[command(subcommand)]
|
||||
action: TokenAction,
|
||||
},
|
||||
|
||||
/// Server health check.
|
||||
///
|
||||
/// Performs a simple `GET /api/v1/health` request and prints the JSON
|
||||
/// response. Useful for verifying that the Jupiter server is running
|
||||
/// and reachable. Does not require authentication.
|
||||
Health,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Account subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Actions available under `jupiter-ctl account`.
|
||||
///
|
||||
/// Maps to the `/api/v1/accounts` REST resource.
|
||||
#[derive(Subcommand)]
|
||||
enum AccountAction {
|
||||
/// Create a new account.
|
||||
///
|
||||
/// Sends `POST /api/v1/accounts` with `{ "name": "<name>" }`.
|
||||
/// Prints the created account object (including its server-assigned ID).
|
||||
Create { name: String },
|
||||
|
||||
/// List all accounts.
|
||||
///
|
||||
/// Sends `GET /api/v1/accounts` and prints the JSON array of accounts.
|
||||
List,
|
||||
|
||||
/// Get details for a single account by ID.
|
||||
///
|
||||
/// Sends `GET /api/v1/accounts/{id}` and prints the account object.
|
||||
Get { id: String },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Actions available under `jupiter-ctl agent`.
|
||||
///
|
||||
/// Agents are read-only from the CLI's perspective. Their lifecycle is
|
||||
/// controlled by the hercules-ci-agent processes themselves; the server
|
||||
/// merely tracks their state. Maps to `/api/v1/agents`.
|
||||
#[derive(Subcommand)]
|
||||
enum AgentAction {
|
||||
/// List all connected agents.
|
||||
///
|
||||
/// Sends `GET /api/v1/agents` and prints the JSON array of agents.
|
||||
List,
|
||||
|
||||
/// Get details for a single agent by ID.
|
||||
///
|
||||
/// Sends `GET /api/v1/agents/{id}` and prints the agent object,
|
||||
/// including hostname, platform capabilities, and connection status.
|
||||
Get { id: String },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Project subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Actions available under `jupiter-ctl project`.
|
||||
///
|
||||
/// Projects tie an account to a source repository and control whether CI
|
||||
/// jobs are created on push events. Maps to `/api/v1/projects`.
|
||||
#[derive(Subcommand)]
|
||||
enum ProjectAction {
|
||||
/// Create a new project.
|
||||
///
|
||||
/// Sends `POST /api/v1/projects` with the account ID, repository ID,
|
||||
/// and display name. The repository ID is the forge-specific
|
||||
/// identifier (e.g. GitHub repo ID).
|
||||
Create {
|
||||
/// Account that owns this project.
|
||||
#[arg(long)]
|
||||
account_id: String,
|
||||
/// Forge-specific repository identifier.
|
||||
#[arg(long)]
|
||||
repo_id: String,
|
||||
/// Human-readable project name.
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// List all projects.
|
||||
///
|
||||
/// Sends `GET /api/v1/projects` and prints the JSON array.
|
||||
List,
|
||||
|
||||
/// Get details for a single project by ID.
|
||||
///
|
||||
/// Sends `GET /api/v1/projects/{id}`.
|
||||
Get { id: String },
|
||||
|
||||
/// Enable a project so that pushes trigger CI jobs.
|
||||
///
|
||||
/// Sends `POST /api/v1/projects/{id}` with `{ "enabled": true }`.
|
||||
Enable { id: String },
|
||||
|
||||
/// Disable a project so that pushes no longer trigger CI jobs.
|
||||
///
|
||||
/// Sends `POST /api/v1/projects/{id}` with `{ "enabled": false }`.
|
||||
Disable { id: String },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Job subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Actions available under `jupiter-ctl job`.
|
||||
///
|
||||
/// Jobs represent evaluation or build work dispatched to agents. Maps to
|
||||
/// `/api/v1/jobs` and `/api/v1/projects/{id}/jobs`.
|
||||
#[derive(Subcommand)]
|
||||
enum JobAction {
|
||||
/// List jobs for a specific project (paginated).
|
||||
///
|
||||
/// Sends `GET /api/v1/projects/{project_id}/jobs?page={page}`.
|
||||
List {
|
||||
/// Project whose jobs to list.
|
||||
#[arg(long)]
|
||||
project_id: String,
|
||||
/// Page number (1-indexed). Defaults to 1.
|
||||
#[arg(long, default_value = "1")]
|
||||
page: u64,
|
||||
},
|
||||
|
||||
/// Get details for a single job by ID.
|
||||
///
|
||||
/// Sends `GET /api/v1/jobs/{id}` and prints the job object, including
|
||||
/// status, timestamps, and associated evaluation results.
|
||||
Get { id: String },
|
||||
|
||||
/// Re-run a previously completed (or failed) job.
|
||||
///
|
||||
/// Sends `POST /api/v1/jobs/{id}/rerun`. The server will create a new
|
||||
/// job execution with the same parameters.
|
||||
Rerun { id: String },
|
||||
|
||||
/// Cancel a currently running job.
|
||||
///
|
||||
/// Sends `POST /api/v1/jobs/{id}/cancel`. The agent executing the
|
||||
/// job will be notified to abort.
|
||||
Cancel { id: String },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Actions available under `jupiter-ctl state`.
|
||||
///
|
||||
/// State files are opaque binary blobs that Hercules CI effects can
|
||||
/// persist between runs. For example, a deployment effect might store a
|
||||
/// Terraform state file. The state API uses `application/octet-stream`
|
||||
/// for upload and download rather than JSON.
|
||||
///
|
||||
/// Maps to `/api/v1/projects/{id}/states` (listing) and
|
||||
/// `/api/v1/projects/{id}/state/{name}/data` (get/put).
|
||||
#[derive(Subcommand)]
|
||||
enum StateAction {
|
||||
/// List all state files for a project.
|
||||
///
|
||||
/// Sends `GET /api/v1/projects/{project_id}/states` and prints the
|
||||
/// JSON array of state file metadata.
|
||||
List {
|
||||
/// Project whose state files to list.
|
||||
#[arg(long)]
|
||||
project_id: String,
|
||||
},
|
||||
|
||||
/// Download a state file (binary).
|
||||
///
|
||||
/// Sends `GET /api/v1/projects/{project_id}/state/{name}/data`.
|
||||
/// The raw bytes are written to `--output` if specified, otherwise
|
||||
/// they are written directly to stdout. This allows piping into
|
||||
/// other tools (e.g. `jupiter-ctl state get ... | tar xz`).
|
||||
Get {
|
||||
/// Project that owns the state file.
|
||||
#[arg(long)]
|
||||
project_id: String,
|
||||
/// Logical name of the state file (as used in the Hercules CI effect).
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
/// Output file path. If omitted, raw bytes are written to stdout.
|
||||
#[arg(long)]
|
||||
output: Option<String>,
|
||||
},
|
||||
|
||||
/// Upload (create or replace) a state file (binary).
|
||||
///
|
||||
/// Reads the file at `--input` and sends its contents as
|
||||
/// `PUT /api/v1/projects/{project_id}/state/{name}/data` with
|
||||
/// `Content-Type: application/octet-stream`.
|
||||
Put {
|
||||
/// Project that owns the state file.
|
||||
#[arg(long)]
|
||||
project_id: String,
|
||||
/// Logical name of the state file.
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
/// Path to the local file whose contents will be uploaded.
|
||||
#[arg(long)]
|
||||
input: String,
|
||||
},
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Token subcommands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Actions available under `jupiter-ctl token`.
|
||||
///
|
||||
/// Cluster join tokens authorize hercules-ci-agent instances to register
|
||||
/// with the Jupiter server under a specific account. An agent presents
|
||||
/// this token during its initial handshake; the server then associates
|
||||
/// the agent with the account.
|
||||
///
|
||||
/// Maps to `/api/v1/accounts/{id}/clusterJoinTokens` and
|
||||
/// `/api/v1/cluster-join-tokens/{id}`.
|
||||
#[derive(Subcommand)]
|
||||
enum TokenAction {
|
||||
/// Create a new cluster join token for an account.
|
||||
///
|
||||
/// Sends `POST /api/v1/accounts/{account_id}/clusterJoinTokens`
|
||||
/// with `{ "name": "<name>" }`. The response includes the raw token
|
||||
/// value -- this is the only time it is returned in cleartext.
|
||||
Create {
|
||||
/// Account the token belongs to.
|
||||
#[arg(long)]
|
||||
account_id: String,
|
||||
/// Human-readable label for the token.
|
||||
#[arg(long)]
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// List all cluster join tokens for an account.
|
||||
///
|
||||
/// Sends `GET /api/v1/accounts/{account_id}/clusterJoinTokens`.
|
||||
/// Note: the raw token values are **not** included in the listing for
|
||||
/// security reasons.
|
||||
List {
|
||||
/// Account whose tokens to list.
|
||||
#[arg(long)]
|
||||
account_id: String,
|
||||
},
|
||||
|
||||
/// Revoke (delete) a cluster join token by ID.
|
||||
///
|
||||
/// Sends `DELETE /api/v1/cluster-join-tokens/{id}`. Any agent that
|
||||
/// was using this token will be unable to re-authenticate after its
|
||||
/// current session expires.
|
||||
Revoke { id: String },
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// API client
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// HTTP client wrapper for the Jupiter REST API.
|
||||
///
|
||||
/// `ApiClient` encapsulates a [`reqwest::Client`], the server base URL, and
|
||||
/// an optional bearer token. All subcommand handlers use this struct to
|
||||
/// issue HTTP requests against the Jupiter server.
|
||||
///
|
||||
/// The client provides convenience methods for common request patterns:
|
||||
///
|
||||
/// - [`get_json`](Self::get_json) / [`post_json`](Self::post_json) --
|
||||
/// JSON request/response for most CRUD operations.
|
||||
/// - [`get_bytes`](Self::get_bytes) / [`put_bytes`](Self::put_bytes) --
|
||||
/// raw binary transfer for state file operations.
|
||||
/// - [`delete`](Self::delete) -- resource deletion (e.g. token revocation).
|
||||
///
|
||||
/// Every method checks the HTTP status code and returns an [`anyhow::Error`]
|
||||
/// with the status and response body on non-2xx responses.
|
||||
struct ApiClient {
|
||||
/// Underlying HTTP client (connection pool, TLS, etc.).
|
||||
client: Client,
|
||||
/// Base URL of the Jupiter server, e.g. `http://localhost:3000`.
|
||||
base_url: String,
|
||||
/// Optional bearer token for authentication. When `Some`, it is
|
||||
/// attached to every outgoing request as an `Authorization: Bearer`
|
||||
/// header.
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
/// Create a new `ApiClient` targeting the given server URL.
|
||||
///
|
||||
/// If `token` is `Some`, all requests will include an
|
||||
/// `Authorization: Bearer <token>` header.
|
||||
fn new(base_url: String, token: Option<String>) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
base_url,
|
||||
token,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an absolute URL for the given API path.
|
||||
///
|
||||
/// Joins the base URL with `/api/v1` and the provided `path`.
|
||||
/// Trailing slashes on the base URL are normalized to avoid double
|
||||
/// slashes.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```text
|
||||
/// base_url = "http://localhost:3000/"
|
||||
/// path = "/accounts"
|
||||
/// result = "http://localhost:3000/api/v1/accounts"
|
||||
/// ```
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("{}/api/v1{}", self.base_url.trim_end_matches('/'), path)
|
||||
}
|
||||
|
||||
/// Start building an HTTP request with the given method and API path.
|
||||
///
|
||||
/// The bearer token (if present) is automatically attached. Callers
|
||||
/// can further customize the [`reqwest::RequestBuilder`] before
|
||||
/// sending (e.g. adding a JSON body or custom headers).
|
||||
fn request(&self, method: reqwest::Method, path: &str) -> reqwest::RequestBuilder {
|
||||
let mut req = self.client.request(method, self.url(path));
|
||||
if let Some(ref token) = self.token {
|
||||
req = req.bearer_auth(token);
|
||||
}
|
||||
req
|
||||
}
|
||||
|
||||
/// Send a `GET` request and deserialize the response as JSON.
|
||||
///
|
||||
/// Returns `Err` if the server responds with a non-2xx status code
|
||||
/// (the error message includes both the status and the response body).
|
||||
async fn get_json(&self, path: &str) -> Result<serde_json::Value> {
|
||||
let resp = self.request(reqwest::Method::GET, path).send().await?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("HTTP {}: {}", status, body);
|
||||
}
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
|
||||
/// Send a `POST` request with a JSON body and deserialize the JSON
|
||||
/// response.
|
||||
///
|
||||
/// Used for creating resources (accounts, projects, tokens) and for
|
||||
/// triggering actions (rerun, cancel, enable/disable).
|
||||
///
|
||||
/// Returns `Err` on non-2xx status codes.
|
||||
async fn post_json(&self, path: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
|
||||
let resp = self
|
||||
.request(reqwest::Method::POST, path)
|
||||
.json(body)
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("HTTP {}: {}", status, body);
|
||||
}
|
||||
Ok(resp.json().await?)
|
||||
}
|
||||
|
||||
/// Send a `DELETE` request. Expects no response body.
|
||||
///
|
||||
/// Currently used only for revoking cluster join tokens
|
||||
/// (`DELETE /api/v1/cluster-join-tokens/{id}`).
|
||||
///
|
||||
/// Returns `Err` on non-2xx status codes.
|
||||
async fn delete(&self, path: &str) -> Result<()> {
|
||||
let resp = self
|
||||
.request(reqwest::Method::DELETE, path)
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("HTTP {}: {}", status, body);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a `PUT` request with a raw binary body
|
||||
/// (`Content-Type: application/octet-stream`).
|
||||
///
|
||||
/// Used to upload state file data. The Jupiter server stores the
|
||||
/// bytes verbatim and makes them available for subsequent downloads.
|
||||
///
|
||||
/// Returns `Err` on non-2xx status codes.
|
||||
async fn put_bytes(&self, path: &str, data: Vec<u8>) -> Result<()> {
|
||||
let resp = self
|
||||
.request(reqwest::Method::PUT, path)
|
||||
.header("content-type", "application/octet-stream")
|
||||
.body(data)
|
||||
.send()
|
||||
.await?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("HTTP {}: {}", status, body);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a `GET` request and return the response as raw bytes.
|
||||
///
|
||||
/// Used to download state file data. The response is returned as an
|
||||
/// owned `Vec<u8>` without any deserialization.
|
||||
///
|
||||
/// Returns `Err` on non-2xx status codes.
|
||||
async fn get_bytes(&self, path: &str) -> Result<Vec<u8>> {
|
||||
let resp = self.request(reqwest::Method::GET, path).send().await?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
anyhow::bail!("HTTP {}: {}", status, body);
|
||||
}
|
||||
Ok(resp.bytes().await?.to_vec())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Entry point
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Application entry point.
|
||||
///
|
||||
/// Parses CLI arguments via clap, constructs an [`ApiClient`] from the
|
||||
/// global `--server` and `--token` options, then dispatches to the
|
||||
/// appropriate handler based on the selected subcommand.
|
||||
///
|
||||
/// All subcommand handlers follow the same pattern:
|
||||
/// 1. Build the API path from the subcommand arguments.
|
||||
/// 2. Call the matching [`ApiClient`] method (`get_json`, `post_json`,
|
||||
/// `delete`, `get_bytes`, or `put_bytes`).
|
||||
/// 3. Pretty-print the JSON response to stdout (or write raw bytes for
|
||||
/// state file downloads).
|
||||
///
|
||||
/// Errors from the HTTP layer or JSON serialization are propagated via
|
||||
/// `anyhow` and printed to stderr by the tokio runtime.
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
let api = ApiClient::new(cli.server, cli.token);
|
||||
|
||||
match cli.command {
|
||||
Commands::Health => {
|
||||
let resp = api.get_json("/health").await?;
|
||||
println!("{}", serde_json::to_string_pretty(&resp)?);
|
||||
}
|
||||
Commands::Account { action } => match action {
|
||||
AccountAction::Create { name } => {
|
||||
let resp = api
|
||||
.post_json("/accounts", &serde_json::json!({ "name": name }))
|
||||
.await?;
|
||||
println!("{}", serde_json::to_string_pretty(&resp)?);
|
||||
}
|
||||
AccountAction::List => {
|
||||
let resp = api.get_json("/accounts").await?;
|
||||
println!("{}", serde_json::to_string_pretty(&resp)?);
|
||||
}
|
||||
AccountAction::Get { id } => {
|
||||
let resp = api.get_json(&format!("/accounts/{}", id)).await?;
|
||||
println!("{}", serde_json::to_string_pretty(&resp)?);
|
||||
}
|
||||
},
|
||||
Commands::Agent { action } => match action {
|
||||
AgentAction::List => {
|
||||
let resp = api.get_json("/agents").await?;
|
||||
println!("{}", serde_json::to_string_pretty(&resp)?);
|
||||
}
|
||||
AgentAction::Get { id } => {
|
||||
let resp = api.get_json(&format!("/agents/{}", id)).await?;
|
||||
println!("{}", serde_json::to_string_pretty(&resp)?);
|
||||
}
|
||||
},
|
||||
Commands::Project { action } => match action {
|
||||
ProjectAction::Create {
|
||||
account_id,
|
||||
repo_id,
|
||||
name,
|
||||
} => {
|
||||
let resp = api
|
||||
.post_json(
|
||||
"/projects",
|
||||
&serde_json::json!({
|
||||
"accountId": account_id,
|
||||
"repoId": repo_id,
|
||||
"name": name,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
println!("{}", serde_json::to_string_pretty(&resp)?);
|
||||
}
|
||||
ProjectAction::List => {
|
||||
let resp = api.get_json("/projects").await?;
|
||||
println!("{}", serde_json::to_string_pretty(&resp)?);
|
||||
}
|
||||
ProjectAction::Get { id } => {
|
||||
let resp = api.get_json(&format!("/projects/{}", id)).await?;
|
||||
println!("{}", serde_json::to_string_pretty(&resp)?);
|
||||
}
|
||||
ProjectAction::Enable { id } => {
|
||||
let resp = api
|
||||
.post_json(
|
||||
&format!("/projects/{}", id),
|
||||
&serde_json::json!({ "enabled": true }),
|
||||
)
|
||||
.await?;
|
||||
println!("{}", serde_json::to_string_pretty(&resp)?);
|
||||
}
|
||||
ProjectAction::Disable { id } => {
|
||||
let resp = api
|
||||
.post_json(
|
||||
&format!("/projects/{}", id),
|
||||
&serde_json::json!({ "enabled": false }),
|
||||
)
|
||||
.await?;
|
||||
println!("{}", serde_json::to_string_pretty(&resp)?);
|
||||
}
|
||||
},
|
||||
Commands::Job { action } => match action {
|
||||
JobAction::List { project_id, page } => {
|
||||
let resp = api
|
||||
.get_json(&format!("/projects/{}/jobs?page={}", project_id, page))
|
||||
.await?;
|
||||
println!("{}", serde_json::to_string_pretty(&resp)?);
|
||||
}
|
||||
JobAction::Get { id } => {
|
||||
let resp = api.get_json(&format!("/jobs/{}", id)).await?;
|
||||
println!("{}", serde_json::to_string_pretty(&resp)?);
|
||||
}
|
||||
JobAction::Rerun { id } => {
|
||||
let resp = api
|
||||
.post_json(&format!("/jobs/{}/rerun", id), &serde_json::json!({}))
|
||||
.await?;
|
||||
println!("{}", serde_json::to_string_pretty(&resp)?);
|
||||
}
|
||||
JobAction::Cancel { id } => {
|
||||
let resp = api
|
||||
.post_json(&format!("/jobs/{}/cancel", id), &serde_json::json!({}))
|
||||
.await?;
|
||||
println!("{}", serde_json::to_string_pretty(&resp)?);
|
||||
}
|
||||
},
|
||||
Commands::State { action } => match action {
|
||||
StateAction::List { project_id } => {
|
||||
let resp = api
|
||||
.get_json(&format!("/projects/{}/states", project_id))
|
||||
.await?;
|
||||
println!("{}", serde_json::to_string_pretty(&resp)?);
|
||||
}
|
||||
StateAction::Get {
|
||||
project_id,
|
||||
name,
|
||||
output,
|
||||
} => {
|
||||
let data = api
|
||||
.get_bytes(&format!(
|
||||
"/projects/{}/state/{}/data",
|
||||
project_id, name
|
||||
))
|
||||
.await?;
|
||||
if let Some(path) = output {
|
||||
tokio::fs::write(&path, &data).await?;
|
||||
println!("Written {} bytes to {}", data.len(), path);
|
||||
} else {
|
||||
std::io::stdout().write_all(&data)?;
|
||||
}
|
||||
}
|
||||
StateAction::Put {
|
||||
project_id,
|
||||
name,
|
||||
input,
|
||||
} => {
|
||||
let data = tokio::fs::read(&input).await?;
|
||||
api.put_bytes(
|
||||
&format!("/projects/{}/state/{}/data", project_id, name),
|
||||
data,
|
||||
)
|
||||
.await?;
|
||||
println!("State '{}' updated", name);
|
||||
}
|
||||
},
|
||||
Commands::Token { action } => match action {
|
||||
TokenAction::Create { account_id, name } => {
|
||||
let resp = api
|
||||
.post_json(
|
||||
&format!("/accounts/{}/clusterJoinTokens", account_id),
|
||||
&serde_json::json!({ "name": name }),
|
||||
)
|
||||
.await?;
|
||||
println!("{}", serde_json::to_string_pretty(&resp)?);
|
||||
}
|
||||
TokenAction::List { account_id } => {
|
||||
let resp = api
|
||||
.get_json(&format!("/accounts/{}/clusterJoinTokens", account_id))
|
||||
.await?;
|
||||
println!("{}", serde_json::to_string_pretty(&resp)?);
|
||||
}
|
||||
TokenAction::Revoke { id } => {
|
||||
api.delete(&format!("/cluster-join-tokens/{}", id)).await?;
|
||||
println!("Token revoked");
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue