Skip to content

Commit 2df5894

Browse files
author
ghashtag-agent
committed
feat(rw-02,rw-03): typed Railway queries + mutations + service CLI (openai#3 openai#4 openai#5)
- AuthMode enum: auto-detect UUID-shaped Project-Access-Token - queries: project_view, recent_deployments, service_variables, latest_deploy_id - mutations: service_create, service_instance_set_image, variable_upsert, service_redeploy, service_delete - bin/tri-railway: 'service list', 'service deploy', 'service redeploy', 'service delete' verbs (R7 audit triplet appended on deploy) - Live-tested against Railway IGLA project (verified service list + redeploy on seed-43 SUCCESS digest e53ade00) - Fixed clippy items_after_test_module + needless_lifetimes - 16 unit tests still green; build green Closes openai#3 Closes openai#4 Closes openai#5 Refs: L-T5 (trainer-igla-sot), Gate-2 deadline 2026-04-30T23:59Z Agent: GENERAL
1 parent df2e69f commit 2df5894

5 files changed

Lines changed: 620 additions & 7 deletions

File tree

bin/tri-railway/src/main.rs

Lines changed: 176 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,15 @@ use clap::{Parser, Subcommand};
1717
use std::path::PathBuf;
1818

1919
use trios_railway_audit::migrations;
20-
use trios_railway_core::{ProjectId, RailwayHash, ServiceId};
20+
use trios_railway_core::{
21+
mutations as M, queries as Q, Client, EnvironmentId, ProjectId, RailwayHash, ServiceId,
22+
};
2123
use trios_railway_experience::{append_line, ExperienceLine};
2224

25+
const IGLA_PROJECT_ID: &str = "e4fe33bb-3b09-4842-9782-7d2dea1abc9b";
26+
const IGLA_PROD_ENV_ID: &str = "54e293b9-00a9-4102-814d-db151636d96e";
27+
const DEFAULT_TRAINER_IMAGE: &str = "ghcr.io/ghashtag/trios-trainer-igla:latest";
28+
2329
#[derive(Parser, Debug)]
2430
#[command(
2531
name = "tri-railway",
@@ -53,6 +59,66 @@ enum Cmd {
5359
#[command(subcommand)]
5460
sub: ExperienceCmd,
5561
},
62+
63+
/// Service operations against Railway (RW-02 + RW-03).
64+
///
65+
/// Requires `RAILWAY_TOKEN` in the environment. UUID-shaped tokens
66+
/// are auto-detected as project-access tokens.
67+
Service {
68+
#[command(subcommand)]
69+
sub: ServiceCmd,
70+
},
71+
}
72+
73+
#[derive(Subcommand, Debug)]
74+
enum ServiceCmd {
75+
/// Print all services in the configured project.
76+
List {
77+
#[arg(long, env = "TRIOS_RAILWAY_PROJECT", default_value = IGLA_PROJECT_ID)]
78+
project: String,
79+
},
80+
/// Create a new image-backed service named `--name` with `--image`,
81+
/// upsert the variables, and trigger one redeploy. R7 audit triplet
82+
/// is appended to the local experience log.
83+
Deploy {
84+
#[arg(long, env = "TRIOS_RAILWAY_PROJECT", default_value = IGLA_PROJECT_ID)]
85+
project: String,
86+
#[arg(long, env = "TRIOS_RAILWAY_ENV", default_value = IGLA_PROD_ENV_ID)]
87+
environment: String,
88+
/// Service name (e.g. `trios-train-seed-43`).
89+
#[arg(long)]
90+
name: String,
91+
/// Docker image; defaults to the IGLA trainer image.
92+
#[arg(long, default_value = DEFAULT_TRAINER_IMAGE)]
93+
image: String,
94+
/// `KEY=VALUE` env pairs to upsert. Repeatable.
95+
#[arg(long = "var", value_name = "KEY=VALUE")]
96+
vars: Vec<String>,
97+
/// Reuse this existing service id instead of creating a new one.
98+
#[arg(long)]
99+
existing: Option<String>,
100+
/// If set, only print what would happen.
101+
#[arg(long)]
102+
dry_run: bool,
103+
/// Repo root for the experience log.
104+
#[arg(long, default_value = ".")]
105+
root: PathBuf,
106+
},
107+
/// Trigger a redeploy of an existing service.
108+
Redeploy {
109+
#[arg(long, env = "TRIOS_RAILWAY_ENV", default_value = IGLA_PROD_ENV_ID)]
110+
environment: String,
111+
#[arg(long)]
112+
service: String,
113+
},
114+
/// Permanently delete a service.
115+
Delete {
116+
#[arg(long)]
117+
service: String,
118+
/// Confirm the destruction with `--yes`.
119+
#[arg(long)]
120+
yes: bool,
121+
},
56122
}
57123

