diff --git a/src/commands/config/mod.rs b/src/commands/config/mod.rs index b99ed547e..e735fc80c 100644 --- a/src/commands/config/mod.rs +++ b/src/commands/config/mod.rs @@ -1411,4 +1411,71 @@ mod tests { assert!(rendered.contains("source: image")); assert!(rendered.contains("registryCredentials")); } + + #[test] + fn pull_renderer_declares_volumes_and_attachments() { + let graph: runner::DesiredGraph = serde_json::from_value(json!({ + "project": { "name": "acme" }, + "resources": [ + { + "address": "service.web", + "type": "service", + "name": "web", + "source": { "type": "image", "image": "ghcr.io/acme/api:1.2.3" }, + "deploy": { "startCommand": "node index.js" }, + "volumeAttachments": { + "web": { "volume": "volume.web", "mountPath": "/data" } + } + }, + { + "address": "database.db", + "type": "database", + "engine": "postgres", + "name": "db", + "deploy": { "requiredMountPath": "/var/lib/postgresql/data" } + }, + { "address": "volume.web", "type": "volume", "name": "web", "config": { "name": "web", "sizeMB": 1024 } }, + { "address": "volume.db-volume", "type": "volume", "name": "db-volume", "config": { "name": "db-volume", "sizeMB": 2048 } }, + { "address": "volume.scratch", "type": "volume", "name": "scratch", "config": { "name": "scratch", "sizeMB": 512 } } + ] + })) + .expect("valid graph json"); + + let out = render_graph_as_railway_ts(&graph, false); + + // Volume names colliding with service names, database volumes, and + // unattached volumes must all be declared and referenced. + assert!(out.contains("volume(\"web\""), "missing volume(web): {out}"); + assert!( + out.contains("volume(\"db-volume\""), + "missing volume(db-volume): {out}" + ); + assert!( + out.contains("volume(\"scratch\""), + "missing volume(scratch): {out}" + ); + assert!(out.contains("volumeMounts"), "missing attachment: {out}"); + } + + #[test] + fn runner_response_surfaces_sdk_version() { + let with_version: runner::RunnerResponse = serde_json::from_value(json!({ + "ok": true, + "command": "current", + "file": "railway.ts", + "sdkVersion": "3.5.3", + "diagnostics": [] + })) + .expect("valid response"); + assert_eq!(with_version.sdk_version.as_deref(), Some("3.5.3")); + + let without_version: runner::RunnerResponse = serde_json::from_value(json!({ + "ok": true, + "command": "current", + "file": "railway.ts", + "diagnostics": [] + })) + .expect("valid response"); + assert!(without_version.sdk_version.is_none()); + } } diff --git a/src/commands/config/runner.rs b/src/commands/config/runner.rs index 665f436c6..1ae1fe771 100644 --- a/src/commands/config/runner.rs +++ b/src/commands/config/runner.rs @@ -70,6 +70,9 @@ pub(super) struct RunnerResponse { pub(super) ok: bool, command: String, file: String, + /// Absent when the installed railway SDK predates the version handshake. + #[serde(default)] + pub(super) sdk_version: Option, current_environment: Option, pub(super) change_set: Option, diff: Option, @@ -441,9 +444,30 @@ async fn invoke_runner( format!("IaC runner returned non-JSON output.\nstdout:\n{stdout}\nstderr:\n{stderr}") })?; + warn_outdated_runner(&response); + Ok(response) } +/// Responses without sdkVersion come from SDKs that predate the version +/// handshake. Warn once per invocation, on stderr so --json output stays clean. +fn warn_outdated_runner(response: &RunnerResponse) { + use colored::Colorize; + + static WARN_ONCE: std::sync::Once = std::sync::Once::new(); + if response.sdk_version.is_some() { + return; + } + WARN_ONCE.call_once(|| { + eprintln!( + "{} You are running an outdated version of the {} SDK, which may result in undesired behavior. Upgrade it with {}.", + "Warning:".yellow().bold(), + "railway".cyan(), + "npm install railway@latest".cyan(), + ); + }); +} + struct ResolvedRunner { path: String, source: RunnerSource,