Skip to content
Draft
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
29 changes: 25 additions & 4 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, 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.
A non-interactive CLI for managing Brainpod pods, images, blueprints, revisions, resources, deployments, 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, 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 @@ -54,7 +54,7 @@ Cross-compile the Windows binary from an x86_64 Linux Nix host with:
nix build .#packages.x86_64-linux.windows
```

The result is `result/bin/brainpod.exe`. API commands and database tunnels work natively on Windows. Railpack image builds are unavailable because Railpack does not publish a Windows binary; use a Dockerfile or run image builds under WSL2.
The result is `result/bin/brainpod.exe`. API commands and tunnels work natively on Windows. Railpack image builds are unavailable because Railpack does not publish a Windows binary; use a Dockerfile or run image builds under WSL2.

`nix develop` gives you the toolchain, `rustfmt`, and `rust-analyzer` for working on the CLI itself. `direnv` picks up the same shell through `.envrc`.

Expand Down Expand Up @@ -207,7 +207,7 @@ 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> tunnel <resource> [<listen-address>] [--port <port>] [--skip-preflight]

brainpod --pod <pod> deploy [--summary <text>] [--wait] [--timeout <seconds>]
brainpod --pod <pod> redeploy
Expand All @@ -221,7 +221,13 @@ brainpod --pod <pod> events --watch --resource <resource> \
[--duration <1-20>] [--last-event-id <id>]
```

