railwayapp 5.61.0

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
//! The registry of database engines whose managed features the CLI drives.
//!
//! Everything engine-specific that is DATA -- the HA companion's template
//! code, the PITR archive variable contract, which Railway-built image
//! lineages carry each capability -- is declared here and nowhere else. The
//! commands and controllers resolve an [`DatabaseEngine`] and stay
//! engine-name-free, so adding an engine is an entry in this file rather than
//! a new branch in every flow.
//!
//! The entries mirror the platform's own registries -- `DATABASE_ENGINES`
//! (frontend `lib/databaseEngines.ts`) for the HA companion, `pitrEngineSpecs`
//! (`@railway/models` `src/pitr.ts`) for the archive variable contract -- but
//! only as far as the CLI actually reads them, so an unused field never sits
//! here going stale against its source.
//!
//! Anything a TEMPLATE declares about itself -- `haActiveVariable`,
//! `clusterWiring`, `haConversionConfig`, `adoptionImageEligibility` -- is
//! deliberately absent: those are read off the live config or template record
//! at runtime (see `database_plugins` and `adoption_eligibility`), so shipping
//! support for a new image major, or rewiring a cluster, stays a template
//! update rather than a CLI release.

/// How a cluster's data nodes are asked about their role, and driven to hand
/// the primary over -- the one part of HA that is a protocol, not a variable.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SwitchoverMechanism {
    /// The data nodes run a coordinator with a rich member API the CLI speaks
    /// (`clusterWiring.memberStatusApi.protocol == "patroni"`): member list,
    /// roles, lag, and a cluster-wide switchover endpoint. Postgres.
    Patroni,
    /// The data nodes expose the platform's generic role/switchover REST
    /// contract (`clusterWiring.dataNodeRoleCheck` / `dataNodeSwitchover`):
    /// GET role on a node says whether it is the primary, POST switchover asks
    /// that node's own colocated coordinator to make it one. Redis (Sentinel)
    /// and MySQL (Group Replication).
    DeclaredHttp,
}

/// The engine's HA companion template, when one ships.
#[derive(Debug, Clone, Copy)]
pub struct HaCompanion {
    /// Template deployed onto the existing service to convert it, and reverted
    /// to tear the cluster back down. Only a fallback: a service provisioned
    /// from a first-party template carries its own `haTemplateCode`, which
    /// wins (see [`DatabaseEngine::ha_template_code_for`]).
    pub template_code: &'static str,
    /// Variable the HA agent sets to "true" when the cluster is active, for
    /// clusters converted before templates declared `haActiveVariable`.
    /// `None` for engines whose HA companion always declared it.
    pub legacy_active_variable: Option<&'static str>,
    pub switchover: SwitchoverMechanism,
}

/// Which live-archive probe implementation backs an engine's PITR status.
///
/// Declared kind -> code registry, so the probe is selected by declaration
/// rather than by branching on the engine's name. An engine that declares
/// none simply reports no coverage detail: the archive's own restore is the
/// hard guard, and inventing a probe it has no tool for would be worse than
/// saying nothing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PitrProbeKind {
    PgBackRest,
}

/// The engine's PITR contract -- the mirror of one `pitrEngineSpecs` entry.
#[derive(Debug, Clone, Copy)]
pub struct PitrSpec {
    /// Composable template overlaid onto the root service to enable archiving.
    pub template_code: &'static str,
    /// Prefix of the archive variable contract the template stamps. Every
    /// engine's contract is the same six variables, differing only in prefix.
    pub archive_var_prefix: &'static str,
    /// Live coverage probe backing `pitr status`, when one exists for this
    /// engine's archiver.
    pub probe_kind: Option<PitrProbeKind>,
    /// Whether the platform's rolling HA enable/disable workflow exists for
    /// this engine's clusters (backboard's `enable-pitr-ha`, dispatched on
    /// the registry's `haRolloutKind`). An engine without one refuses the
    /// HA paths up front instead of surfacing a server error from a workflow
    /// that was never going to start.
    pub supports_ha: bool,
}

impl PitrSpec {
    /// The gate variable whose presence means archiving is configured. Its
    /// presence -- not its value -- is what the enable overlay stamps, and what
    /// fleet discovery keys on.
    pub fn archive_gate_variable(&self) -> String {
        format!("{}BUCKET", self.archive_var_prefix)
    }
}

