railwayapp 5.34.4

Interact with Railway via CLI
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
//! `railway postgres {pitr,ha,pgbouncer}` -- CLI parity for the three biggest
//! Postgres-plugin features (continuous backups/point-in-time recovery, high
//! availability clustering, and PgBouncer connection pooling). Nested under a
//! single `postgres` command (rather than three flat top-level commands) to
//! match how customers think about these features and mirror existing
//! nesting precedent (`railway service source connect/disconnect`, `railway
//! service files ...`).
//!
//! Every environment-config fetch in this module tree uses
//! `decryptVariables: true`: the non-decrypted config masks EVERY variable
//! value as null in production (confirmed live 2026-08-07), and the
//! enabled-state detection here depends on values -- `PATRONI_ENABLED ==
//! "true"`, a non-empty `WAL_ARCHIVE_BUCKET`, PgBouncer's pool knobs. The
//! caller's own access already gates decryption server-side.

use std::collections::BTreeMap;

use is_terminal::IsTerminal;
use serde::Serialize;

use crate::controllers::{config::EnvironmentConfig, postgres_plugins, project::ServiceContext};
use crate::util::prompt::prompt_confirm_with_default;

use super::*;

/// Shared `{id, name}` output shape for the service/environment being acted on.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(super) struct ResourceRef {
    pub id: String,
    pub name: String,
}

pub mod ha;
pub mod ops_log;
pub mod pgbouncer;
pub mod pitr;

/// Manage Postgres plugin features: point-in-time recovery, high availability, and connection pooling
#[derive(Parser)]
#[clap(
    after_help = "Examples:\n\n  railway postgres pitr status --service postgres\n  railway postgres pitr enable --service postgres\n  railway postgres ha status --service postgres\n  railway postgres ha convert --service postgres --replicas 2\n  railway postgres pgbouncer add --service postgres --pool-mode transaction\n\nAutomation notes:\n  --service/--environment/--project/--json apply to every subcommand below `railway postgres`.\n  Actions that change config (enable/disable/convert/revert/add/remove/configure/scale) commit and deploy by default; pass --no-deploy to commit the config change without triggering deploys (it then applies on each affected service's next deploy)."
)]
pub struct Args {
    #[clap(subcommand)]
    command: Commands,

    /// Service name or ID (defaults to linked service)
    #[clap(short, long, global = true)]
    service: Option<String>,

    /// Environment to use (defaults to linked environment)
    #[clap(short, long, global = true)]
    environment: Option<String>,

    /// Project ID to use (defaults to linked project)
    #[clap(short = 'p', long, value_name = "PROJECT_ID", global = true)]
    project: Option<String>,

    /// Output in JSON format
    #[clap(long, global = true)]
    json: bool,
}

#[derive(Parser)]
enum Commands {
    /// Manage point-in-time recovery (continuous backups)
    Pitr(pitr::Args),

    /// Manage high-availability clustering
    Ha(ha::Args),

    /// Manage PgBouncer connection pooling
    Pgbouncer(pgbouncer::Args),

    /// Show the local audit trail of postgres operations
    History(HistoryArgs),
}

#[derive(Parser)]
struct HistoryArgs {
    /// Maximum entries to show (newest last)
    #[clap(long, default_value_t = 50, value_parser = clap::value_parser!(usize))]
    limit: usize,
}