`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.
`brainpod tunnel` creates a two-hour 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 the target by name, URN, or stable UUID: a deployed PostgreSQL, MariaDB, Valkey, or Microsoft SQL Server resource, or an app that declares ports. For example, `brainpod --pod my-pod tunnel db` resolves `db` through the API before opening the tunnel.

A session reaches exactly one remote port. Databases use their engine port and ignore `--port`. An app that declares a single port needs no `--port`; an app that declares several requires one, and the error lists the ports it exposes. To reach two ports of the same app, run two tunnels.

The listener defaults to `127.0.0.1` and the remote port, so `brainpod --pod my-pod tunnel web` on an app serving 8080 listens on `127.0.0.1:8080`. Pass an explicit address such as `127.0.0.1:15432` to override it.

Before accepting connections the command prints a banner with the local-to-remote port mapping and a client command. Database targets also preflight their managed credentials and print the username, database, password, and DSN, so GUI clients such as DBeaver can be configured; pass `--skip-preflight` to suppress that. Apps have no managed credentials, so nothing is fetched and no password is printed.

```text
╭─ ◆ Brainpod tunnel
Expand All @@ -243,6 +249,21 @@ brainpod --pod <pod> events --watch --resource <resource> \
╰─ ● Ready · press Ctrl+C to stop
```

An app tunnel prints the same frame without the credential block:

```text
╭─ ◆ Brainpod tunnel
│
│ App
│ Local 127.0.0.1:8080
│ Remote App:8080
│
├─ Client
│ curl http://127.0.0.1:8080/
│
╰─ ● 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`.
Expand Down
16 changes: 15 additions & 1 deletion proto/brainpod/tunnel/v1/broker.proto
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,13 @@ service TunnelBroker {
}

message OpenSessionRequest {
string database_id = 1;
// Stable UUID of the resource to tunnel to.
string resource_id = 1;
// Client-generated UUID used to make retries idempotent for these credentials.
string request_id = 2;
// Port to reach on the target. Required when an app declares more than one
// port. Ignored for databases, which expose a single engine port.
uint32 port = 3;
}

message OpenSessionResponse {
Expand All @@ -26,7 +30,11 @@ message OpenSessionResponse {
string session_id = 3;
// Fixed deadline. Existing connections are closed when it is reached.
google.protobuf.Timestamp expires_at = 4;
// Set only when target_kind is TARGET_KIND_DATABASE.
DatabaseEngine engine = 5;
TargetKind target_kind = 6;
// Remote port this session is bound to.
uint32 port = 7;
}

message CloseSessionRequest {
Expand All @@ -35,6 +43,12 @@ message CloseSessionRequest {

message CloseSessionResponse {}

enum TargetKind {
TARGET_KIND_UNSPECIFIED = 0;
TARGET_KIND_DATABASE = 1;
TARGET_KIND_APP = 2;
}

enum DatabaseEngine {
DATABASE_ENGINE_UNSPECIFIED = 0;
DATABASE_ENGINE_POSTGRES = 1;
Expand Down
9 changes: 6 additions & 3 deletions src/cmd/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ pub enum Command {
Image(ImageArgs),
/// Inspect pod revisions
Revision(RevisionArgs),
/// Create a local TCP tunnel to a deployed database
/// Create a local TCP tunnel to a deployed database or app
Tunnel(TunnelArgs),
/// Create and manage pod resources
Resource(ResourceArgs),
Expand Down Expand Up @@ -63,10 +63,13 @@ pub struct LoginArgs {

#[derive(Debug, Args)]
pub struct TunnelArgs {
/// Database resource name, URN, or stable UUID
/// Resource name, URN, or stable UUID: a database, or an app that declares ports
pub resource: String,
/// Local IP address and port; defaults to loopback and the database engine's standard port
/// Local IP address and port; defaults to loopback and the target's remote port
pub listen_address: Option<std::net::SocketAddr>,
/// Remote port to reach; required when an app declares more than one port
#[arg(long)]
pub port: Option<u16>,
/// Skip database credential preflight and password output
#[arg(long)]
pub skip_preflight: bool,
Expand Down
8 changes: 5 additions & 3 deletions src/describe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,14 @@ pub fn generate(mut root: Command, path: &[String]) -> Result<Value> {
"json": "Pass --json to emit the complete API response as one JSON value for non-streaming commands.",
"loginJson": "Login emits an authorize event followed by an authenticated event as NDJSON on stdout.",
"eventWatchJson": "Event watches emit one JSON value per line as NDJSON.",
"tunnelJson": "Tunnels emit listening, optional credentials, and closed events as NDJSON.",
"tunnelJson": "Tunnels emit listening, optional credentials, and closed events as NDJSON. The listening and closed events carry targetKind (database or app); engine is null for apps, and the credentials event is emitted for database targets only.",
"waitProgress": "Interactive waits report unhealthy-to-healthy transitions on stderr; progress is suppressed when stderr is redirected or --json is used.",
"errors": "Errors are written to stderr and return a non-zero exit code; --json also makes errors JSON."
},
"guidance": [
"Use --json for complete machine-readable API responses and errors; login, event watches, and tunnels are streamed as NDJSON.",
"Pod-scoped commands require --pod, BRAINPOD_POD, or a configured default pod.",
"Database tunnels resolve a resource name, URN, or stable UUID in the selected pod and listen on loopback by default until Ctrl-C is pressed.",
"Tunnels resolve a resource name, URN, or stable UUID in the selected pod and listen on loopback by default until Ctrl-C is pressed. The target is a database or an app that declares ports; a session reaches exactly one remote port, so use --port to pick among an app's ports and run one tunnel per port.",
"Image builds prefer an existing Dockerfile, otherwise use Railpack, target the best architecture supported by the API (override with --platform), and push to the selected pod's private registry namespace.",
"Blueprint installation and resource mutations update the mutable draft; run deploy separately when ready, optionally with --wait.",
"Use blueprint get to inspect blueprint documentation, defaults, and its input schema before installation.",
Expand Down Expand Up @@ -353,7 +353,8 @@ fn next_steps(path: &[&str]) -> Vec<&'static str> {
"Secret values are never returned; reference them instead of reading them.",
],
["tunnel"] => vec![
"Keep the tunnel running while the local database client is connected.",
"Keep the tunnel running while the local client is connected.",
"Open one tunnel per port when an app exposes several.",
"Press Ctrl-C to close the tunnel session.",
],
_ => Vec::new(),
Expand All @@ -378,6 +379,7 @@ fn examples(path: &[&str]) -> Vec<&'static str> {
["tunnel"] => vec![
"brainpod --pod my-pod tunnel db",
"brainpod --pod my-pod tunnel urn:brain:postgres:default:db 127.0.0.1:15432",
"brainpod --pod my-pod tunnel web --port 8080",
],
["blueprint", "get"] => vec!["brainpod blueprint get laravel"],
["blueprint", "install"] => vec![
Expand Down
17 changes: 17 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -399,11 +399,28 @@ mod tests {
};
assert_eq!(args.resource, "db");
assert_eq!(args.listen_address.unwrap().port(), 15432);
assert_eq!(args.port, None);
assert!(args.skip_preflight);
assert!(super::cmd::needs_client(&opts.command));
assert!(super::cmd::needs_api_token(&opts.command));
}

#[test]
fn parses_app_tunnel_with_an_explicit_remote_port() {
let opts = Opts::try_parse_from([
"brainpod", "--pod", "my-pod", "tunnel", "web", "--port", "9090",
])
.unwrap();

let Command::Tunnel(args) = &opts.command else {
panic!("expected tunnel command");
};
assert_eq!(args.resource, "web");
assert_eq!(args.listen_address, None);
assert_eq!(args.port, Some(9090));
assert!(!args.skip_preflight);
}

#[test]
fn parses_image_build() {
let opts = Opts::try_parse_from([
Expand Down
Loading
Loading