/// Connection pooling, for engines that ship a pooler companion.
#[derive(Debug, Clone, Copy)]
pub struct PoolingSpec {
    pub template_code: &'static str,
    /// Substring identifying the pooler's image among a root's edge children.
    pub image_identifier: &'static str,
}

#[derive(Debug, Clone, Copy)]
pub struct DatabaseEngine {
    /// The `railway <key>` command this engine's features live under, and the
    /// key used in the local ops log.
    pub key: &'static str,
    /// Engine name for user-facing copy, e.g. "Postgres".
    pub display_name: &'static str,
    pub ha: Option<HaCompanion>,
    pub pitr: Option<PitrSpec>,
    pub pooling: Option<PoolingSpec>,
}

pub const POSTGRES: DatabaseEngine = DatabaseEngine {
    key: "postgres",
    display_name: "Postgres",
    ha: Some(HaCompanion {
        template_code: "postgres-ha",
        // Clusters converted before templates declared haActiveVariable are
        // detected by the variable Patroni itself reads.
        legacy_active_variable: Some("PATRONI_ENABLED"),
        switchover: SwitchoverMechanism::Patroni,
    }),
    pitr: Some(PitrSpec {
        template_code: "postgres-pitr",
        archive_var_prefix: "WAL_ARCHIVE_",
        probe_kind: Some(PitrProbeKind::PgBackRest),
        supports_ha: true,
    }),
    pooling: Some(PoolingSpec {
        template_code: "postgres-with-pgbouncer",
        image_identifier: "pgbouncer",
    }),
};

pub const MYSQL: DatabaseEngine = DatabaseEngine {
    key: "mysql",
    display_name: "MySQL",
    ha: Some(HaCompanion {
        template_code: "mysql-ha",
        // mysql-ha has declared haActiveVariable since it shipped.
        legacy_active_variable: None,
        switchover: SwitchoverMechanism::DeclaredHttp,
    }),
    pitr: Some(PitrSpec {
        template_code: "mysql-pitr",
        archive_var_prefix: "BINLOG_ARCHIVE_",
        probe_kind: None,
        // mysql-ha archives from whichever member is the writable primary and
        // the platform rolls Group Replication clusters the same way it rolls
        // Patroni (replicas first, a controlled switchover, the former primary
        // last) -- `enable`/`disable` on a cluster root, and
        // `progress`/`cancel`/`clear`, drive that workflow.
        supports_ha: true,
    }),
    pooling: None,
};

pub const REDIS: DatabaseEngine = DatabaseEngine {
    key: "redis",
    display_name: "Redis",
    ha: Some(HaCompanion {
        template_code: "redis-ha",
        legacy_active_variable: None,
        switchover: SwitchoverMechanism::DeclaredHttp,
    }),
    pitr: None,
    pooling: None,
};

impl DatabaseEngine {
    /// The HA companion template to deploy for `declared_ha_template_code` --
    /// the service's own `haTemplateCode` when its origin template declared
    /// one, else this engine's registry default. Services provisioned before
    /// templates carried the field (and legacy deploys with no template link
    /// at all) have no declaration, which is exactly what the fallback covers.
    pub fn ha_template_code_for(&self, declared: Option<&str>) -> Option<String> {
        declared
            .map(str::to_string)
            .or_else(|| self.ha.map(|ha| ha.template_code.to_string()))
    }
}

/// A parsed Docker image reference: registry host (when one is present) and
/// repository path, with any tag or digest stripped.
///
/// Mirrors `parseDockerImage` from `@railway/images`: a leading segment counts
/// as the registry only when it looks like a host (contains a dot or colon, or
/// is `localhost`), so `mysql:8` parses as the Docker-library path `mysql`
/// rather than a host named `mysql`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageRef {
    pub domain: Option<String>,
    pub path: String,
    pub tag: Option<String>,
    pub digest: Option<String>,
}