pub async fn command(args: Args) -> Result<()> {
    let Args {
        command,
        service,
        environment,
        project,
        json,
    } = args;

    crate::util::reporter::set_mode(json);

    // `history` only reads the local trail -- it neither needs resolution
    // nor should it append to the very log it displays.
    if let Commands::History(history_args) = &command {
        return history(history_args, json);
    }

    let started = std::time::Instant::now();
    let result = match command {
        Commands::Pitr(sub) => {
            pitr::command(
                sub,
                project.clone(),
                service.clone(),
                environment.clone(),
                json,
            )
            .await
        }
        Commands::Ha(sub) => {
            ha::command(
                sub,
                project.clone(),
                service.clone(),
                environment.clone(),
                json,
            )
            .await
        }
        Commands::Pgbouncer(sub) => {
            pgbouncer::command(
                sub,
                project.clone(),
                service.clone(),
                environment.clone(),
                json,
            )
            .await
        }
        Commands::History(_) => unreachable!("handled above"),
    };
    let result = result.map_err(add_api_mismatch_guidance);

    // Best-effort persistent audit trail (see ops_log): PITR/HA/PgBouncer
    // compose, and reconstructing WHICH sequence of operations produced a
    // misconfigured Postgres needs more than server-side command counters.
    let (project, environment, service) =
        resolved_selectors_for_log(project, service, environment).await;
    ops_log::record(&ops_log::OpsLogEntry {
        timestamp: chrono::Utc::now(),
        cli_version: env!("CARGO_PKG_VERSION").to_string(),
        args: std::env::args().skip(1).collect(),
        project,
        environment,
        service,
        success: result.is_ok(),
        error: result.as_ref().err().map(|e| {
            let message = format!("{e:#}");
            if message.len() > 512 {
                message[..512].to_string()
            } else {
                message
            }
        }),
        duration_ms: started.elapsed().as_millis() as u64,
    });

    result
}

/// The selectors that actually applied: explicit flags win; otherwise the
/// linked project's ids (config-file read, no network). Best-effort -- the
/// log entry still lands with whatever could be resolved.
async fn resolved_selectors_for_log(
    project: Option<String>,
    service: Option<String>,
    environment: Option<String>,
) -> (Option<String>, Option<String>, Option<String>) {
    if project.is_some() && environment.is_some() && service.is_some() {
        return (project, environment, service);
    }
    let linked = match crate::config::Configs::new() {
        Ok(configs) => configs.get_linked_project().await.ok(),
        Err(_) => None,
    };
    (
        project.or_else(|| linked.as_ref().map(|l| l.project.clone())),
        environment.or_else(|| linked.as_ref().and_then(|l| l.environment.clone())),
        service.or_else(|| linked.as_ref().and_then(|l| l.service.clone())),
    )
}

fn history(args: &HistoryArgs, json: bool) -> Result<()> {
    let entries = ops_log::read_entries();
    let start = entries.len().saturating_sub(args.limit);
    let window = &entries[start..];

    if json {
        println!("{}", serde_json::to_string_pretty(window)?);
        return Ok(());
    }

    if window.is_empty() {
        println!(
            "No postgres operations recorded yet (the trail lives at {}).",
            ops_log::log_path()
                .map(|p| p.display().to_string())
                .unwrap_or_else(|| "~/.railway/postgres-ops.jsonl".to_string())
        );
        return Ok(());
    }

    println!(
        "{:<21} {:<7} {:<9} {:<37} COMMAND",
        "WHEN (UTC)", "OUTCOME", "DURATION", "PROJECT/SERVICE"
    );
    for entry in window {
        let outcome = if entry.success {
            "ok".green().to_string()
        } else {
            "FAIL".red().to_string()
        };
        let target = format!(
            "{}/{}",
            entry.project.as_deref().unwrap_or("-"),
            entry.service.as_deref().unwrap_or("-")
        );
        let target = if target.len() > 37 {
            format!("{}…", &target[..36])
        } else {
            target
        };
        println!(
            "{:<21} {:<7} {:<9} {:<37} railway {}",
            entry.timestamp.format("%Y-%m-%d %H:%M:%S"),
            outcome,
            format!("{}ms", entry.duration_ms),
            target,
            entry.args.join(" ")
        );
        if let Some(error) = &entry.error {
            println!("{:<40} {}", "", error.lines().next().unwrap_or("").red());
        }
    }
    Ok(())
}

/// Marker phrases the backend uses (or may use in the future) in a
/// `UserError` when an operation this CLI build depends on has been
/// removed or changed and the fix is a newer CLI. Matched
/// case-insensitively against the whole error chain.
const UPGRADE_REQUIRED_MARKERS: &[&str] = &[
    "update your railway cli",
    "upgrade your railway cli",
    "update the railway cli",
    "upgrade the railway cli",
    "newer version of the railway cli",
    "railway cli is out of date",
];

