diff --git a/README.md b/README.md index 0565de0..bc079f6 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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`. @@ -207,7 +207,7 @@ brainpod --pod resource replace --file brainpod --pod resource delete brainpod --pod resource variables [ ] [--revision | --at ] -brainpod --pod tunnel [] [--skip-preflight] +brainpod --pod tunnel [] [--port ] [--skip-preflight] brainpod --pod deploy [--summary ] [--wait] [--timeout ] brainpod --pod redeploy @@ -221,7 +221,13 @@ brainpod --pod events --watch --resource \ [--duration <1-20>] [--last-event-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 @@ -243,6 +249,21 @@ brainpod --pod events --watch --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`. diff --git a/proto/brainpod/tunnel/v1/broker.proto b/proto/brainpod/tunnel/v1/broker.proto index 5f12680..e333d22 100644 --- a/proto/brainpod/tunnel/v1/broker.proto +++ b/proto/brainpod/tunnel/v1/broker.proto @@ -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 { @@ -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 { @@ -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; diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index 4877aa4..2571379 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -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), @@ -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, + /// Remote port to reach; required when an app declares more than one port + #[arg(long)] + pub port: Option, /// Skip database credential preflight and password output #[arg(long)] pub skip_preflight: bool, diff --git a/src/describe.rs b/src/describe.rs index bb6796c..53cf418 100644 --- a/src/describe.rs +++ b/src/describe.rs @@ -36,14 +36,14 @@ pub fn generate(mut root: Command, path: &[String]) -> Result { "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.", @@ -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(), @@ -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![ diff --git a/src/main.rs b/src/main.rs index 630be05..69109ad 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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([ diff --git a/src/tunnel.rs b/src/tunnel.rs index b4ded11..3b54311 100644 --- a/src/tunnel.rs +++ b/src/tunnel.rs @@ -25,7 +25,7 @@ mod pb { use pb::tunnel_broker_client::TunnelBrokerClient; use pb::tunnel_service_client::TunnelServiceClient; -use pb::{Chunk, CloseSessionRequest, DatabaseEngine, OpenSessionRequest}; +use pb::{Chunk, CloseSessionRequest, DatabaseEngine, OpenSessionRequest, TargetKind}; const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); const MAX_CHUNK_BYTES: usize = 64 * 1024; @@ -90,6 +90,51 @@ impl fmt::Display for RemoteTunnelError { impl std::error::Error for RemoteTunnelError {} +/// What a tunnel session points at. A session binds exactly one remote port, +/// so an app exposing several ports needs one session per port. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Target { + Database(DatabaseEngine), + App, +} + +impl Target { + fn decode(target_kind: i32, engine: i32) -> Result { + match TargetKind::try_from(target_kind) { + Ok(TargetKind::Database) => DatabaseEngine::try_from(engine) + .ok() + .filter(|engine| *engine != DatabaseEngine::Unspecified) + .map(Self::Database) + .ok_or_else(|| { + anyhow!("Brainpod tunnel broker returned an unknown database engine") + }), + Ok(TargetKind::App) => Ok(Self::App), + Ok(TargetKind::Unspecified) | Err(_) => Err(anyhow!( + "Brainpod tunnel broker returned an unknown target kind; update the Brainpod CLI" + )), + } + } + + /// Databases hand out a managed password; apps have no credentials. + const fn has_credentials(self) -> bool { + matches!(self, Self::Database(_)) + } + + const fn kind_name(self) -> &'static str { + match self { + Self::Database(_) => "database", + Self::App => "app", + } + } + + const fn engine_name(self) -> Option<&'static str> { + match self { + Self::Database(engine) => Some(engine_name(engine)), + Self::App => None, + } + } +} + pub async fn handle( client: &Client, pod: &str, @@ -98,7 +143,7 @@ pub async fn handle( api_token: &str, json_output: bool, ) -> Result { - let database_id = client + let resource_id = client .resolve_resource(pod, &args.resource) .await? .uuid @@ -110,8 +155,9 @@ pub async fn handle( .context("failed to connect to the Brainpod tunnel broker")?; let mut broker = TunnelBrokerClient::new(channel); let mut open_request = Request::new(OpenSessionRequest { - database_id: database_id.to_string(), + resource_id: resource_id.to_string(), request_id: Uuid::new_v4().to_string(), + port: u32::from(args.port.unwrap_or_default()), }); open_request .metadata_mut() @@ -121,19 +167,21 @@ pub async fn handle( .await .map_err(|status| RemoteTunnelError::status("failed to create tunnel session", status))? .into_inner(); - let engine = DatabaseEngine::try_from(session.engine) + let target = Target::decode(session.target_kind, session.engine)?; + let remote_port = u16::try_from(session.port) .ok() - .filter(|engine| *engine != DatabaseEngine::Unspecified) - .ok_or_else(|| anyhow!("Brainpod tunnel broker returned an unknown database engine"))?; + .filter(|port| *port != 0) + .ok_or_else(|| anyhow!("Brainpod tunnel broker returned an invalid remote port"))?; let listen_address = args .listen_address - .unwrap_or_else(|| SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), default_port(engine))); + .unwrap_or_else(|| SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), remote_port)); let proxy_result = run_proxy( listen_address, &session.endpoint, &session.ticket, - engine, + target, + remote_port, json_output, args.skip_preflight, ) @@ -160,7 +208,8 @@ pub async fn handle( json!({ "event": "closed", "address": bound_address, - "engine": engine_name(engine), + "targetKind": target.kind_name(), + "engine": target.engine_name(), }), View::Tunnel, )) @@ -170,7 +219,8 @@ async fn run_proxy( listen_address: SocketAddr, endpoint: &str, ticket: &str, - engine: DatabaseEngine, + target: Target, + remote_port: u16, json_output: bool, skip_preflight: bool, ) -> Result { @@ -185,7 +235,7 @@ async fn run_proxy( .context("failed to connect to the Brainpod tunnel service")?; let client = TunnelServiceClient::new(channel); let ticket = ticket.to_owned(); - let password = if skip_preflight { + let password = if skip_preflight || !target.has_credentials() { None } else { let mut connection = open_remote(client.clone(), ticket.clone(), true).await?; @@ -196,7 +246,13 @@ async fn run_proxy( drop(connection); Some(password) }; - announce(listen_address, engine, password.as_deref(), json_output)?; + announce( + listen_address, + target, + remote_port, + password.as_deref(), + json_output, + )?; let mut connections = JoinSet::new(); let shutdown = tokio::signal::ctrl_c(); tokio::pin!(shutdown); @@ -364,11 +420,12 @@ async fn connect_channel(endpoint: &str) -> Result { fn announce( address: SocketAddr, - engine: DatabaseEngine, + target: Target, + remote_port: u16, password: Option<&str>, json_output: bool, ) -> Result<()> { - let details = connection_details(address, engine, password); + let details = connection_details(address, target, password); let stdout = io::stdout(); let mut stdout = stdout.lock(); if json_output { @@ -380,8 +437,9 @@ fn announce( "address": address, "host": address.ip(), "localPort": address.port(), - "remotePort": details.remote_port, - "engine": engine_name(engine), + "remotePort": remote_port, + "targetKind": target.kind_name(), + "engine": target.engine_name(), "clientCommand": details.client_command, }))? ) @@ -403,20 +461,19 @@ fn announce( } else { let color = stdout.is_terminal(); let title = style("◆ Brainpod tunnel", "1;35", color); - let engine = style(details.display_name, "1;36", color); + let display_name = style(details.display_name, "1;36", color); let ready = style("● Ready", "1;32", color); let label = |value: &str| style(&format!("{value:<10}"), "2", color); writeln!(stdout, "╭─ {title}")?; writeln!(stdout, "│")?; - writeln!(stdout, "│ {engine}")?; + writeln!(stdout, "│ {display_name}")?; writeln!(stdout, "│ {} {address}", label("Local"))?; writeln!( stdout, - "│ {} {}:{}", + "│ {} {}:{remote_port}", label("Remote"), - details.display_name, - details.remote_port + details.display_name )?; if details.username.is_some() || details.database.is_some() || password.is_some() { writeln!(stdout, "│")?; @@ -511,7 +568,6 @@ fn style(value: &str, code: &str, enabled: bool) -> String { struct ConnectionDetails { display_name: &'static str, - remote_port: u16, username: Option<&'static str>, database: Option<&'static str>, client_command: String, @@ -520,17 +576,28 @@ struct ConnectionDetails { fn connection_details( address: SocketAddr, - engine: DatabaseEngine, + target: Target, password: Option<&str>, ) -> ConnectionDetails { let host = address.ip(); let dsn_host = dsn_host(host); let port = address.port(); let password = password.map(percent_encode); + let engine = match target { + Target::App => { + return ConnectionDetails { + display_name: "App", + username: None, + database: None, + client_command: format!("curl http://{dsn_host}:{port}/"), + dsn: None, + }; + } + Target::Database(engine) => engine, + }; match engine { DatabaseEngine::Postgres => ConnectionDetails { display_name: "PostgreSQL", - remote_port: 5432, username: Some("brainpod"), database: Some("brainpod"), client_command: format!( @@ -544,7 +611,6 @@ fn connection_details( }, DatabaseEngine::Mariadb => ConnectionDetails { display_name: "MariaDB", - remote_port: 3306, username: Some("brainpod"), database: Some("brainpod"), client_command: format!( @@ -556,7 +622,6 @@ fn connection_details( }, DatabaseEngine::Valkey => ConnectionDetails { display_name: "Valkey", - remote_port: 6379, username: None, database: None, client_command: format!("valkey-cli --tls --insecure -h {host} -p {port}"), @@ -565,7 +630,6 @@ fn connection_details( }, DatabaseEngine::Mssql => ConnectionDetails { display_name: "Microsoft SQL Server", - remote_port: 1433, username: Some("brainpod"), database: Some("brainpod"), client_command: format!( @@ -579,7 +643,6 @@ fn connection_details( }, DatabaseEngine::Unspecified => ConnectionDetails { display_name: "Database", - remote_port: 0, username: None, database: None, client_command: address.to_string(), @@ -613,16 +676,6 @@ fn bearer_value(token: &str) -> Result> { .context("API token contains invalid header characters") } -const fn default_port(engine: DatabaseEngine) -> u16 { - match engine { - DatabaseEngine::Postgres => 5432, - DatabaseEngine::Mariadb => 3306, - DatabaseEngine::Valkey => 6379, - DatabaseEngine::Mssql => 1433, - DatabaseEngine::Unspecified => 0, - } -} - const fn engine_name(engine: DatabaseEngine) -> &'static str { match engine { DatabaseEngine::Postgres => "postgres", @@ -637,18 +690,18 @@ const fn engine_name(engine: DatabaseEngine) -> &'static str { mod tests { use std::net::SocketAddr; - use super::{DatabaseEngine, RemoteTunnelError, connection_details}; + use super::{DatabaseEngine, RemoteTunnelError, Target, TargetKind, connection_details}; #[test] fn formats_remote_status_without_metadata() { let error = RemoteTunnelError::status( "tunnel service rejected the connection", - tonic::Status::unavailable("database unavailable"), + tonic::Status::unavailable("tunnel target unavailable"), ); assert_eq!( error.to_string(), - "tunnel service rejected the connection: database unavailable (Unavailable)" + "tunnel service rejected the connection: tunnel target unavailable (Unavailable)" ); assert!(!error.ends_tunnel); } @@ -658,19 +711,64 @@ mod tests { for status in [ tonic::Status::unauthenticated("invalid tunnel credentials"), tonic::Status::deadline_exceeded("tunnel session expired"), - tonic::Status::failed_precondition("database credentials unavailable"), + tonic::Status::failed_precondition("resource credentials unavailable"), ] { assert!(RemoteTunnelError::status("connection failed", status).ends_tunnel); } } + #[test] + fn decodes_database_and_app_targets() { + assert_eq!( + Target::decode(TargetKind::Database as i32, DatabaseEngine::Postgres as i32).unwrap(), + Target::Database(DatabaseEngine::Postgres) + ); + assert_eq!( + Target::decode(TargetKind::App as i32, DatabaseEngine::Unspecified as i32).unwrap(), + Target::App + ); + } + + #[test] + fn rejects_a_database_target_without_an_engine() { + let error = Target::decode( + TargetKind::Database as i32, + DatabaseEngine::Unspecified as i32, + ) + .unwrap_err(); + + assert!(error.to_string().contains("unknown database engine")); + } + + #[test] + fn rejects_an_unknown_target_kind() { + let error = Target::decode(TargetKind::Unspecified as i32, 0).unwrap_err(); + + assert!(error.to_string().contains("unknown target kind")); + } + + #[test] + fn app_targets_have_no_credentials() { + assert!(!Target::App.has_credentials()); + assert_eq!(Target::App.kind_name(), "app"); + assert_eq!(Target::App.engine_name(), None); + assert!(Target::Database(DatabaseEngine::Postgres).has_credentials()); + assert_eq!( + Target::Database(DatabaseEngine::Postgres).engine_name(), + Some("postgres") + ); + } + #[test] fn builds_postgres_banner_details() { let address = "127.0.0.1:15432".parse::().unwrap(); - let details = connection_details(address, DatabaseEngine::Postgres, Some("p@ ss")); + let details = connection_details( + address, + Target::Database(DatabaseEngine::Postgres), + Some("p@ ss"), + ); assert_eq!(details.display_name, "PostgreSQL"); - assert_eq!(details.remote_port, 5432); assert_eq!(details.username, Some("brainpod")); assert_eq!(details.database, Some("brainpod")); assert_eq!( @@ -683,9 +781,28 @@ mod tests { #[test] fn omits_dsn_without_preflight_credentials() { let address = "127.0.0.1:16379".parse::().unwrap(); - let details = connection_details(address, DatabaseEngine::Valkey, None); + let details = connection_details(address, Target::Database(DatabaseEngine::Valkey), None); - assert_eq!(details.remote_port, 6379); assert!(details.dsn.is_none()); } + + #[test] + fn builds_app_banner_details_without_credentials() { + let address = "127.0.0.1:8080".parse::().unwrap(); + let details = connection_details(address, Target::App, None); + + assert_eq!(details.display_name, "App"); + assert_eq!(details.username, None); + assert_eq!(details.database, None); + assert_eq!(details.dsn, None); + assert_eq!(details.client_command, "curl http://127.0.0.1:8080/"); + } + + #[test] + fn brackets_ipv6_hosts_in_the_app_client_command() { + let address = "[::1]:8080".parse::().unwrap(); + let details = connection_details(address, Target::App, None); + + assert_eq!(details.client_command, "curl http://[::1]:8080/"); + } }