pub fn parse_image_ref(image: &str) -> Option<ImageRef> {
    let image = image.trim();
    if image.is_empty() {
        return None;
    }

    let (remainder, digest) = match image.split_once('@') {
        Some((remainder, digest)) if !digest.is_empty() => (remainder, Some(digest.to_string())),
        Some(_) => return None,
        None => (image, None),
    };

    let (mut domain, path_and_tag) = match remainder.split_once('/') {
        Some((first, rest)) if is_registry_host(first) => (Some(first.to_string()), rest),
        _ => (None, remainder),
    };

    // A tag can only live in the final path segment -- a colon anywhere
    // earlier belongs to a registry port, which the host split above already
    // consumed.
    let last_slash = path_and_tag.rfind('/').map(|i| i + 1).unwrap_or(0);
    let (path, tag) = match path_and_tag[last_slash..].split_once(':') {
        Some((_, tag)) => {
            let cut = last_slash + path_and_tag[last_slash..].find(':').unwrap_or(0);
            (
                path_and_tag[..cut].to_string(),
                (!tag.is_empty()).then(|| tag.to_string()),
            )
        }
        None => (path_and_tag.to_string(), None),
    };

    if path.is_empty() {
        return None;
    }
    // `docker.io` is the implicit registry; normalize it away so a reference
    // written either way compares equal.
    if domain.as_deref() == Some("docker.io") {
        domain = None;
    }

    Some(ImageRef {
        domain,
        path,
        tag,
        digest,
    })
}

fn is_registry_host(segment: &str) -> bool {
    segment == "localhost" || segment.contains('.') || segment.contains(':')
}

/// The `{major, minor}` an image TAG declares. `minor` is `None` for a bare
/// major tag (`:8`), and the whole result is `None` when the tag declares no
/// leading-digit version (`:latest`, a named tag, no tag at all, or a
/// digest-only reference -- whose `sha256:...` payload is not a version).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ImageTagVersion {
    pub major: i64,
    pub minor: Option<i64>,
}

/// The leading run of digits of a tag component, ignoring any variant suffix
/// attached to it. This is the half of the contract the CLI kept getting
/// wrong: a component is versioned by what it STARTS with, not by being
/// numeric end to end.
fn leading_number(component: &str) -> Option<i64> {
    let digits: String = component.chars().take_while(char::is_ascii_digit).collect();
    digits.parse().ok()
}

