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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
800 changes: 560 additions & 240 deletions Cargo.lock

Large diffs are not rendered by default.

13 changes: 11 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,23 @@ clap = { version = "4.6.1", features = ["derive"] }
dirs = "6.0.0"
flate2 = "1.1.2"
getrandom = "0.3.4"
reqwest = { version = "0.13.4", default-features = false, features = ["charset", "http2", "json", "query", "rustls", "stream", "system-proxy"] }
prost = "0.13.5"
prost-types = "0.13.5"
reqwest = { version = "0.13.4", default-features = false, features = ["charset", "http2", "json", "query", "rustls-no-provider", "stream", "system-proxy"] }
rustls = { version = "0.23.40", default-features = false, features = ["ring", "std", "tls12"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150"
sha2 = "0.10.9"
tar = "0.4.44"
tempfile = "3.20.0"
tokio = { version = "1.52.3", features = ["fs", "io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "sync", "time"] }
tokio = { version = "1.52.3", features = ["fs", "io-std", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] }
tokio-stream = "0.1.17"
tokio-util = { version = "0.7.16", features = ["io"] }
toml = "0.9.5"
tonic = { version = "0.12.3", features = ["tls", "tls-webpki-roots"] }
uuid = { version = "1.16.0", features = ["v4"] }
webbrowser = "=1.0.6"

[build-dependencies]
protox = "0.7.1"
tonic-build = "0.12.3"
42 changes: 35 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

# Brainpod CLI

A non-interactive CLI for managing Brainpod pods, images, blueprints, revisions, resources, deployments, and events. Its default output is deterministic line-oriented text suitable for LLMs and shell tools. Add `--json` to receive machine-readable JSON; login and event watches use NDJSON.
A non-interactive CLI for managing Brainpod pods, images, blueprints, revisions, resources, deployments, database tunnels, and events. Its default output is deterministic line-oriented text suitable for LLMs and shell tools. Add `--json` to receive machine-readable JSON; login, database tunnels, and event watches use NDJSON.

The CLI builds application images locally from an existing Dockerfile or with Railpack, then pushes them directly to the selected pod's private Brainpod registry namespace. Image builds probe the API's cluster architectures, prefer amd64 and then arm64, and store the selected default architecture in the configuration. Use `--platform linux/arm64` for a one-off override.

Expand Down Expand Up @@ -95,6 +95,7 @@ The default configuration file is `~/.config/brainpod/config.toml`. `XDG_CONFIG_
brainpod config set api-token brain_example
brainpod config set pod my-pod
brainpod config set endpoint https://api.brainpod.io
brainpod config set control-plane-endpoint https://control-plane.brainpod.io
brainpod config set registry-endpoint https://registry.brainpod.io
brainpod config set architecture arm64
brainpod config show
Expand All @@ -105,6 +106,7 @@ Configuration uses TOML:

```toml
endpoint = "https://api.brainpod.io"
control_plane_endpoint = "https://control-plane.brainpod.io"
registry_endpoint = "https://registry.brainpod.io"
api_token = "brain_example"
pod = "my-pod"
Expand All @@ -113,10 +115,10 @@ architecture = "amd64"

Values are resolved in this order:

1. Global flags: `--endpoint`, `--registry-endpoint`, `--api-token`, `--pod`
2. `BRAINPOD_API_ENDPOINT`, `BRAINPOD_REGISTRY_ENDPOINT`, `BRAINPOD_API_TOKEN`, `BRAINPOD_POD`
1. Global flags: `--endpoint`, `--control-plane-endpoint`, `--registry-endpoint`, `--api-token`, `--pod`
2. `BRAINPOD_API_ENDPOINT`, `BRAINPOD_CONTROL_PLANE_ENDPOINT`, `BRAINPOD_REGISTRY_ENDPOINT`, `BRAINPOD_API_TOKEN`, `BRAINPOD_POD`
3. The configuration file
4. The defaults `https://api.brainpod.io` and `https://registry.brainpod.io`
4. The defaults `https://api.brainpod.io`, `https://control-plane.brainpod.io`, and `https://registry.brainpod.io`

For image builds, `--platform` overrides the configured architecture. Without it, the CLI probes the available clusters, prefers `amd64` and then `arm64`, and stores the selected architecture in the configuration.

Expand Down Expand Up @@ -196,19 +198,45 @@ brainpod --pod <pod> resource replace <kind> <name> --file <path|->
brainpod --pod <pod> resource delete <kind> <name>
brainpod --pod <pod> resource variables [<kind> <name>] [--revision <uuid> | --at <timestamp>]

brainpod --pod <pod> tunnel <database-resource> [<listen-address>] [--skip-preflight]

brainpod --pod <pod> deploy [--summary <text>] [--wait] [--timeout <seconds>]
brainpod --pod <pod> redeploy

brainpod --pod <pod> events --resource <urn> [--kind <app|http-access|platform>] \
brainpod --pod <pod> events --resource <resource> [--kind <app|http-access|platform>] \
[--level <trace|debug|info|warn|error>] [--search <text>] \
[--range <5m|15m|30m|1h|24h|7d>] [--cursor <cursor>]
brainpod --pod <pod> events --watch --resource <urn> \
brainpod --pod <pod> events --watch --resource <resource> \
[--kind <app|http-access|platform>] [--level <trace|debug|info|warn|error>] \
[--search <text>] [--range <5m|15m|30m|1h|24h|7d>] [--cursor <cursor>] \
[--duration <1-20>] [--last-event-id <id>]
```

Events use the resource URN returned by resource list, get, or mutation responses, such as `urn:brain:app:default:api`. Omit `--kind` to return every stream available for that resource. `--level` requires `--kind app`.
`brainpod tunnel` creates a two-hour database tunnel session and forwards local TCP connections until Ctrl-C is pressed. Select the pod with `--pod`, `BRAINPOD_POD`, or the configured default, then identify a deployed PostgreSQL, MariaDB, Valkey, or Microsoft SQL Server resource by name, URN, or stable UUID. For example, `brainpod --pod my-pod tunnel db` resolves `db` through the API before opening the tunnel. The listener defaults to `127.0.0.1` and the engine's standard port; pass an explicit address such as `127.0.0.1:15432` to override it. By default, the command prints an engine-specific banner with the local-to-remote port mapping, credentials, client command, and DSN before it starts accepting connections, so GUI clients such as DBeaver can be configured first. Pass `--skip-preflight` to skip credential retrieval and omit the password and DSN. The API token must grant `resources:read` and `database:connect` for the database or its pod.

```text
╭─ ◆ Brainpod tunnel
│ PostgreSQL
│ Local 127.0.0.1:15432
│ Remote PostgreSQL:5432
│ Username brainpod
│ Database brainpod
│ Password ...
├─ Client
│ psql "host=127.0.0.1 port=15432 user=brainpod dbname=brainpod sslmode=require"
├─ DSN
│ postgres://brainpod:...@127.0.0.1:15432/brainpod?sslmode=require
╰─ ● Ready · press Ctrl+C to stop
```

New, closed, and failed local connections are reported as concise status lines while the tunnel is running.

Events accept a resource name, URN, or stable UUID. For example, `brainpod --pod my-pod events --resource api` resolves `api` to its canonical URN before querying events. Passing a URN directly skips resolution, so an API token with only `events:read` remains sufficient; resolving a name or UUID also requires `resources:read`. Omit `--kind` to return every stream available for the resource. `--level` requires `--kind app`.

Event watches flush text or JSON output as messages arrive and reconnect after each server-imposed stream duration, continuing until interrupted. The per-request duration defaults to 10 seconds. Reconnects use the latest SSE event ID to avoid replaying emitted events. Use `--last-event-id` to set the initial event ID; `--cursor` resumes the initial request from an API event cursor.

Expand Down
16 changes: 16 additions & 0 deletions build.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
let descriptors = protox::compile(
[
"proto/brainpod/tunnel/v1/broker.proto",
"proto/brainpod/tunnel/v1/tunnel.proto",
],
["proto"],
)?;

tonic_build::configure()
.build_server(false)
.build_transport(false)
.compile_fds(descriptors)?;

Ok(())
}
3 changes: 3 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,9 @@
in
relative == "Cargo.toml"
|| relative == "Cargo.lock"
|| relative == "build.rs"
|| relative == "proto"
|| pkgs.lib.hasPrefix "proto/" relative
|| relative == "src"
|| pkgs.lib.hasPrefix "src/" relative;
};
Expand Down
44 changes: 44 additions & 0 deletions proto/brainpod/tunnel/v1/broker.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
syntax = "proto3";

package brainpod.tunnel.v1;

import "google/protobuf/timestamp.proto";

// Client-facing control-plane API. Calls are authenticated with the client's
// control-plane credentials, not with a tunnel ticket.
service TunnelBroker {
rpc OpenSession(OpenSessionRequest) returns (OpenSessionResponse);
// Prevents new connections. Existing connections keep their fixed deadline.
rpc CloseSession(CloseSessionRequest) returns (CloseSessionResponse);
}

message OpenSessionRequest {
string database_id = 1;
// Client-generated UUID used to make retries idempotent for these credentials.
string request_id = 2;
}

message OpenSessionResponse {
// URI of the endpoint implementing TunnelService.
string endpoint = 1;
// Bearer capability reusable for connections until the session expires.
string ticket = 2;
string session_id = 3;
// Fixed deadline. Existing connections are closed when it is reached.
google.protobuf.Timestamp expires_at = 4;
DatabaseEngine engine = 5;
}

message CloseSessionRequest {
string session_id = 1;
}

message CloseSessionResponse {}

enum DatabaseEngine {
DATABASE_ENGINE_UNSPECIFIED = 0;
DATABASE_ENGINE_POSTGRES = 1;
DATABASE_ENGINE_MARIADB = 2;
DATABASE_ENGINE_VALKEY = 3;
DATABASE_ENGINE_MSSQL = 4;
}
14 changes: 14 additions & 0 deletions proto/brainpod/tunnel/v1/tunnel.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
syntax = "proto3";

package brainpod.tunnel.v1;

// Relays one TCP connection. The client presents its opaque ticket using
// `authorization: Bearer <ticket>` gRPC metadata for broker authorization.
service TunnelService {
rpc Open(stream Chunk) returns (stream Chunk);
}

message Chunk {
// Chunks must contain between 1 byte and 64 KiB.
bytes data = 1;
}
29 changes: 22 additions & 7 deletions src/agent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,10 @@ fn chat_id() -> Option<String> {

fn slug(chat: &str) -> String {
let digest = Sha256::digest(chat.as_bytes());
digest[..6].iter().map(|byte| format!("{byte:02x}")).collect()
digest[..6]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}

#[derive(Debug, Args)]
Expand Down Expand Up @@ -1016,7 +1019,9 @@ fn locate(root: &Path, chat: Option<&str>, ancestry: &[Owner]) -> Result<PathBuf
}
}

let mut live = found.iter().filter(|(_, session)| session.state == "running");
let mut live = found
.iter()
.filter(|(_, session)| session.state == "running");
match (live.next(), live.next()) {
(Some((path, _)), None) => return Ok(path.clone()),
(Some(_), Some(_)) => {
Expand Down Expand Up @@ -1166,8 +1171,8 @@ pub fn render_clear(value: &Value) -> Vec<String> {
#[cfg(test)]
mod tests {
use super::{
covers_console, locate, pod_url, slug, Owner, Session, Step, DIRECTORY, LOG_FILE, SCHEMA,
SESSION_FILE,
DIRECTORY, LOG_FILE, Owner, SCHEMA, SESSION_FILE, Session, Step, covers_console, locate,
pod_url, slug,
};
use std::fs;
use std::path::Path;
Expand Down Expand Up @@ -1224,7 +1229,9 @@ mod tests {
plant(root.path(), &slug("chat-a"), "running", &[owner(7)], 1);
plant(root.path(), &slug("chat-b"), "running", &[owner(7)], 2);

let error = locate(root.path(), None, &[owner(7)]).unwrap_err().to_string();
let error = locate(root.path(), None, &[owner(7)])
.unwrap_err()
.to_string();
assert!(error.contains("--session"), "{error}");
}

Expand All @@ -1243,7 +1250,11 @@ mod tests {
let root = TempDir::new().unwrap();
let flat = root.path().join(DIRECTORY);
fs::create_dir_all(&flat).unwrap();
fs::write(flat.join(SESSION_FILE), b"{\"schema\":2,\"session\":\"old\"}").unwrap();
fs::write(
flat.join(SESSION_FILE),
b"{\"schema\":2,\"session\":\"old\"}",
)
.unwrap();
fs::write(flat.join(LOG_FILE), b"").unwrap();

assert_eq!(locate(root.path(), Some("chat-a"), &[]).unwrap(), flat);
Expand Down Expand Up @@ -1348,7 +1359,11 @@ mod tests {
}

fn states(session: &Session) -> Vec<&str> {
session.steps.iter().map(|step| step.state.as_str()).collect()
session
.steps
.iter()
.map(|step| step.state.as_str())
.collect()
}

#[test]
Expand Down
3 changes: 2 additions & 1 deletion src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -407,7 +407,8 @@ fn escape(text: &str) -> String {
/// advertises, so the session file routinely outlives the server that wrote it.
async fn handover() -> Option<String> {
let url = crate::agent::console_url()?;
let http = reqwest::Client::builder()
let http = crate::http_client_builder()
.ok()?
.timeout(HANDOVER_PROBE)
.build()
.ok()?;
Expand Down
20 changes: 19 additions & 1 deletion src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use std::fmt;
use anyhow::{Context, Result, anyhow};
use reqwest::header::{ACCEPT, CONTENT_TYPE, HeaderValue};
use reqwest::{Method, Response, StatusCode, Url};
use serde::Deserialize;
use serde_json::Value;

#[derive(Clone)]
Expand Down Expand Up @@ -32,6 +33,12 @@ pub struct EventStreamMessage {
pub data: String,
}

#[derive(Deserialize)]
pub struct ResourceIdentity {
pub urn: String,
pub uuid: String,
}

impl Client {
pub fn try_new(endpoint: &str, api_token: &str) -> Result<Self> {
if api_token.trim().is_empty() {
Expand All @@ -43,7 +50,7 @@ impl Client {
let authorization = reqwest::header::HeaderValue::from_str(&format!("Bearer {api_token}"))
.context("API token contains invalid header characters")?;
headers.insert(reqwest::header::AUTHORIZATION, authorization);
let http = reqwest::Client::builder()
let http = crate::http_client_builder()?
.default_headers(headers)
.build()
.context("failed to create HTTP client")?;
Expand All @@ -55,6 +62,17 @@ impl Client {
self.request(Method::GET, path, query, None).await
}

pub async fn resolve_resource(&self, pod: &str, identifier: &str) -> Result<ResourceIdentity> {
let value = self
.get(
&["v1", "pods", pod, "resources", "resolve", identifier],
&[],
)
.await
.with_context(|| format!("failed to resolve resource `{identifier}` in pod `{pod}`"))?;
serde_json::from_value(value).context("Brainpod API returned an invalid resource identity")
}

pub async fn post(
&self,
path: &[&str],
Expand Down
Loading
Loading