/// GraphQL validation messages that mean the running binary was built
/// against a different API schema than the server is exposing -- an
/// operation or field this command depends on no longer exists (removed,
/// renamed, or re-internalized server-side).
fn is_schema_mismatch_message(lower_chain: &str) -> bool {
    lower_chain.contains("cannot query field")
        || lower_chain.contains("is not defined by type")
        || lower_chain.contains("unknown argument")
        || lower_chain.contains("unknown field")
}

/// `railway postgres` drives API operations that the backend reserves the
/// right to evolve (they were exposed on the public subgraph specifically
/// for this CLI). When one disappears or the backend explicitly asks for a
/// newer CLI, translate the raw GraphQL error into actionable guidance
/// instead of a cryptic validation dump. Every other error passes through
/// untouched.
pub(super) fn add_api_mismatch_guidance(err: anyhow::Error) -> anyhow::Error {
    let lower_chain = format!("{err:#}").to_ascii_lowercase();

    if UPGRADE_REQUIRED_MARKERS
        .iter()
        .any(|marker| lower_chain.contains(marker))
    {
        return err.context(
            "The Railway API requires a newer CLI for this command. Update with `railway upgrade` (or your package manager) and try again.",
        );
    }

    if is_schema_mismatch_message(&lower_chain) {
        return err.context(
            "This CLI build no longer matches the Railway API -- an operation this command depends on is missing or has changed. Update with `railway upgrade` and try again; if the latest CLI still fails, the operation may have been removed (check the Railway changelog).",
        );
    }

    err
}

/// Shared confirm-before-mutating helper: `--yes` bypasses the prompt; a
/// non-TTY session without `--yes` fails loudly instead of hanging, matching
/// `tcp_proxy.rs delete`'s convention.
pub(super) fn confirm_or_bail(message: &str, yes: bool) -> Result<bool> {
    if yes {
        return Ok(true);
    }
    if std::io::stdout().is_terminal() {
        prompt_confirm_with_default(message, false)
    } else {
        bail!(
            "Cannot prompt for confirmation in non-interactive mode. Use --yes to skip confirmation."
        );
    }
}

/// Service id -> name lookup, used to label HA cluster members (which are
/// only identified by id in `environment.config`).
pub(super) fn service_name_map(ctx: &ServiceContext) -> BTreeMap<String, String> {
    ctx.project
        .services
        .edges
        .iter()
        .map(|edge| (edge.node.id.clone(), edge.node.name.clone()))
        .collect()
}

/// The resolved cluster/standalone root for `ctx.service_id` -- if the
/// resolved service is a PgBouncer/HAProxy edge child, this follows
/// `parentServiceId` back to the actual database root (mirrors
/// `PgBouncerSection.tsx`'s `templateRootServiceId`).
pub(super) struct RootContext {
    pub root_id: String,
    pub root_name: String,
}

pub(super) const FIELD_LABEL_WIDTH: usize = 20;

/// Fixed-width field printer, matching `cdn.rs`'s status output convention.
pub(super) fn print_field(label: &str, value: &dyn std::fmt::Display) {
    let padded = format!("{label:<FIELD_LABEL_WIDTH$}");
    println!("{} {value}", padded.dimmed());
}

pub(super) fn status_label(enabled: bool) -> colored::ColoredString {
    if enabled {
        "enabled".green().bold()
    } else {
        "disabled".yellow().bold()
    }
}

pub(super) fn yes_no(value: bool) -> &'static str {
    if value { "yes" } else { "no" }
}

pub(super) fn resolve_root(ctx: &ServiceContext, config: &EnvironmentConfig) -> RootContext {
    let root_id = postgres_plugins::resolve_root_service_id(config, &ctx.service_id);
    let root_name = if root_id == ctx.service_id {
        ctx.service_name.clone()
    } else {
        service_name_map(ctx)
            .get(&root_id)
            .cloned()
            .unwrap_or_else(|| root_id.clone())
    };
    RootContext { root_id, root_name }
}