/// Mirrors the server's `extractImageTagVersion`
/// (`packages/backboard/src/controllers/templates/adoptionImageEligibility.ts`),
/// which reads the tag's leading `major[.minor]` and ignores everything past
/// it -- patch digits and `-variant` suffixes alike.
///
/// Parsing a component only when it is numeric end to end read `8.2-alpine`
/// as having no minor and `7-bookworm` as having no version at all, so the
/// CLI refused conversions the server would have accepted: a distro-variant
/// tag is pinned to a real `major.minor` as far as the gate that matters is
/// concerned.
pub fn image_tag_version(image: Option<&str>) -> Option<ImageTagVersion> {
    let tag = parse_image_ref(image?)?.tag?;
    let mut parts = tag.split('.');
    let major = leading_number(parts.next()?)?;
    let minor = parts.next().and_then(leading_number);
    Some(ImageTagVersion { major, minor })
}

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

    #[test]
    fn parses_registry_qualified_references() {
        let parsed = parse_image_ref("ghcr.io/railwayapp-templates/postgres-ssl:16").unwrap();
        assert_eq!(parsed.domain.as_deref(), Some("ghcr.io"));
        assert_eq!(parsed.path, "railwayapp-templates/postgres-ssl");
        assert_eq!(parsed.tag.as_deref(), Some("16"));
        assert!(parsed.digest.is_none());
    }

    #[test]
    fn parses_docker_library_references_without_a_host() {
        let parsed = parse_image_ref("mysql:8.4").unwrap();
        assert!(parsed.domain.is_none());
        assert_eq!(parsed.path, "mysql");
        assert_eq!(parsed.tag.as_deref(), Some("8.4"));

        // An explicit docker.io normalizes to the same shape.
        assert_eq!(
            parse_image_ref("docker.io/library/redis:8").unwrap().domain,
            None
        );
    }

    #[test]
    fn parses_ports_and_digests_without_mistaking_them_for_tags() {
        let parsed = parse_image_ref("localhost:5000/team/db:16").unwrap();
        assert_eq!(parsed.domain.as_deref(), Some("localhost:5000"));
        assert_eq!(parsed.path, "team/db");
        assert_eq!(parsed.tag.as_deref(), Some("16"));

        let parsed = parse_image_ref("ghcr.io/owner/img@sha256:abc123").unwrap();
        assert_eq!(parsed.path, "owner/img");
        assert!(parsed.tag.is_none());
        assert_eq!(parsed.digest.as_deref(), Some("sha256:abc123"));
    }

    #[test]
    fn image_tag_version_reads_major_and_optional_minor() {
        let v = image_tag_version(Some("ghcr.io/railwayapp-templates/mysql-ha/mysql:8.4")).unwrap();
        assert_eq!(v.major, 8);
        assert_eq!(v.minor, Some(4));

        let v = image_tag_version(Some("ghcr.io/railwayapp-templates/postgres-ssl:16")).unwrap();
        assert_eq!(v.major, 16);
        assert_eq!(v.minor, None);

        // A variant suffix does not erase the version underneath it. The
        // server reads both of these as pinned (`8.2` and `7`), so a CLI that
        // read "no minor" / "no version" refused conversions the server would
        // have accepted.
        let v = image_tag_version(Some("redis:8.2-alpine")).unwrap();
        assert_eq!(v.major, 8);
        assert_eq!(v.minor, Some(2));

        let v = image_tag_version(Some("redis:7-bookworm")).unwrap();
        assert_eq!(v.major, 7);
        assert_eq!(v.minor, None);

        // Anything past the minor is ignored, patch digits included.
        let v = image_tag_version(Some("postgres:16.4.2")).unwrap();
        assert_eq!(v.major, 16);
        assert_eq!(v.minor, Some(4));

        // A tag has to START with digits to carry a version.
        assert!(image_tag_version(Some("ghcr.io/x/y:v8.2")).is_none());
        assert!(image_tag_version(Some("ghcr.io/x/y:latest")).is_none());
        assert!(image_tag_version(Some("ghcr.io/x/y")).is_none());
        // A digest is not a version: `sha256:23a8...` must never read as 23.
        assert!(image_tag_version(Some("ghcr.io/x/y@sha256:23a8ff")).is_none());
        assert!(image_tag_version(None).is_none());
    }

    #[test]
    fn ha_template_code_prefers_the_services_own_declaration() {
        // A first-party service declares the companion its origin template
        // names; the registry default only covers services that declare none.
        assert_eq!(
            POSTGRES.ha_template_code_for(Some("postgres-ha-custom")),
            Some("postgres-ha-custom".to_string())
        );
        assert_eq!(
            POSTGRES.ha_template_code_for(None),
            Some("postgres-ha".to_string())
        );
        assert_eq!(
            MYSQL.ha_template_code_for(None),
            Some("mysql-ha".to_string())
        );
        assert_eq!(
            REDIS.ha_template_code_for(None),
            Some("redis-ha".to_string())
        );
    }

    #[test]
    fn pitr_gate_variable_follows_the_declared_prefix() {
        assert_eq!(
            POSTGRES.pitr.unwrap().archive_gate_variable(),
            "WAL_ARCHIVE_BUCKET"
        );
        assert_eq!(
            MYSQL.pitr.unwrap().archive_gate_variable(),
            "BINLOG_ARCHIVE_BUCKET"
        );
    }

    #[test]
    fn capability_declarations_match_what_ships() {
        // These are the gates each engine's command tree reads to decide
        // which subcommands exist at all, so they are worth pinning.
        assert!(POSTGRES.pitr.is_some());
        assert!(POSTGRES.pooling.is_some());
        assert!(MYSQL.pitr.is_some());
        assert!(MYSQL.pooling.is_none());
        // No Redis image ships a continuous archiver.
        assert!(REDIS.pitr.is_none());
        assert!(REDIS.pooling.is_none());
        // Every engine here ships an HA companion; that is the feature this
        // registry exists for.
        assert!(POSTGRES.ha.is_some());
        assert!(MYSQL.ha.is_some());
        assert!(REDIS.ha.is_some());
        // Both shipped engines roll their clusters through the platform's
        // rolling HA enable/disable workflow -- MySQL's archiver runs off
        // whichever member is the writable primary, the same as Postgres.
        assert!(POSTGRES.pitr.unwrap().supports_ha);
        assert!(MYSQL.pitr.unwrap().supports_ha);
    }
}