Skip to content
Open
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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,27 @@ echo "export STAGING_DATABASE_URL='postgresql://...'" >> ~/.zshrc

`pgterm list` shows every profile and whether its variable is currently set.

### Databases behind an SSH jump host

If a database only answers from inside a bastion or private network, tell the
profile how to get there:

```bash
pgterm add production --env PROD_DATABASE_URL --ssh deploy@bastion.example.com
```

`--ssh` takes `[user@]host[:port]` or a bare `~/.ssh/config` alias — your own
ssh resolves it, so `HostName`, `IdentityFile`, `ProxyJump`, the agent and
`known_hosts` all behave exactly as they do when you type `ssh bastion`
yourself. The DSN keeps naming the **real** database host: no local port is
opened, `sslmode=verify-full` still verifies that hostname, and `.pgpass`
still matches on it.

Health checks hand the spec to pgbot, which tunnels natively. The SQL and
Data tabs ride an `ssh -W` child process in BatchMode — it never prompts, so
authenticate once (`ssh bastion`) or load your key into the agent first; a
refused login shows ssh's own reason in the tab.

### Adding from inside the UI

Press `a` (or click `+ Add database`). Name, then Stage (`←`/`→` cycles
Expand Down Expand Up @@ -348,6 +369,7 @@ bell = false # ring the terminal bell with a toast
name = "production"
env = "PROD_DATABASE_URL"
stage = "prod" # prod | staging | dev | local — inferred when absent
ssh = "deploy@bastion" # optional: reach it through this SSH jump host
```

When a database you are *not* looking at turns critical or unavailable, a
Expand Down
28 changes: 20 additions & 8 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ impl DbState {
stage: None,
pgrun_project: None,
writes: false,
ssh: None,
});
db.source = ConnSource::Session(url);
db
Expand Down Expand Up @@ -985,7 +986,7 @@ impl App {
}
};
if let Err(e) = cfg
.add_with_stage(name, env_name, stage)
.add_with_stage(name, env_name, stage, None)
.and_then(|()| cfg.save())
{
popup.message = Some(Err(SafeError::new(
Expand Down Expand Up @@ -1031,7 +1032,7 @@ impl App {
ConnSource::Session(_) => unreachable!("handled above"),
};
if let Err(e) = cfg
.add_with_stage(name, &env_name, stage)
.add_with_stage(name, &env_name, stage, None)
.and_then(|()| cfg.save())
{
popup.message = Some(Err(SafeError::new(
Expand All @@ -1049,6 +1050,7 @@ impl App {
stage,
pgrun_project: None,
writes: false,
ssh: None,
}));
let idx = self.dbs.len() - 1;
self.selected = idx;
Expand Down Expand Up @@ -1752,14 +1754,15 @@ fn parse_export_assignment(s: &str) -> Option<(String, String)> {
pub async fn run_effect(
pgbot_bin: PathBuf,
source: ConnSource,
ssh: Option<String>,
db: usize,
cmd: PgbotCommand,
kind: CmdKind,
sem: Arc<Semaphore>,
) -> Action {
let _permit = sem.acquire_owned().await.ok();
let timeout = runner::default_timeout(&cmd);
let result = runner::run_pgbot(&pgbot_bin, &source, &cmd, timeout)
let result = runner::run_pgbot(&pgbot_bin, &source, ssh.as_deref(), &cmd, timeout)
.await
.and_then(|out| decode_result(&cmd, &out));
Action::CheckFinished { db, kind, result }
Expand All @@ -1779,14 +1782,15 @@ impl Connections {
&mut self,
db: usize,
source: &ConnSource,
ssh: Option<&str>,
) -> Result<&mut tokio_postgres::Client, SafeError> {
// A closed connection is indistinguishable from a working one until
// it is used, so drop it and reconnect rather than fail the query.
if self.0.get(&db).map(|c| c.is_closed()).unwrap_or(false) {
self.0.remove(&db);
}
if let std::collections::hash_map::Entry::Vacant(slot) = self.0.entry(db) {
slot.insert(crate::db::connect(source).await?);
slot.insert(crate::db::connect(source, ssh).await?);
}
Ok(self.0.get_mut(&db).expect("present or just inserted"))
}
Expand All @@ -1801,12 +1805,13 @@ pub async fn run_sql_effect(
conns: Arc<tokio::sync::Mutex<Connections>>,
db: usize,
source: ConnSource,
ssh: Option<String>,
target: SqlTarget,
sql: String,
policy: WritePolicy,
) -> Action {
let mut guard = conns.lock().await;
let result = match guard.get(db, &source).await {
let result = match guard.get(db, &source, ssh.as_deref()).await {
Ok(client) => crate::db::run_sql(client, &sql, policy).await.map(Box::new),
Err(e) => Err(e),
};
Expand Down Expand Up @@ -1844,9 +1849,16 @@ pub async fn run_probe(
) -> Action {
let _permit = sem.acquire_owned().await.ok();
let cmd = PgbotCommand::Probe;
let result = runner::run_pgbot(&pgbot_bin, &source, &cmd, runner::default_timeout(&cmd))
.await
.and_then(|out| decode_result(&cmd, &out));
// The add popup has no ssh field (yet); its probes always dial direct.
let result = runner::run_pgbot(
&pgbot_bin,
&source,
None,
&cmd,
runner::default_timeout(&cmd),
)
.await
.and_then(|out| decode_result(&cmd, &out));
Action::ProbeFinished {
name,
source,
Expand Down
54 changes: 48 additions & 6 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ pub struct AddOptions {
pub open: bool,
/// The environment badge to save with the profile; None = infer it.
pub stage: Option<Stage>,
/// SSH jump host the database is reached through; validated and saved
/// with the profile.
pub ssh: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
Expand All @@ -38,7 +41,8 @@ pub enum Invocation {
}

const USAGE: &str = "usage: pgterm [--interval <dur>] [--no-monitor]
pgterm add <name> [--env <ENV_NAME>] [--stage prod|staging|dev|local] [--open]
pgterm add <name> [--env <ENV_NAME>] [--stage prod|staging|dev|local]
[--ssh [user@]host[:port]] [--open]
pgterm list
pgterm remove <name>
pgterm --default-config";
Expand All @@ -53,13 +57,22 @@ pub fn parse_args(args: &[String]) -> Invocation {
let mut env = None;
let mut open = false;
let mut stage = None;
let mut ssh = None;
while let Some(a) = it.next() {
match a.as_str() {
"--env" => match it.next() {
Some(v) => env = Some(v.clone()),
None => return Invocation::Usage("--env needs a value".into()),
},
"--open" => open = true,
"--ssh" => match it.next() {
Some(v) => ssh = Some(v.clone()),
None => {
return Invocation::Usage(
"--ssh needs a jump host ([user@]host[:port])".into(),
)
}
},
"--stage" => match it.next().and_then(|v| Stage::parse(v)) {
Some(st) => stage = Some(st),
None => {
Expand All @@ -81,6 +94,7 @@ pub fn parse_args(args: &[String]) -> Invocation {
env,
open,
stage,
ssh,
}),
None => Invocation::Usage("add needs a database name".into()),
}
Expand Down Expand Up @@ -188,7 +202,10 @@ pub async fn cmd_add(opts: &AddOptions) -> i32 {
return EXIT_FAILED;
}
};
if let Err(e) = cfg.clone().add(&opts.name, &env_name) {
if let Err(e) =
cfg.clone()
.add_with_stage(&opts.name, &env_name, opts.stage, opts.ssh.as_deref())
{
eprintln!("pgterm: {e}");
eprintln!("Nothing was saved.");
return EXIT_FAILED;
Expand All @@ -203,10 +220,14 @@ pub async fn cmd_add(opts: &AddOptions) -> i32 {
println!("✓ Found {env_name}");
}

println!("Testing {}...\n", opts.name);
match &opts.ssh {
Some(spec) => println!("Testing {} (via ssh {spec})...\n", opts.name),
None => println!("Testing {}...\n", opts.name),
}
let probe = runner::run_pgbot(
&runner::pgbot_bin(),
&ConnSource::Env(env_name.clone()),
opts.ssh.as_deref(),
&PgbotCommand::Probe,
runner::default_timeout(&PgbotCommand::Probe),
)
Expand Down Expand Up @@ -250,7 +271,7 @@ pub async fn cmd_add(opts: &AddOptions) -> i32 {
}
}

if let Err(e) = cfg.add_with_stage(&opts.name, &env_name, opts.stage) {
if let Err(e) = cfg.add_with_stage(&opts.name, &env_name, opts.stage, opts.ssh.as_deref()) {
eprintln!("pgterm: {e}\nNothing was saved.");
return EXIT_FAILED;
}
Expand Down Expand Up @@ -361,7 +382,8 @@ mod tests {
name: "prod".into(),
env: None,
open: false,
stage: None
stage: None,
ssh: None
})
);
assert_eq!(
Expand All @@ -370,7 +392,8 @@ mod tests {
name: "prod".into(),
env: Some("PROD_URL".into()),
open: true,
stage: None
stage: None,
ssh: None
})
);
assert!(matches!(parse_args(&s(&["add"])), Invocation::Usage(_)));
Expand Down Expand Up @@ -455,6 +478,25 @@ mod tests {
));
}

#[test]
fn add_takes_an_ssh_jump_host() {
match parse_args(&s(&[
"add",
"prod",
"--env",
"P",
"--ssh",
"deploy@bastion",
])) {
Invocation::Add(o) => assert_eq!(o.ssh.as_deref(), Some("deploy@bastion")),
other => panic!("{other:?}"),
}
match parse_args(&s(&["add", "p", "--ssh"])) {
Invocation::Usage(msg) => assert!(msg.contains("jump host"), "{msg}"),
other => panic!("{other:?}"),
}
}

#[test]
fn default_config_flag_is_its_own_invocation() {
assert_eq!(
Expand Down
50 changes: 47 additions & 3 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ pointer = true
# stage = \"prod\" # prod | staging | dev | local \u{2014} badge; inferred from the name when absent
# pgrun_project = \"acme-api\" # show this pgrun project's branches for the database
# writes = false # true lets the SQL tab write (a PROD badge still asks first)
# ssh = \"user@bastion\" # reach the database through this SSH jump host
# # ([user@]host[:port] or a ~/.ssh/config alias)
";

/// One monitored database: a friendly name and the environment variable that
Expand All @@ -152,6 +154,11 @@ pub struct DatabaseProfile {
/// READ ONLY transaction until this is set.
#[serde(default, skip_serializing_if = "is_false")]
pub writes: bool,
/// SSH jump host to reach this database through: `[user@]host[:port]`, or
/// a bare `~/.ssh/config` alias. Resolved by the user's own ssh — pgterm
/// stores the spec, never keys or passphrases.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh: Option<String>,
}

fn is_false(b: &bool) -> bool {
Expand Down Expand Up @@ -257,17 +264,27 @@ impl TerminalConfig {
/// Validates and appends a profile. Names are what tabs display: short,
/// shell-friendly, unique.
pub fn add(&mut self, name: &str, env: &str) -> anyhow::Result<()> {
self.add_with_stage(name, env, None)
self.add_with_stage(name, env, None, None)
}

/// `add`, with the environment badge the caller chose (None = infer).
/// `add`, with the environment badge the caller chose (None = infer) and
/// the SSH jump host, if the database is only reachable through one.
pub fn add_with_stage(
&mut self,
name: &str,
env: &str,
stage: Option<Stage>,
ssh: Option<&str>,
) -> anyhow::Result<()> {
validate_name(name)?;
let ssh = match ssh {
None => None,
Some(spec) => match crate::ssh::Spec::parse(spec) {
// The original spelling is kept — it is what ssh will resolve.
Ok(_) => Some(spec.trim().to_string()),
Err(e) => bail!("--ssh: {e}"),
},
};
if env.is_empty() {
bail!("environment variable name is empty");
}
Expand All @@ -290,6 +307,7 @@ impl TerminalConfig {
stage,
pgrun_project: None,
writes: false,
ssh,
});
Ok(())
}
Expand Down Expand Up @@ -533,6 +551,7 @@ env = "STAGING_DATABASE_URL"
stage: Some(Stage::Dev),
pgrun_project: None,
writes: false,
ssh: None,
};
assert_eq!(p.badge(), Some(Stage::Dev), "explicit stage beats the name");
let p = DatabaseProfile {
Expand All @@ -541,14 +560,15 @@ env = "STAGING_DATABASE_URL"
stage: None,
pgrun_project: None,
writes: false,
ssh: None,
};
assert_eq!(p.badge(), Some(Stage::Prod));
}

#[test]
fn stage_and_ui_round_trip_and_old_files_still_load() {
let mut cfg = TerminalConfig::default();
cfg.add_with_stage("prod", "PROD_URL", Some(Stage::Prod))
cfg.add_with_stage("prod", "PROD_URL", Some(Stage::Prod), None)
.unwrap();
cfg.add("analytics", "AN_URL").unwrap();
cfg.ui.bell = true;
Expand All @@ -571,6 +591,30 @@ env = "STAGING_DATABASE_URL"
);
}

#[test]
fn ssh_round_trips_is_validated_and_old_files_load_without_it() {
let mut cfg = TerminalConfig::default();
cfg.add_with_stage("prod", "P_URL", None, Some("deploy@bastion:2222"))
.unwrap();
cfg.add("plain", "X_URL").unwrap();
let text = toml::to_string_pretty(&cfg).unwrap();
assert!(text.contains("ssh = \"deploy@bastion:2222\""), "{text}");
let back: TerminalConfig = toml::from_str(&text).unwrap();
assert_eq!(back, cfg);
assert_eq!(back.databases[1].ssh, None, "absent must stay absent");

// A spec that could read as an ssh option is refused at add time.
let err = cfg
.add_with_stage("evil", "E_URL", None, Some("-oProxyCommand=x"))
.unwrap_err()
.to_string();
assert!(err.contains("--ssh"), "{err}");

let old = "version = 1\n[[databases]]\nname = \"p\"\nenv = \"P_URL\"\n";
let cfg: TerminalConfig = toml::from_str(old).unwrap();
assert_eq!(cfg.databases[0].ssh, None);
}

#[test]
fn unknown_stage_is_an_error_naming_the_four() {
let bad =
Expand Down
Loading
Loading