#[cfg(test)]
mod tests {
    use super::*;
    use clap::Parser;

    #[test]
    fn parses_feature_subcommands() {
        assert!(matches!(
            Args::parse_from(["postgres", "pitr", "status"]).command,
            Commands::Pitr(_)
        ));
        assert!(matches!(
            Args::parse_from(["postgres", "ha", "status"]).command,
            Commands::Ha(_)
        ));
        assert!(matches!(
            Args::parse_from(["postgres", "pgbouncer", "status"]).command,
            Commands::Pgbouncer(_)
        ));
    }

    #[test]
    fn parses_history_with_limit() {
        let args = Args::parse_from(["postgres", "history"]);
        assert!(matches!(
            args.command,
            Commands::History(HistoryArgs { limit: 50 })
        ));
        let args = Args::parse_from(["postgres", "history", "--limit", "5"]);
        assert!(matches!(
            args.command,
            Commands::History(HistoryArgs { limit: 5 })
        ));
    }

    #[test]
    fn api_mismatch_guidance_translates_missing_field_validation_errors() {
        // Real message shape from the public gateway when a mutation this
        // build uses is not on the Public subgraph.
        let err = anyhow::anyhow!(
            "Cannot query field \"volumeInstanceBackupCreateForHaConversion\" on type \"Mutation\"."
        )
        .context("Failed to enable PITR");
        let wrapped = add_api_mismatch_guidance(err);
        assert!(format!("{wrapped:#}").contains("railway upgrade"));

        // Input-field removal shape ("Field X is not defined by type Y").
        let err = anyhow::anyhow!(
            "Variable \"$input\" got invalid value; Field \"stageOnly\" is not defined by type \"TemplateDeployV2Input\"."
        );
        let wrapped = add_api_mismatch_guidance(err);
        assert!(format!("{wrapped:#}").contains("no longer matches the Railway API"));
    }

    #[test]
    fn api_mismatch_guidance_surfaces_explicit_upgrade_user_errors() {
        // If the backend ever retires one of these routes it throws a
        // UserError telling the caller to update -- the CLI must lead with
        // actionable guidance, not a bare GraphQL error.
        let err = anyhow::anyhow!(
            "This operation has moved. Please update your Railway CLI to continue managing PITR."
        )
        .context("Failed to enable PITR");
        let wrapped = add_api_mismatch_guidance(err);
        let rendered = format!("{wrapped:#}");
        assert!(rendered.contains("requires a newer CLI"));
        assert!(rendered.contains("railway upgrade"));
        // The server's own message stays visible in the chain.
        assert!(rendered.contains("This operation has moved"));
    }

    #[test]
    fn api_mismatch_guidance_passes_unrelated_errors_through() {
        let err = anyhow::anyhow!("Problem processing request").context("Failed to enable PITR");
        let before = format!("{err:#}");
        let after = format!("{:#}", add_api_mismatch_guidance(err));
        assert_eq!(before, after);

        let err = anyhow::anyhow!("connection reset by peer");
        let after = format!("{:#}", add_api_mismatch_guidance(err));
        assert_eq!(after, "connection reset by peer");
    }

    #[test]
    fn global_selectors_are_accepted_before_and_after_the_subcommand() {
        let args = Args::parse_from([
            "postgres",
            "--project",
            "project-id",
            "--environment",
            "production",
            "--service",
            "web",
            "--json",
            "pitr",
            "status",
        ]);
        assert_eq!(args.project.as_deref(), Some("project-id"));
        assert_eq!(args.environment.as_deref(), Some("production"));
        assert_eq!(args.service.as_deref(), Some("web"));
        assert!(args.json);

        let args = Args::parse_from(["postgres", "ha", "status", "--service", "web", "--json"]);
        assert_eq!(args.service.as_deref(), Some("web"));
        assert!(args.json);
    }
}