58124
#[derive(Subcommand, Debug)]
@@ -129,6 +195,7 @@ async fn main() -> Result<()> {
129195
println!("{stmt};");
130196
}
131197
}
198+
Cmd::Service { sub } => run_service(sub).await?,
132199
Cmd::Experience { sub } => match sub {
133200
ExperienceCmd::Append {
134201
root,
@@ -157,3 +224,111 @@ async fn main() -> Result<()> {
157224

158225
Ok(())
159226
}
227+
228+
fn parse_var(s: &str) -> Result<(String, String)> {
229+
let (k, v) = s
230+
.split_once('=')
231+
.ok_or_else(|| anyhow::anyhow!("variable `{s}` is not in KEY=VALUE form"))?;
232+
if k.is_empty() {
233+
anyhow::bail!("empty variable name in `{s}`");
234+
}
235+
Ok((k.to_string(), v.to_string()))
236+
}
237+
238+
async fn run_service(cmd: ServiceCmd) -> Result<()> {
239+
let client =
240+
Client::from_env().map_err(|e| anyhow::anyhow!("RAILWAY_TOKEN not set or invalid: {e}"))?;
241+
let token_fp = client.token_fingerprint();
242+
243+
match cmd {
244+
ServiceCmd::List { project } => {
245+
let pid = ProjectId::from(project);
246+
let pv = Q::project_view(&client, &pid).await?;
247+
println!("project {} ({})", pv.name, pv.id);
248+
for s in pv.services() {
249+
println!(" {} {} {}", s.id, s.name, s.created_at);
250+
}
251+
}
252+
ServiceCmd::Deploy {
253+
project,
254+
environment,
255+
name,
256+
image,
257+
vars,
258+
existing,
259+
dry_run,
260+
root,
261+
} => {
262+
let pid = ProjectId::from(project);
263+
let eid = EnvironmentId::from(environment);
264+
let mut parsed = Vec::with_capacity(vars.len());
265+
for v in &vars {
266+
parsed.push(parse_var(v)?);
267+
}
268+
269+
if dry_run {
270+
println!("DRY RUN: would deploy {name} from {image}");
271+
println!(" project = {}", pid.as_str());
272+
println!(" env = {}", eid.as_str());
273+
if let Some(eid) = &existing {
274+
println!(" reuse svc = {eid}");
275+
}
276+
for (k, v) in &parsed {
277+
println!(" var = {k}={v}");
278+
}
279+
return Ok(());
280+
}
281+
282+
let service_id: ServiceId = if let Some(eid) = existing {
283+
ServiceId::from(eid)
284+
} else {
285+
let created = M::service_create(&client, &pid, &name).await?;
286+
println!("created service {} ({})", created.name, created.id);
287+
ServiceId::from(created.id)
288+
};
289+
290+
M::service_instance_set_image(&client, &service_id, &eid, &image).await?;
291+
println!("set image: {image}");
292+
293+
for (k, v) in &parsed {
294+
M::variable_upsert(&client, &pid, &eid, &service_id, k, v).await?;
295+
println!(" var: {k}=<{}>", v.len());
296+
}
297+
298+
let deploy_id = M::service_redeploy(&client, &service_id, &eid).await?;
299+
println!("redeploy triggered: {deploy_id}");
300+
301+
// R7 triplet to local experience log.
302+
let hash = RailwayHash::seal("deploy", &pid, Some(&service_id), &token_fp);
303+
let line = ExperienceLine::from_hash(
304+
"GENERAL",
305+
"RailRangerOne",
306+
"#5",
307+
&format!("deploy {name} image={image}"),
308+
"OK",
309+
"PUSH",
310+
&hash,
311+
)?;
312+
let path = append_line(&root.join(".trinity"), &line).await?;
313+
println!("experience: {}", path.display());
314+
}
315+
ServiceCmd::Redeploy {
316+
environment,
317+
service,
318+
} => {
319+
let eid = EnvironmentId::from(environment);
320+
let sid = ServiceId::from(service);
321+
let deploy_id = M::service_redeploy(&client, &sid, &eid).await?;
322+
println!("redeploy triggered: {deploy_id}");
323+
}
324+
ServiceCmd::Delete { service, yes } => {
325+
if !yes {
326+
anyhow::bail!("refusing to delete service `{service}` without --yes");
327+
}
328+
let sid = ServiceId::from(service);
329+
M::service_delete(&client, &sid).await?;
330+
println!("deleted: {sid}");
331+
}
332+
}
333+
Ok(())
334+
}

crates/trios-railway-core/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
1212
pub mod hash;
1313
pub mod ids;
14+
pub mod mutations;
15+
pub mod queries;
1416
pub mod transport;
1517

1618
pub use hash::RailwayHash;
1719
pub use ids::{DeployId, EnvironmentId, ProjectId, ServiceId};
18-
pub use transport::{Client, ClientError};
20+
pub use transport::{AuthMode, Client, ClientError};
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
//! RW-03: typed mutations against the Railway GraphQL API.
2+
//!
3+
//! Mutations supported:
4+
//! - `serviceCreate` (image-based, project-scoped)
5+
//! - `variableUpsert` (one variable at a time, scoped to project + env + service)
6+
//! - `serviceInstanceDeployV2` (trigger a redeploy on the latest source)
7+
//! - `serviceInstanceUpdate` (set source.image so the next deploy pulls a new image)
8+
//! - `serviceDelete`
9+
//!
10+
//! All mutations log a `R7` triplet via `RailwayHash::seal` at the call site
11+
//! (callers must do that — this module returns plain ids).
12+
13+
use serde::Deserialize;
14+
use serde_json::json;
15+
16+
use crate::ids::{DeployId, EnvironmentId, ProjectId, ServiceId};
17+
use crate::transport::{Client, ClientError};
18+
19+
pub const M_SERVICE_CREATE: &str = "mutation M($input: ServiceCreateInput!) {
20+
serviceCreate(input: $input) { id name projectId }
21+
}";
22+
23+
pub const M_VARIABLE_UPSERT: &str = "mutation M($input: VariableUpsertInput!) {
24+
variableUpsert(input: $input)
25+
}";
26+
27+
pub const M_DEPLOY_REDEPLOY: &str = "mutation M($serviceId: String!, $environmentId: String!) {
28+
serviceInstanceRedeploy(serviceId: $serviceId, environmentId: $environmentId)
29+
}";
30+
31+
pub const M_SERVICE_INSTANCE_UPDATE: &str =
32+
"mutation M($serviceId: String!, $environmentId: String!, $input: ServiceInstanceUpdateInput!) {
33+
serviceInstanceUpdate(serviceId: $serviceId, environmentId: $environmentId, input: $input)
34+
}";
35+
36+
pub const M_SERVICE_DELETE: &str = "mutation M($id: String!) {
37+
serviceDelete(id: $id)
38+
}";
39+
40+
#[derive(Debug, Clone, Deserialize)]
41+
pub struct CreatedService {
42+
pub id: String,
43+
pub name: String,
44+
#[serde(rename = "projectId")]
45+
pub project_id: String,
46+
}
47+
48+
/// Create a new service in a project. The image is set on the service
49+
/// instance via a follow-up `serviceInstanceUpdate` call (Railway splits
50+
/// service vs service-instance config).
51+
pub async fn service_create(
52+
client: &Client,
53+
project: &ProjectId,
54+
name: &str,
55+
) -> Result<CreatedService, ClientError> {
56+
#[derive(Deserialize)]
57+
struct R {
58+
#[serde(rename = "serviceCreate")]
59+
service_create: CreatedService,
60+
}
61+
let vars = json!({
62+
"input": {
63+
"projectId": project.as_str(),
64+
"name": name,
65+
}
66+
});
67+
let r: R = client.query(M_SERVICE_CREATE, Some(vars)).await?;
68+
Ok(r.service_create)
69+
}
70+
71+
/// Pin the image source on a service instance. The next redeploy will use it.
72+
pub async fn service_instance_set_image(
73+
client: &Client,
74+
service: &ServiceId,
75+
env: &EnvironmentId,
76+
image: &str,
77+
) -> Result<(), ClientError> {
78+
let vars = json!({
79+
"serviceId": service.as_str(),
80+
"environmentId": env.as_str(),
81+
"input": {
82+
"source": { "image": image }
83+
}
84+
});
85+
let _: serde_json::Value = client.query(M_SERVICE_INSTANCE_UPDATE, Some(vars)).await?;
86+
Ok(())
87+
}
88+
89+
/// Upsert a single environment variable for a service.
90+
pub async fn variable_upsert(
91+
client: &Client,
92+
project: &ProjectId,
93+
env: &EnvironmentId,
94+
service: &ServiceId,
95+
name: &str,
96+
value: &str,
97+
) -> Result<(), ClientError> {
98+
let vars = json!({
99+
"input": {
100+
"projectId": project.as_str(),
101+
"environmentId": env.as_str(),
102+
"serviceId": service.as_str(),
103+
"name": name,
104+
"value": value,
105+
}
106+
});
107+
let _: serde_json::Value = client.query(M_VARIABLE_UPSERT, Some(vars)).await?;
108+
Ok(())
109+
}
110+
111+
/// Redeploy a service in an environment using the most recent source.
112+
/// Returns the new deployment id.
113+
pub async fn service_redeploy(
114+
client: &Client,
115+
service: &ServiceId,
116+
env: &EnvironmentId,
117+
) -> Result<DeployId, ClientError> {
118+
#[derive(Deserialize)]
119+
struct R {
120+
#[serde(rename = "serviceInstanceRedeploy")]
121+
service_instance_redeploy: serde_json::Value,
122+
}
123+
let vars = json!({
124+
"serviceId": service.as_str(),
125+
"environmentId": env.as_str(),
126+
});
127+
let r: R = client.query(M_DEPLOY_REDEPLOY, Some(vars)).await?;
128+
let id = match r.service_instance_redeploy {
129+
serde_json::Value::String(s) => s,
130+
v => v.to_string(),
131+
};
132+
Ok(DeployId::new(id))
133+
}
134+
135+
/// Permanently delete a service (and all its deployments).
136+
pub async fn service_delete(client: &Client, service: &ServiceId) -> Result<(), ClientError> {
137+
let vars = json!({ "id": service.as_str() });
138+
let _: serde_json::Value = client.query(M_SERVICE_DELETE, Some(vars)).await?;
139+
Ok(())
140+
}
141+
142+
#[cfg(test)]
143+
mod tests {
144+
use super::*;
145+
146+
#[test]
147+
fn mutation_strings_present() {
148+
for m in [
149+
M_SERVICE_CREATE,
150+
M_VARIABLE_UPSERT,
151+
M_DEPLOY_REDEPLOY,
152+
M_SERVICE_INSTANCE_UPDATE,
153+
M_SERVICE_DELETE,
154+
] {
155+
assert!(m.contains("mutation M("));
156+
}
157+
}
158+
159+
#[test]
160+
fn created_service_parses() {
161+
let raw = serde_json::json!({"id":"s1","name":"trios-train-seed-43","projectId":"p"});
162+
let cs: CreatedService = serde_json::from_value(raw).unwrap();
163+
assert_eq!(cs.name, "trios-train-seed-43");
164+
}
165+
}

0 commit comments

Comments
 (0)