This commit is contained in:
atagen 2026-03-16 22:23:10 +11:00
commit fd80fbab7e
48 changed files with 16775 additions and 0 deletions

View 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 }

View 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(())
}