diff --git a/.icons/railway.svg b/.icons/railway.svg new file mode 100644 index 000000000..b372817de --- /dev/null +++ b/.icons/railway.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/registry/bpmct/templates/railway/README.md b/registry/bpmct/templates/railway/README.md new file mode 100644 index 000000000..d4bd29caa --- /dev/null +++ b/registry/bpmct/templates/railway/README.md @@ -0,0 +1,128 @@ +--- +display_name: Railway (via GraphQL) +description: Coder workspaces backed by Railway services, provisioned via direct GraphQL calls (no community Terraform provider). +icon: ../../../../.icons/railway.svg +verified: false +tags: [railway, cloud, container, docker] +--- + +# Railway (via GraphQL) + +Each Coder workspace is a fully isolated [Railway](https://railway.com) project running a container built from a public image on GHCR. Every workspace becomes a first-class Railway project (its own environment, its own volumes, its own service token), which sets up a natural extension point: the workspace user can manage further Railway resources inside their own project via the [Railway CLI](https://docs.railway.com/reference/cli-api). + +

+ + Demo: Railway + Coder workspaces + +
+ Demo (Loom, ~5 min) +

+ +> [!NOTE] +> The Loom above was recorded before startup was optimized. The demo shows a per-start Docker build (~62s median). This template now uses `serviceInstanceUpdate(source: { image })` against a pre-built image (`ghcr.io/bpmct/railway-coder-workspace:latest`), so median start time is ~6s. The rest of the flow in the demo (workspace creation, project isolation, Railway CLI) still applies. + +## Why direct GraphQL instead of the Terraform provider + +The community [`terraform-community-providers/railway`](https://github.com/terraform-community-providers/terraform-provider-railway) provider is a great starting point, but hits two blocking issues for a Coder template that has to run reliably under stress: + +1. **Volume creation is racy.** `railway_volume` can write to a service before the service is fully attached, and Railway rejects `volumeCreate` on services that have had deployments. Terraform's implicit ordering does not respect Railway's API contract here. +2. **`variableUpsert` triggers extra deploys.** Every env var write during workspace start races a redeploy that intermittently fails with "Cannot redeploy without a snapshot" (just after `serviceDisconnect`) or "Cannot redeploy yet, please wait for the original deployment to finish building" (during the first second after a `deploymentCancel`). + +This template bypasses the provider entirely for the Railway operations and calls the [Railway public API](https://docs.railway.com/reference/public-api) directly with `curl`. Key fixes that keep the reliability suite green: + +- **`skipDeploys: true`** on `variableUpsert`. An undocumented flag that tells Railway to persist the value without enqueuing a redeploy. The subsequent `serviceInstanceDeployV2` is what actually deploys, and it picks up the freshly-set variables. +- **Explicit `volumeCreate` before any deployment activity**, with backoff on the "creating volumes too quickly" rate limit. +- **`serviceInstanceUpdate(source: { image })` + `serviceInstanceDeployV2`** for the image deploy, rather than `serviceConnect` (which builds from a repo on every start). + +See [bpmct/coder-railway](https://github.com/bpmct/coder-railway) for the full write-up, the reliability suite, and three other variants (Terraform-provider-based, hybrid, and Railway-CLI-based) that were tried along the way. + +## Prerequisites + +### 1. Railway API token + +Create a Railway account/team token at [railway.com/account/tokens](https://railway.com/account/tokens). It must be an account or team token (not a project token) so that the template can call `projectCreate`. + +### 2. Push the template with the token + +```sh +coder templates push railway --directory . \ + --variable railway_token=YOUR_RAILWAY_TOKEN +``` + +The Railway master token is stored as a Terraform variable at the template level and is never exposed to workspace users. If `enable_project_management` is set to `true`, each workspace also gets its own project-scoped Railway token (a much narrower blast radius) injected as `$RAILWAY_TOKEN` inside the workspace, and the Railway CLI is auto-installed and pre-authenticated: + +```sh +coder templates push railway --directory . \ + --variable railway_token=YOUR_RAILWAY_TOKEN \ + --variable enable_project_management=true +``` + +## Usage + +Create a workspace from the `railway` template. The only end-user parameter is: + +- **Region**: US West, US East, EU West, Asia Southeast. + +Everything else (project, service, volume, env vars, image source, first deploy) happens under the hood via GraphQL. + +## Architecture + +For each workspace, this template provisions on Railway: + +- **Project** (`coder--`, one per workspace). +- **Service** (`workspace`) pinned to the image in `workspace_image`. +- **Volume** persisting `/home/coder`, survives stop/start. +- **Env vars** on the service: `CODER_AGENT_TOKEN`, `CODER_INIT_SCRIPT_B64`, `RAILWAY_RUN_UID=0`. +- Optionally, a **project-scoped Railway token** exposed to the workspace as `$RAILWAY_TOKEN`. + +Persistent (survive stop/start): project, service, volume, project token. Ephemeral (per start): env vars, image deploy. + +### Custom workspace image + +The default image is `ghcr.io/bpmct/railway-coder-workspace:latest`, which is `codercom/enterprise-base:ubuntu` plus a small entrypoint that fixes Railway volume ownership, decodes `CODER_INIT_SCRIPT_B64`, and runs the Coder agent as the `coder` user. The Dockerfile and entrypoint are vendored in this template under [`build/`](./build) so you can read, fork, or extend them without leaving the registry: + +- [`build/Dockerfile`](./build/Dockerfile) - 10 lines, thin layer on `codercom/enterprise-base:ubuntu`. +- [`build/entrypoint.sh`](./build/entrypoint.sh) - Railway volume `chown`, skeleton seed, `CODER_INIT_SCRIPT_B64` decode + drop to `coder`. + +**Adding your own tools:** the default image is deliberately minimal, so most teams will want to extend it. Two patterns: + +1. **Extend the default image** (recommended for most cases). Write a Dockerfile that starts `FROM ghcr.io/bpmct/railway-coder-workspace:latest` and layer whatever you need on top. Entrypoint and Coder agent wiring are inherited, so you only touch what you actually need. + + ```dockerfile + FROM ghcr.io/bpmct/railway-coder-workspace:latest + USER root + RUN apt-get update && apt-get install -y --no-install-recommends \ + postgresql-client redis-tools \ + && rm -rf /var/lib/apt/lists/* + ``` + +2. **Duplicate `build/` and build from a different base**, if you need to swap the base image entirely (e.g. `codercom/example-universal:ubuntu`, an internal golden image, or a non-Ubuntu distro). Copy [`build/entrypoint.sh`](./build/entrypoint.sh) verbatim - the image contract that the template relies on (volume chown, `CODER_INIT_SCRIPT_B64` decode, drop to `coder` user) lives in that entrypoint, not in the base image. + +Build, push to any registry, and point the template at it: + +```sh +coder templates push railway --directory . \ + --variable railway_token=YOUR_RAILWAY_TOKEN \ + --variable workspace_image=ghcr.io/yourorg/your-image:tag +``` + +For private registries, also set `image_registry_username` and `image_registry_password`. + +The only contract the template requires from the image is that its `ENTRYPOINT` consumes: + +- `CODER_INIT_SCRIPT_B64`: base64-encoded Coder agent init script. +- `CODER_AGENT_TOKEN`: Coder agent token. +- `RAILWAY_RUN_UID=0`: Railway UID override so the entrypoint can chown the root-owned Railway volume mount before dropping to the workspace user. + +## Other Railway approaches + +This template ships the GraphQL variant, which is the most reliable of four approaches I tried against the Railway API. The others live in [bpmct/coder-railway/variants/wip/](https://github.com/bpmct/coder-railway/tree/main/variants/wip): + +- **`tf-patched`**: Uses the Railway Terraform provider with a small patch and a `trailing_zombie` workaround for the redeploy race. +- **`hybrid`**: Uses `railway_service` from the provider, GraphQL for everything else. +- **`cli`**: Uses the Railway CLI (`railway up`, `railway link`) instead of the provider or GraphQL. + +See the [benchmark table](https://github.com/bpmct/coder-railway#benchmarks) for the reliability comparison across variants. + +> [!NOTE] +> This template is designed to be a starting point. Edit the Terraform to extend it for your use case. diff --git a/registry/bpmct/templates/railway/build/Dockerfile b/registry/bpmct/templates/railway/build/Dockerfile new file mode 100644 index 000000000..d4af2500e --- /dev/null +++ b/registry/bpmct/templates/railway/build/Dockerfile @@ -0,0 +1,8 @@ +FROM codercom/enterprise-base:ubuntu + +USER root + +COPY entrypoint.sh /coder-entrypoint.sh +RUN chmod +x /coder-entrypoint.sh + +ENTRYPOINT ["/coder-entrypoint.sh"] diff --git a/registry/bpmct/templates/railway/build/entrypoint.sh b/registry/bpmct/templates/railway/build/entrypoint.sh new file mode 100755 index 000000000..6fe51ea93 --- /dev/null +++ b/registry/bpmct/templates/railway/build/entrypoint.sh @@ -0,0 +1,36 @@ +#!/bin/bash +set -e + +# Railway volumes are root-owned by default. When the container +# runs as root (via RAILWAY_RUN_UID=0), we fix ownership before +# dropping privileges to the coder user. +if [ "$(id -u)" = "0" ]; then + chown coder:coder /home/coder 2> /dev/null || true +fi + +# Seed home directory with skeleton files on first start. +if [ ! -f /home/coder/.init_done ]; then + cp -rT /etc/skel /home/coder 2> /dev/null || true + touch /home/coder/.init_done + if [ "$(id -u)" = "0" ]; then + chown -R coder:coder /home/coder 2> /dev/null || true + fi +fi + +# The Coder init script is passed base64-encoded to avoid shell +# escaping issues with multi-line environment variable values. +if [ -n "$CODER_INIT_SCRIPT_B64" ]; then + INIT_SCRIPT_FILE=$(mktemp /tmp/coder-init.XXXXXX) + echo "$CODER_INIT_SCRIPT_B64" | base64 -d > "$INIT_SCRIPT_FILE" + chmod +x "$INIT_SCRIPT_FILE" + if [ "$(id -u)" = "0" ]; then + chown coder:coder "$INIT_SCRIPT_FILE" + exec su -s /bin/bash coder "$INIT_SCRIPT_FILE" + else + exec "$INIT_SCRIPT_FILE" + fi +else + echo "ERROR: CODER_INIT_SCRIPT_B64 is not set." + echo "This image is designed to run as a Coder workspace on Railway." + sleep infinity +fi diff --git a/registry/bpmct/templates/railway/main.tf b/registry/bpmct/templates/railway/main.tf new file mode 100644 index 000000000..17d0cfe49 --- /dev/null +++ b/registry/bpmct/templates/railway/main.tf @@ -0,0 +1,531 @@ +# ============================================================================ +# Railway (via GraphQL) — Coder Template +# ============================================================================ +# +# Each Coder workspace becomes a fully isolated Railway project running +# a container built from a pre-built image on GHCR. Every Railway +# operation is a direct GraphQL mutation via `curl` (no community +# Railway Terraform provider). +# +# See registry/bpmct/templates/railway/README.md for the full write-up +# on why this template calls the Railway API directly instead of using +# the community provider, and https://github.com/bpmct/coder-railway +# for three alternative approaches (tf-patched, hybrid, cli) and the +# reliability suite behind the design choices. +# +# Image contract: +# The workspace image's ENTRYPOINT must consume: +# CODER_INIT_SCRIPT_B64 base64-encoded Coder agent init script +# CODER_AGENT_TOKEN Coder agent token +# RAILWAY_RUN_UID "0" so the entrypoint can chown the +# root-owned Railway volume mount and then +# drop to the coder user. +# The default image (ghcr.io/bpmct/railway-coder-workspace:latest) is +# codercom/enterprise-base:ubuntu plus a small entrypoint. Source at +# https://github.com/bpmct/coder-railway/tree/main/build. +# +# Lifecycle: +# Persistent (survive stop): project, service, volume, project token +# Ephemeral (start_count): env vars, image deploy +# +# Provisioner layout: +# Bash bodies live in scripts/*.sh, one file per (resource, action). +# main.tf only wires `command = "bash ${path.module}/scripts/X.sh"` +# and passes runtime values via environment {} blocks. Destroy +# provisioners reference self.input.* because vars/locals/data are +# not allowed in destroy scopes. ${path.module} is a constant and +# safe to use everywhere. +# ============================================================================ + +terraform { + required_providers { + coder = { + source = "coder/coder" + } + } +} + +variable "railway_token" { + type = string + description = "Railway API token." + sensitive = true +} + +variable "enable_project_management" { + type = bool + default = false + description = <<-EOT + If true, provision a project-scoped Railway token in each workspace's + Railway project and install the Railway CLI in the workspace. The + workspace user can then run `railway logs`, `railway up`, + `railway run`, etc. against their own project. The master token + (var.railway_token) is never exposed to the workspace; only the + project-scoped token is. + EOT +} + +variable "workspace_image" { + type = string + default = "ghcr.io/bpmct/railway-coder-workspace:latest" + description = <<-EOT + Pre-built workspace image. The image's ENTRYPOINT must consume the + CODER_INIT_SCRIPT_B64, CODER_AGENT_TOKEN, and RAILWAY_RUN_UID env + vars. The default image is public; see + https://github.com/bpmct/coder-railway/tree/main/build for the + Dockerfile if you want to build your own. + EOT +} + +variable "image_registry_username" { + type = string + default = "" + description = <<-EOT + Optional registry username for private image pulls. Leave empty + when pointing at the public default image + (ghcr.io/bpmct/railway-coder-workspace) or any other public + registry. + EOT +} + +variable "image_registry_password" { + type = string + default = "" + sensitive = true + description = <<-EOT + Optional registry password / PAT for private image pulls. Leave + empty when pointing at the public default image + (ghcr.io/bpmct/railway-coder-workspace) or any other public + registry. + EOT +} + +provider "coder" {} + +locals { + username = data.coder_workspace_owner.me.name + workspace = lower(data.coder_workspace.me.name) + started = data.coder_workspace.me.start_count > 0 + railway_api = "https://backboard.railway.app/graphql/v2" + state_dir = "${path.module}/.railway-state" + scripts_dir = "${path.module}/scripts" + project_name = lower(substr("coder-${local.username}-${local.workspace}", 0, 32)) +} + +data "coder_provisioner" "me" {} +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +data "coder_parameter" "region" { + name = "region" + display_name = "Region" + description = "Railway region for the workspace." + icon = "/emojis/1f30e.png" + type = "string" + default = "us-west1" + mutable = false + option { + name = "US West" + value = "us-west1" + icon = "/emojis/1f1fa-1f1f8.png" + } + option { + name = "US East" + value = "us-east4" + icon = "/emojis/1f1fa-1f1f8.png" + } + option { + name = "Europe West" + value = "europe-west4" + icon = "/emojis/1f1ea-1f1fa.png" + } + option { + name = "Asia Southeast" + value = "asia-southeast1" + icon = "/emojis/1f1f8-1f1ec.png" + } +} + +# --------------------------------------------------------------------------- +# Coder agent +# --------------------------------------------------------------------------- + +resource "coder_agent" "main" { + os = "linux" + arch = "amd64" + + startup_script = <<-EOT + set -e + if [ ! -f ~/.init_done ]; then + cp -rT /etc/skel ~ 2>/dev/null || true + touch ~/.init_done + fi + EOT + + env = { + GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_AUTHOR_EMAIL = data.coder_workspace_owner.me.email + GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_COMMITTER_EMAIL = data.coder_workspace_owner.me.email + } + + metadata { + display_name = "CPU Usage" + key = "0_cpu_usage" + script = "coder stat cpu" + interval = 10 + timeout = 1 + } + metadata { + display_name = "RAM Usage" + key = "1_ram_usage" + script = "coder stat mem" + interval = 10 + timeout = 1 + } + metadata { + display_name = "Home Disk" + key = "3_home_disk" + script = "coder stat disk --path $${HOME}" + interval = 60 + timeout = 1 + } +} + +# =========================================================================== +# Railway resources, all via GraphQL +# =========================================================================== + +# Helper: every curl call follows this pattern: +# 1. POST the mutation. +# 2. Print the response (for terraform logs). +# 3. grep for "errors", exit 1 if found. +# 4. Extract the ID with sed and write to state file. + +# --------------------------------------------------------------------------- +# 1. Project (persistent) +# --------------------------------------------------------------------------- +resource "terraform_data" "project" { + # Triggers replacement if project name changes. Stores token and + # project name so the destroy provisioner can authenticate and find + # the project by name without depending on state files. + input = { + project_name = local.project_name + token = var.railway_token + scripts_dir = local.scripts_dir + } + + triggers_replace = local.project_name + + provisioner "local-exec" { + interpreter = ["bash", "-c"] + command = "bash ${local.scripts_dir}/project_create.sh" + environment = { + API = local.railway_api + TOKEN = var.railway_token + PROJECT_NAME = local.project_name + STATE_DIR = local.state_dir + } + } + + provisioner "local-exec" { + when = destroy + interpreter = ["bash", "-c"] + command = "bash ${self.input.scripts_dir}/project_destroy.sh" + environment = { + API = "https://backboard.railway.app/graphql/v2" + TOKEN = self.input.token + PROJECT_NAME = self.input.project_name + } + } +} + +# --------------------------------------------------------------------------- +# 2. Service (persistent) +# --------------------------------------------------------------------------- +resource "terraform_data" "service" { + input = { + token = var.railway_token + scripts_dir = local.scripts_dir + } + depends_on = [terraform_data.project] + + provisioner "local-exec" { + interpreter = ["bash", "-c"] + command = "bash ${local.scripts_dir}/service_create.sh" + environment = { + API = local.railway_api + TOKEN = var.railway_token + PROJECT_NAME = local.project_name + STATE_DIR = local.state_dir + } + } + + provisioner "local-exec" { + when = destroy + interpreter = ["bash", "-c"] + command = "bash ${self.input.scripts_dir}/service_destroy.sh" + environment = { + API = "https://backboard.railway.app/graphql/v2" + TOKEN = self.input.token + } + } +} + +# --------------------------------------------------------------------------- +# 3. Volume (persistent) +# +# CRITICAL: This must run before ANY deployment activity on the service. +# Railway rejects volumeCreate on services that have had deployments. +# By using pure GraphQL we control ordering exactly. +# --------------------------------------------------------------------------- +resource "terraform_data" "volume" { + input = var.railway_token + depends_on = [terraform_data.service] + + provisioner "local-exec" { + interpreter = ["bash", "-c"] + command = "bash ${local.scripts_dir}/volume_create.sh" + environment = { + API = local.railway_api + TOKEN = var.railway_token + PROJECT_NAME = local.project_name + STATE_DIR = local.state_dir + } + } + + # Volume is deleted when service/project is deleted (cascade). +} + +# --------------------------------------------------------------------------- +# 4. Environment variables (ephemeral, only when started) +# +# Set BEFORE the image is attached, so that when the first deployment +# runs it already has the correct env vars. This avoids triggering +# extra redeploys that would happen if we upserted vars after the +# source was set (each variableUpsert on a connected service triggers +# a redeploy). +# --------------------------------------------------------------------------- +resource "terraform_data" "env_vars" { + count = data.coder_workspace.me.start_count + depends_on = [terraform_data.volume] + + input = { + token = var.railway_token + project_name = local.project_name + scripts_dir = local.scripts_dir + } + + provisioner "local-exec" { + interpreter = ["bash", "-c"] + command = "bash ${local.scripts_dir}/env_vars_create.sh" + environment = { + API = local.railway_api + TOKEN = var.railway_token + PROJECT_NAME = local.project_name + STATE_DIR = local.state_dir + CODER_INIT_SCRIPT_B64 = base64encode(coder_agent.main.init_script) + CODER_AGENT_TOKEN = coder_agent.main.token + } + } + + provisioner "local-exec" { + when = destroy + interpreter = ["bash", "-c"] + command = "bash ${self.input.scripts_dir}/env_vars_destroy.sh" + environment = { + API = "https://backboard.railway.app/graphql/v2" + TOKEN = self.input.token + PROJECT_NAME = self.input.project_name + } + } +} + +# --------------------------------------------------------------------------- +# 4b. Project-scoped Railway token + RAILWAY_TOKEN env var (optional). +# +# When var.enable_project_management is true, provision a Railway +# project-scoped token via projectTokenCreate and upsert it as +# RAILWAY_TOKEN on the workspace service. The Coder script +# `railway_cli` (below) installs the Railway CLI in the workspace. +# The CLI auto-picks up RAILWAY_TOKEN from the container env. +# +# Persistent across stop/start (count is tied to the variable, not +# start_count). The token survives until workspace delete, when the +# destroy provisioner deletes it and the env var. The variableUpsert +# happens before image_deploy, so the first deploy already has +# RAILWAY_TOKEN in scope. +# --------------------------------------------------------------------------- +resource "terraform_data" "project_token" { + count = var.enable_project_management ? 1 : 0 + depends_on = [terraform_data.project, terraform_data.service] + + triggers_replace = local.project_name + + input = { + token = var.railway_token + project_name = local.project_name + scripts_dir = local.scripts_dir + } + + provisioner "local-exec" { + interpreter = ["bash", "-c"] + command = "bash ${local.scripts_dir}/project_token_create.sh" + environment = { + API = local.railway_api + TOKEN = var.railway_token + PROJECT_NAME = local.project_name + STATE_DIR = local.state_dir + } + } + + provisioner "local-exec" { + when = destroy + interpreter = ["bash", "-c"] + command = "bash ${self.input.scripts_dir}/project_token_destroy.sh" + environment = { + API = "https://backboard.railway.app/graphql/v2" + TOKEN = self.input.token + PROJECT_NAME = self.input.project_name + } + } +} + +# --------------------------------------------------------------------------- +# 5. Image deploy (ephemeral, only when started) +# +# Points the workspace service at a pre-built container image and +# triggers the first deployment. Two GraphQL calls: +# 1. serviceInstanceUpdate(source: { image, registryCredentials? }) +# Updates the source. Does NOT trigger a deploy on its own in +# current Railway behavior. +# 2. serviceInstanceDeployV2(serviceId, environmentId) +# Explicitly triggers a deploy of the current config. +# +# On stop, the destroy provisioner cancels active deployments to +# scale the container to zero, then polls briefly for stray deploys +# that env_vars destroy might race. +# --------------------------------------------------------------------------- +resource "terraform_data" "image_deploy" { + count = data.coder_workspace.me.start_count + depends_on = [terraform_data.env_vars, terraform_data.project_token] + + # Store IDs needed by the destroy provisioner. Destroy provisioners + # can only reference self, not vars or other resources. + input = { + token = var.railway_token + project_name = local.project_name + scripts_dir = local.scripts_dir + } + + provisioner "local-exec" { + interpreter = ["bash", "-c"] + command = "bash ${local.scripts_dir}/image_deploy_create.sh" + environment = { + API = local.railway_api + TOKEN = var.railway_token + PROJECT_NAME = local.project_name + STATE_DIR = local.state_dir + WORKSPACE_IMAGE = var.workspace_image + IMAGE_REGISTRY_USERNAME = var.image_registry_username + IMAGE_REGISTRY_PASSWORD = var.image_registry_password + } + } + + provisioner "local-exec" { + when = destroy + interpreter = ["bash", "-c"] + command = "bash ${self.input.scripts_dir}/image_deploy_destroy.sh" + environment = { + API = "https://backboard.railway.app/graphql/v2" + TOKEN = self.input.token + PROJECT_NAME = self.input.project_name + } + } +} + + +# =========================================================================== +# Metadata and apps +# =========================================================================== + +resource "coder_metadata" "workspace" { + resource_id = coder_agent.main.id + item { + key = "region" + value = data.coder_parameter.region.value + } + item { + key = "image" + value = var.workspace_image + } + item { + key = "project_id" + value = try(file("${local.state_dir}/project_id"), "pending") + } + item { + key = "service_id" + value = try(file("${local.state_dir}/service_id"), "pending") + } +} + +module "code-server" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/code-server/coder" + version = "~> 1.0" + agent_id = coder_agent.main.id + order = 1 +} + +# Optional: install the Railway CLI on workspace start when project +# management is enabled. The CLI auto-authenticates against the +# project-scoped RAILWAY_TOKEN that terraform_data.project_token sets +# on the workspace service, so the user can immediately run e.g. +# `railway status`, `railway logs`, `railway up`, `railway run -- npm test`. +# +# The installer writes to ~/.railway which is on the persistent home +# volume, so subsequent starts are instant (no re-download). +resource "coder_script" "railway_cli" { + count = var.enable_project_management ? 1 : 0 + agent_id = coder_agent.main.id + display_name = "Install Railway CLI" + icon = "/icon/railway.svg" + run_on_start = true + start_blocks_login = false + + script = <<-EOT + set -eu + + # Install Railway CLI to ~/.railway if not already present. + # The upstream installer is bash-only; running it via /bin/sh + # surfaces a harmless "Bad substitution" error and a non-zero + # exit, which Coder reports as a startup-script failure. Run it + # explicitly under bash and ignore its exit code; we verify the + # binary after. + if ! [ -x "$HOME/.railway/bin/railway" ]; then + echo "Installing Railway CLI..." + curl -fsSL https://railway.app/install.sh -o /tmp/railway-install.sh + bash /tmp/railway-install.sh || true + rm -f /tmp/railway-install.sh + fi + + if ! [ -x "$HOME/.railway/bin/railway" ]; then + echo "ERROR: Railway CLI install failed." + exit 1 + fi + + # Symlink into /usr/local/bin so the CLI is on PATH for every + # shell, including non-interactive `coder ssh ... -- cmd` runs. + # ~/.bashrc would only work for interactive shells; setting the + # PATH in coder_agent.env would not survive new bash sessions. + if ! [ -e /usr/local/bin/railway ]; then + sudo ln -sf "$HOME/.railway/bin/railway" /usr/local/bin/railway + fi + + echo "" + echo "Railway CLI ready. RAILWAY_TOKEN is set to a project-scoped" + echo "token for this workspace's Railway project. Try:" + echo " railway status" + echo " railway logs --service workspace" + echo " railway variables" + EOT +} diff --git a/registry/bpmct/templates/railway/scripts/env_vars_create.sh b/registry/bpmct/templates/railway/scripts/env_vars_create.sh new file mode 100755 index 000000000..e73e2248f --- /dev/null +++ b/registry/bpmct/templates/railway/scripts/env_vars_create.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Upsert the three required env vars on the workspace service: +# CODER_INIT_SCRIPT_B64, CODER_AGENT_TOKEN, RAILWAY_RUN_UID. +# +# Runs BEFORE image_deploy so the first deployment already has the +# correct values. variableUpsert on a connected service would trigger +# an extra redeploy, which we explicitly avoid. +# +# Env vars required: +# API, TOKEN, PROJECT_NAME, STATE_DIR +# CODER_INIT_SCRIPT_B64 - base64 of agent init script +# CODER_AGENT_TOKEN - agent token +set -euo pipefail +. "$(dirname "$0")/lib.sh" + +PROJECT_ID="" +SERVICE_ID="" +ENV_ID="" +load_state + +if [ -z "$PROJECT_ID" ] || [ -z "$SERVICE_ID" ] || [ -z "$ENV_ID" ]; then + PROJECT_ID=$(lookup_project_id) + [ -z "$PROJECT_ID" ] && { + echo "FATAL: project not found" + exit 1 + } + SE=$(lookup_service_and_env "$PROJECT_ID") + SERVICE_ID=$(echo "$SE" | awk '{print $1}') + ENV_ID=$(echo "$SE" | awk '{print $2}') +fi +[ -z "$SERVICE_ID" ] || [ -z "$ENV_ID" ] && { + echo "FATAL: service/env not found" + exit 1 +} + +# Helper: upsert a single variable with retry. Railway's +# variableUpsert has been observed taking 60s+ under load and +# returning 504 at the edge with no body, which would otherwise +# surface as curl exit 28 and abort the script under set -e. +# +# skipDeploys: true tells Railway to set the value without triggering +# a redeploy. We always rely on the subsequent serviceConnect to +# trigger the actual deployment, so the deploy-on-variable-change +# behavior is unwanted and racy: +# - On a freshly-disconnected service it fails with +# "Cannot redeploy without a snapshot". +# - During the first second after a cancel it fails with +# "Cannot redeploy yet, please wait for the original +# deployment to finish building". +upsert_var() { + local name="$1" value="$2" + local attempt resp + for attempt in 1 2 3 4 5; do + resp=$(curl -s --max-time 60 -X POST "$API" \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d "{\"query\": \"mutation { variableUpsert(input: { projectId: \\\"$PROJECT_ID\\\", serviceId: \\\"$SERVICE_ID\\\", environmentId: \\\"$ENV_ID\\\", name: \\\"$name\\\", value: \\\"$value\\\", skipDeploys: true }) }\"}" || true) + if echo "$resp" | grep -q '"variableUpsert":true'; then + return 0 + fi + echo "variableUpsert $name attempt $attempt failed (resp: ${resp:0:200}), retrying..." >&2 + [ "$attempt" -lt 5 ] && sleep 5 + done + echo "FATAL: variableUpsert $name failed after 5 attempts" >&2 + exit 1 +} + +upsert_var "CODER_INIT_SCRIPT_B64" "$CODER_INIT_SCRIPT_B64" +upsert_var "CODER_AGENT_TOKEN" "$CODER_AGENT_TOKEN" +upsert_var "RAILWAY_RUN_UID" "0" diff --git a/registry/bpmct/templates/railway/scripts/env_vars_destroy.sh b/registry/bpmct/templates/railway/scripts/env_vars_destroy.sh new file mode 100755 index 000000000..648151bec --- /dev/null +++ b/registry/bpmct/templates/railway/scripts/env_vars_destroy.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Delete the three managed env vars on stop. By this point +# image_deploy's destroy has already cancelled any active deploys, so +# these variableDelete calls cannot trigger a new redeploy. +# +# Env vars required: API, TOKEN, PROJECT_NAME +. "$(dirname "$0")/lib.sh" + +PROJECT_ID=$(lookup_project_id) +[ -z "$PROJECT_ID" ] && exit 0 + +SE=$(lookup_service_and_env "$PROJECT_ID") +SERVICE_ID=$(echo "$SE" | awk '{print $1}') +ENV_ID=$(echo "$SE" | awk '{print $2}') +[ -z "$SERVICE_ID" ] || [ -z "$ENV_ID" ] && exit 0 + +for VAR_NAME in CODER_INIT_SCRIPT_B64 CODER_AGENT_TOKEN RAILWAY_RUN_UID; do + gql "mutation { variableDelete(input: { projectId: \\\"$PROJECT_ID\\\", serviceId: \\\"$SERVICE_ID\\\", environmentId: \\\"$ENV_ID\\\", name: \\\"$VAR_NAME\\\" }) }" || true +done diff --git a/registry/bpmct/templates/railway/scripts/image_deploy_create.sh b/registry/bpmct/templates/railway/scripts/image_deploy_create.sh new file mode 100755 index 000000000..bc168b793 --- /dev/null +++ b/registry/bpmct/templates/railway/scripts/image_deploy_create.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Set the workspace service's image source and trigger the first +# deployment. Replaces source_connect_create.sh in the GraphQL variant. +# +# Unlike `serviceConnect` (which builds from a GitHub repo on every +# start), `serviceInstanceUpdate(source: { image: "..." })` points the +# service at a pre-built container image. Railway just pulls the image +# at start, which removes the per-start Docker build (~30-60s). +# +# Sequence: +# 1. serviceInstanceUpdate(serviceId, environmentId, input: { source: { image }, ... }) +# Updates the source. By itself does NOT trigger a deploy. +# 2. serviceInstanceDeployV2(serviceId, environmentId) +# Explicitly triggers a deploy of the current config. +# +# Env vars required: +# API, TOKEN, PROJECT_NAME, STATE_DIR +# WORKSPACE_IMAGE - e.g. ghcr.io/bpmct/railway-coder-workspace:latest +# IMAGE_REGISTRY_USERNAME - optional, for private registries +# IMAGE_REGISTRY_PASSWORD - optional, for private registries +set -euo pipefail +. "$(dirname "$0")/lib.sh" + +PROJECT_ID="" +SERVICE_ID="" +ENV_ID="" +load_state + +if [ -z "$PROJECT_ID" ] || [ -z "$SERVICE_ID" ] || [ -z "$ENV_ID" ]; then + PROJECT_ID=$(lookup_project_id) + [ -z "$PROJECT_ID" ] && { + echo "FATAL: project '$PROJECT_NAME' not found" + exit 1 + } + SE=$(lookup_service_and_env "$PROJECT_ID") + SERVICE_ID=$(echo "$SE" | awk '{print $1}') + ENV_ID=$(echo "$SE" | awk '{print $2}') +fi +[ -z "$SERVICE_ID" ] && { + echo "FATAL: service not found" + exit 1 +} +[ -z "$ENV_ID" ] && { + echo "FATAL: environment not found" + exit 1 +} + +# Persist IDs for any later resources in this apply cycle (mirrors +# source_connect_create.sh behavior so coder_metadata etc. work). +mkdir -p "$STATE_DIR" +echo "$SERVICE_ID" > "$STATE_DIR/service_id" +echo "$ENV_ID" > "$STATE_DIR/environment_id" + +[ -z "${WORKSPACE_IMAGE:-}" ] && { + echo "FATAL: WORKSPACE_IMAGE is required" + exit 1 +} + +# Build the input object inline in GraphQL. Same nested-escaping pattern +# the other scripts use: the call goes through gql() which wraps the +# string in a JSON envelope, so every quote here is double-escaped. +# +# We omit registryCredentials when not supplied so we don't send an +# empty {username: "", password: ""} block that Railway might reject. +if [ -n "${IMAGE_REGISTRY_USERNAME:-}" ] && [ -n "${IMAGE_REGISTRY_PASSWORD:-}" ]; then + INPUT="{ source: { image: \\\"$WORKSPACE_IMAGE\\\" }, registryCredentials: { username: \\\"$IMAGE_REGISTRY_USERNAME\\\", password: \\\"$IMAGE_REGISTRY_PASSWORD\\\" } }" +else + INPUT="{ source: { image: \\\"$WORKSPACE_IMAGE\\\" } }" +fi + +RESP=$(gql "mutation { serviceInstanceUpdate(serviceId: \\\"$SERVICE_ID\\\", environmentId: \\\"$ENV_ID\\\", input: $INPUT) }") +# Redact creds if present in any error echo. The serviceInstanceUpdate +# return is just `true` on success so this is safe to log normally. +echo "serviceInstanceUpdate response: $RESP" +if ! echo "$RESP" | grep -q '"serviceInstanceUpdate":true'; then + echo "FATAL: serviceInstanceUpdate did not return true" + exit 1 +fi + +# Trigger the actual deploy. serviceInstanceUpdate by itself does not +# enqueue a build in current Railway behavior. serviceInstanceDeployV2 +# returns the new deployment id on success. +DEPLOY_RESP=$(gql "mutation { serviceInstanceDeployV2(serviceId: \\\"$SERVICE_ID\\\", environmentId: \\\"$ENV_ID\\\") }") +echo "serviceInstanceDeployV2 response: $DEPLOY_RESP" +if echo "$DEPLOY_RESP" | grep -q '"errors"'; then + echo "FATAL: serviceInstanceDeployV2 failed" + exit 1 +fi diff --git a/registry/bpmct/templates/railway/scripts/image_deploy_destroy.sh b/registry/bpmct/templates/railway/scripts/image_deploy_destroy.sh new file mode 100755 index 000000000..0e902d502 --- /dev/null +++ b/registry/bpmct/templates/railway/scripts/image_deploy_destroy.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Stop the workspace by cancelling all active deployments. Runs on +# workspace stop (start_count = 0). +# +# Unlike the GraphQL variant's source_connect_destroy.sh, there is no +# `serviceDisconnect` to call: image sources are not "connected" to an +# upstream that could trigger auto-redeploys on their own. We do still +# poll briefly afterwards to catch any deploys that race with the +# env_vars destroy (which runs right after this). +# +# Env vars required: API, TOKEN, PROJECT_NAME +. "$(dirname "$0")/lib.sh" + +PROJECT_ID=$(lookup_project_id) +if [ -z "$PROJECT_ID" ]; then + echo "WARN: project not found, skipping stop" + exit 0 +fi + +SE=$(lookup_service_and_env "$PROJECT_ID") +SERVICE_ID=$(echo "$SE" | awk '{print $1}') +ENV_ID=$(echo "$SE" | awk '{print $2}') +echo "service_id=$SERVICE_ID env_id=$ENV_ID" +[ -z "$SERVICE_ID" ] && { + echo "WARN: service not found" + exit 0 +} + +# Scale to zero by cancelling every recent deployment. deploymentCancel +# reliably stops deployments in any state (BUILDING/DEPLOYING/SUCCESS), +# whereas deploymentStop is unreliable for SUCCESS deployments. +DEPS=$(gql "{ deployments(first: 5, input: { serviceId: \\\"$SERVICE_ID\\\", environmentId: \\\"$ENV_ID\\\" }) { edges { node { id status } } } }") +echo "$DEPS" +for DEP_ID in $(echo "$DEPS" | sed 's/"id":"/\n/g' | grep -o '^[^"]*' | grep -E '^[0-9a-f-]+$' || true); do + echo "Cancelling deployment $DEP_ID" + gql "mutation { deploymentCancel(id: \\\"$DEP_ID\\\") }" || true +done + +# Poll briefly for new deployments triggered by env_vars destroy that +# races with this. Cancel anything that appears. Same pattern as the +# GraphQL variant; cheap insurance. +for _ in 1 2 3 4 5 6; do + sleep 3 + DEPS=$(gql "{ deployments(first: 5, input: { serviceId: \\\"$SERVICE_ID\\\", environmentId: \\\"$ENV_ID\\\" }) { edges { node { id status } } } }") + for DEP_ID in $(echo "$DEPS" | sed 's/"id":"/\n/g' | grep -o '^[^"]*' | grep -E '^[0-9a-f-]+$' || true); do + gql "mutation { deploymentCancel(id: \\\"$DEP_ID\\\") }" > /dev/null || true + done +done +echo "Done" diff --git a/registry/bpmct/templates/railway/scripts/lib.sh b/registry/bpmct/templates/railway/scripts/lib.sh new file mode 100755 index 000000000..443ad0278 --- /dev/null +++ b/registry/bpmct/templates/railway/scripts/lib.sh @@ -0,0 +1,77 @@ +# Common helpers for the Railway GraphQL provisioner scripts. +# Source this file with: . "$(dirname "$0")/lib.sh" +# +# Required env vars set by the caller (TF environment {} block): +# API - Railway GraphQL endpoint +# TOKEN - Railway API token (Bearer) +# Optional: +# PROJECT_NAME - Railway project name (used by lookup helpers) +# STATE_DIR - Local state directory holding *_id files + +# Send a GraphQL query/mutation. The query string is embedded inside a +# JSON envelope via a temp file so we never need to shell-escape it. +# Stdout is the response body. Caller decides how to parse. +gql() { + local query="$1" + local tmpjson + tmpjson=$(mktemp) + printf '{"query": "%s"}' "$query" > "$tmpjson" + curl -s --max-time 120 -X POST "$API" \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d @"$tmpjson" + local rc=$? + rm -f "$tmpjson" + return $rc +} + +# Lookup project id + first environment id by $PROJECT_NAME. Prints +# " " on success, empty on miss. Always returns 0. +lookup_project_and_env() { + local resp pid env_id + resp=$(gql '{ projects { edges { node { id name environments { edges { node { id name } } } } } } }' || echo '') + pid=$(echo "$resp" | grep -o '"id":"[^"]*","name":"'"$PROJECT_NAME"'"' \ + | sed 's/.*"id":"\([^"]*\)".*/\1/' | head -1 || true) + if [ -z "$pid" ]; then return 0; fi + env_id=$(echo "$resp" | sed 's/.*"id":"'"$pid"'","name":"'"$PROJECT_NAME"'","environments":{"edges":\[{"node":{"id":"\([^"]*\)".*/\1/' | head -1 || true) + # Detect "no replacement" case: sed prints the input unchanged. Falls + # back to a coarser grep that picks the first environment id seen. + if [ "${#env_id}" -gt 100 ]; then + env_id=$(echo "$resp" | grep -o '"environments":{"edges":\[{"node":{"id":"[^"]*"' \ + | sed 's/.*"id":"\([^"]*\)".*/\1/' | head -1 || true) + fi + printf '%s %s\n' "$pid" "$env_id" +} + +# Lookup project id only. Prints the id, empty on miss. Always returns 0. +lookup_project_id() { + local resp + resp=$(gql '{ projects { edges { node { id name } } } }' || echo '') + echo "$resp" | grep -o '"id":"[^"]*","name":"'"$PROJECT_NAME"'"' \ + | sed 's/.*"id":"\([^"]*\)".*/\1/' | head -1 || true +} + +# Lookup service id and env id inside a project. Args: project_id. +# Prints " ". Service name fixed to "workspace", +# env name fixed to "production" to match the rest of the template. +lookup_service_and_env() { + local pid="$1" + local resp svc_id env_id + resp=$(gql "{ project(id: \\\"$pid\\\") { services { edges { node { id name } } } environments { edges { node { id name } } } } }" || echo '') + svc_id=$(echo "$resp" | grep -o '"id":"[^"]*","name":"workspace"' \ + | sed 's/.*"id":"\([^"]*\)".*/\1/' | head -1 || true) + env_id=$(echo "$resp" | grep -o '"id":"[^"]*","name":"production"' \ + | sed 's/.*"id":"\([^"]*\)".*/\1/' | head -1 || true) + printf '%s %s\n' "$svc_id" "$env_id" +} + +# Load PROJECT_ID, SERVICE_ID, ENV_ID from $STATE_DIR if files exist. +# Sets the globals; never errors. Intended to be called before any +# fallback Railway API lookup. +load_state() { + [ -n "${STATE_DIR:-}" ] || return 0 + [ -f "$STATE_DIR/project_id" ] && PROJECT_ID=$(cat "$STATE_DIR/project_id") + [ -f "$STATE_DIR/service_id" ] && SERVICE_ID=$(cat "$STATE_DIR/service_id") + [ -f "$STATE_DIR/environment_id" ] && ENV_ID=$(cat "$STATE_DIR/environment_id") + return 0 +} diff --git a/registry/bpmct/templates/railway/scripts/project_create.sh b/registry/bpmct/templates/railway/scripts/project_create.sh new file mode 100755 index 000000000..386eddf00 --- /dev/null +++ b/registry/bpmct/templates/railway/scripts/project_create.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Create (or look up) the Railway project for this workspace. +# Idempotent: reuses an existing project of the same name if found. +# Writes project_id and environment_id to $STATE_DIR. +# +# Env vars required: +# API, TOKEN, PROJECT_NAME, STATE_DIR +set -euo pipefail +. "$(dirname "$0")/lib.sh" + +mkdir -p "$STATE_DIR" + +# If a project with this name already exists (prior attempt succeeded +# but TF lost the response), reuse it instead of creating a duplicate. +EXISTING=$(lookup_project_and_env) +if [ -n "$EXISTING" ]; then + EXISTING_PID=$(echo "$EXISTING" | awk '{print $1}') + EXISTING_EID=$(echo "$EXISTING" | awk '{print $2}') + if [ -n "$EXISTING_PID" ]; then + echo "Project $PROJECT_NAME already exists: $EXISTING_PID" + echo "$EXISTING_PID" > "$STATE_DIR/project_id" + echo "$EXISTING_EID" > "$STATE_DIR/environment_id" + exit 0 + fi +fi + +# Retry projectCreate. Railway projectCreate can take 30s+ under load, +# exceeding edge timeouts (504). A 504 may still have created the +# project, so we look up by name before each retry. +BACKOFF=5 +for ATTEMPT in 1 2 3 4 5; do + echo "projectCreate attempt $ATTEMPT/5" + HTTP_CODE=$(curl -s --max-time 90 -o /tmp/proj-resp.$$ -w '%{http_code}' -X POST "$API" \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d "{\"query\": \"mutation(\$input: ProjectCreateInput!) { projectCreate(input: \$input) { id environments { edges { node { id name } } } } }\", \"variables\": { \"input\": { \"name\": \"$PROJECT_NAME\" } } }" || echo '000') + RESP=$(cat /tmp/proj-resp.$$ 2> /dev/null || echo '') + rm -f /tmp/proj-resp.$$ + echo "HTTP $HTTP_CODE" + echo "$RESP" | head -c 500 + echo + + # Success path: HTTP 200 with projectCreate.id in response. + if [ "$HTTP_CODE" = "200" ] && echo "$RESP" | grep -q '"projectCreate":{"id":"'; then + echo "$RESP" | sed 's/.*"projectCreate":{"id":"\([^"]*\)".*/\1/' > "$STATE_DIR/project_id" + echo "$RESP" | sed 's/.*"node":{"id":"\([^"]*\)".*/\1/' > "$STATE_DIR/environment_id" + echo "projectCreate succeeded" + exit 0 + fi + + # On any non-success, check whether the project got created anyway + # (504 or client timeout but the mutation still landed). + AFTER=$(lookup_project_and_env) + if [ -n "$AFTER" ]; then + AFTER_PID=$(echo "$AFTER" | awk '{print $1}') + AFTER_EID=$(echo "$AFTER" | awk '{print $2}') + if [ -n "$AFTER_PID" ]; then + echo "Found project created by attempt $ATTEMPT despite error: $AFTER_PID" + echo "$AFTER_PID" > "$STATE_DIR/project_id" + echo "$AFTER_EID" > "$STATE_DIR/environment_id" + exit 0 + fi + fi + + echo "projectCreate attempt $ATTEMPT failed (HTTP $HTTP_CODE), retrying in ${BACKOFF}s..." + [ "$ATTEMPT" -lt 5 ] && sleep "$BACKOFF" + BACKOFF=$((BACKOFF * 2)) +done + +echo "FATAL: projectCreate failed after 5 attempts" +exit 1 diff --git a/registry/bpmct/templates/railway/scripts/project_destroy.sh b/registry/bpmct/templates/railway/scripts/project_destroy.sh new file mode 100755 index 000000000..d73a7cbd3 --- /dev/null +++ b/registry/bpmct/templates/railway/scripts/project_destroy.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Delete the Railway project for this workspace. Looks up by name so +# we do not need state files to be present at destroy time. +# +# Env vars required: API, TOKEN, PROJECT_NAME +. "$(dirname "$0")/lib.sh" + +PROJECT_ID=$(lookup_project_id) +if [ -z "$PROJECT_ID" ]; then + echo "Project $PROJECT_NAME not found, nothing to delete" + exit 0 +fi + +echo "Deleting project $PROJECT_NAME ($PROJECT_ID)" +gql "mutation { projectDelete(id: \\\"$PROJECT_ID\\\") }" || true diff --git a/registry/bpmct/templates/railway/scripts/project_token_create.sh b/registry/bpmct/templates/railway/scripts/project_token_create.sh new file mode 100755 index 000000000..9d1508181 --- /dev/null +++ b/registry/bpmct/templates/railway/scripts/project_token_create.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# Provision a project-scoped Railway token (named "coder-managed") and +# upsert it as RAILWAY_TOKEN on the workspace service. The token is +# rotated on every apply because Railway does not expose existing +# token values after creation. +# +# Env vars required: +# API, TOKEN, PROJECT_NAME, STATE_DIR +set -euo pipefail +. "$(dirname "$0")/lib.sh" + +TOKEN_NAME='coder-managed' + +PROJECT_ID="" +SERVICE_ID="" +ENV_ID="" +load_state + +if [ -z "$PROJECT_ID" ] || [ -z "$SERVICE_ID" ] || [ -z "$ENV_ID" ]; then + PE=$(lookup_project_and_env) + PROJECT_ID=$(echo "$PE" | awk '{print $1}') + ENV_ID=$(echo "$PE" | awk '{print $2}') + [ -z "$PROJECT_ID" ] && { + echo "FATAL: project $PROJECT_NAME not found" >&2 + exit 1 + } + DETAIL=$(gql "{ project(id: \\\"$PROJECT_ID\\\") { services { edges { node { id name } } } } }") + SERVICE_ID=$(echo "$DETAIL" | grep -o '"id":"[^"]*","name":"workspace"' \ + | sed 's/.*"id":"\([^"]*\)".*/\1/' | head -1 || true) +fi +[ -z "$SERVICE_ID" ] || [ -z "$ENV_ID" ] && { + echo "FATAL: service or env not found" >&2 + exit 1 +} + +# Project tokens cannot be retrieved by value after creation. Delete +# any existing token with our managed name so we can mint a fresh one. +EXISTING=$(gql "{ projectTokens(projectId: \\\"$PROJECT_ID\\\") { edges { node { id name } } } }") +OLD_ID=$(echo "$EXISTING" | grep -o '"id":"[^"]*","name":"'"$TOKEN_NAME"'"' \ + | sed 's/.*"id":"\([^"]*\)".*/\1/' | head -1 || true) +if [ -n "$OLD_ID" ]; then + echo "Removing existing project token $OLD_ID" + gql "mutation { projectTokenDelete(id: \\\"$OLD_ID\\\") }" > /dev/null || true +fi + +# Create the project-scoped token. +RESP=$(gql "mutation { projectTokenCreate(input: { projectId: \\\"$PROJECT_ID\\\", environmentId: \\\"$ENV_ID\\\", name: \\\"$TOKEN_NAME\\\" }) }") +if echo "$RESP" | grep -q '"errors"'; then + echo "FATAL: projectTokenCreate failed: $RESP" >&2 + exit 1 +fi +NEW_TOKEN=$(echo "$RESP" | sed 's/.*"projectTokenCreate":"\([^"]*\)".*/\1/') +if [ -z "$NEW_TOKEN" ] || [ "$NEW_TOKEN" = "$RESP" ]; then + echo "FATAL: could not parse projectTokenCreate response: $RESP" >&2 + exit 1 +fi + +# Upsert RAILWAY_TOKEN on the workspace service with retry. +# skipDeploys: true avoids triggering a Railway redeploy on the +# variable change. The subsequent serviceConnect (or already-in-flight +# deploy) picks up the new value. +UPSERT_OK="" +for ATTEMPT in 1 2 3 4 5; do + UPSERT=$(curl -s --max-time 60 -X POST "$API" \ + -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' \ + -d "{\"query\": \"mutation { variableUpsert(input: { projectId: \\\"$PROJECT_ID\\\", serviceId: \\\"$SERVICE_ID\\\", environmentId: \\\"$ENV_ID\\\", name: \\\"RAILWAY_TOKEN\\\", value: \\\"$NEW_TOKEN\\\", skipDeploys: true }) }\"}" || true) + if echo "$UPSERT" | grep -q '"variableUpsert":true'; then + UPSERT_OK=1 + break + fi + echo "variableUpsert RAILWAY_TOKEN attempt $ATTEMPT failed, retrying..." >&2 + [ "$ATTEMPT" -lt 5 ] && sleep 5 +done +if [ -z "$UPSERT_OK" ]; then + echo "FATAL: variableUpsert RAILWAY_TOKEN failed after 5 attempts" >&2 + exit 1 +fi +echo "Provisioned project-scoped Railway token and set RAILWAY_TOKEN env var." diff --git a/registry/bpmct/templates/railway/scripts/project_token_destroy.sh b/registry/bpmct/templates/railway/scripts/project_token_destroy.sh new file mode 100755 index 000000000..92da3686d --- /dev/null +++ b/registry/bpmct/templates/railway/scripts/project_token_destroy.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Delete the coder-managed project token and the RAILWAY_TOKEN env var. +# Safe to run when the project is already gone (no-op in that case). +# +# Env vars required: API, TOKEN, PROJECT_NAME +. "$(dirname "$0")/lib.sh" + +TOKEN_NAME='coder-managed' + +PE=$(lookup_project_and_env) +PROJ=$(echo "$PE" | awk '{print $1}') +ENV=$(echo "$PE" | awk '{print $2}') +if [ -z "$PROJ" ]; then + echo "Project $PROJECT_NAME already gone, nothing to clean up." + exit 0 +fi + +DETAIL=$(gql "{ project(id: \\\"$PROJ\\\") { services { edges { node { id name } } } } }" || echo '') +SVC=$(echo "$DETAIL" | grep -o '"id":"[^"]*","name":"workspace"' \ + | sed 's/.*"id":"\([^"]*\)".*/\1/' | head -1 || true) + +# Delete the project token if it exists. +EXISTING=$(gql "{ projectTokens(projectId: \\\"$PROJ\\\") { edges { node { id name } } } }" || echo '') +TOKEN_ID=$(echo "$EXISTING" | grep -o '"id":"[^"]*","name":"'"$TOKEN_NAME"'"' \ + | sed 's/.*"id":"\([^"]*\)".*/\1/' | head -1 || true) +if [ -n "$TOKEN_ID" ]; then + gql "mutation { projectTokenDelete(id: \\\"$TOKEN_ID\\\") }" > /dev/null || true +fi + +# Delete the RAILWAY_TOKEN env var. +if [ -n "$SVC" ] && [ -n "$ENV" ]; then + gql "mutation { variableDelete(input: { projectId: \\\"$PROJ\\\", serviceId: \\\"$SVC\\\", environmentId: \\\"$ENV\\\", name: \\\"RAILWAY_TOKEN\\\" }) }" > /dev/null || true +fi diff --git a/registry/bpmct/templates/railway/scripts/service_create.sh b/registry/bpmct/templates/railway/scripts/service_create.sh new file mode 100755 index 000000000..59339c4df --- /dev/null +++ b/registry/bpmct/templates/railway/scripts/service_create.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Create (or look up) the "workspace" service inside the Railway +# project. Idempotent. Writes service_id to $STATE_DIR. +# +# Env vars required: API, TOKEN, PROJECT_NAME, STATE_DIR +set -euo pipefail +. "$(dirname "$0")/lib.sh" + +# Read project_id from state (same apply) or fall back to API lookup. +PROJECT_ID="" +[ -f "$STATE_DIR/project_id" ] && PROJECT_ID=$(cat "$STATE_DIR/project_id") +if [ -z "$PROJECT_ID" ]; then + PROJECT_ID=$(lookup_project_id) +fi +[ -z "$PROJECT_ID" ] && { + echo "FATAL: project not found" + exit 1 +} + +# If a "workspace" service already exists, reuse it. +EXISTING=$(gql "{ project(id: \\\"$PROJECT_ID\\\") { services { edges { node { id name } } } } }") +EXISTING_SVC=$(echo "$EXISTING" | grep -o '"id":"[^"]*","name":"workspace"' \ + | sed 's/.*"id":"\([^"]*\)".*/\1/' | head -1 || true) +if [ -n "$EXISTING_SVC" ]; then + echo "Service already exists: $EXISTING_SVC" + mkdir -p "$STATE_DIR" + echo "$EXISTING_SVC" > "$STATE_DIR/service_id" + exit 0 +fi + +# Retry serviceCreate. Railway can return "Not Authorized" briefly +# after projectCreate due to auth propagation delay. +RESP="" +for ATTEMPT in 1 2 3 4 5; do + RESP=$(gql "mutation { serviceCreate(input: { name: \\\"workspace\\\", projectId: \\\"$PROJECT_ID\\\" }) { id } }") + echo "$RESP" + if echo "$RESP" | grep -q '"serviceCreate"'; then break; fi + echo "serviceCreate attempt $ATTEMPT failed, retrying in 3s..." + [ "$ATTEMPT" -lt 5 ] && sleep 3 +done +if ! echo "$RESP" | grep -q '"serviceCreate"'; then + echo "FATAL: serviceCreate failed" + exit 1 +fi + +mkdir -p "$STATE_DIR" +echo "$RESP" | sed 's/.*"serviceCreate":{"id":"\([^"]*\)".*/\1/' > "$STATE_DIR/service_id" diff --git a/registry/bpmct/templates/railway/scripts/service_destroy.sh b/registry/bpmct/templates/railway/scripts/service_destroy.sh new file mode 100755 index 000000000..678414280 --- /dev/null +++ b/registry/bpmct/templates/railway/scripts/service_destroy.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Delete the workspace service (nice-to-have; cascade from +# projectDelete already covers this). +# +# Env vars required: API, TOKEN +# Reads service id from local .railway-state/service_id if present. +. "$(dirname "$0")/lib.sh" + +STATE_DIR=".railway-state" +[ ! -f "$STATE_DIR/service_id" ] && exit 0 +SERVICE_ID=$(cat "$STATE_DIR/service_id") + +gql "mutation { serviceDelete(id: \\\"$SERVICE_ID\\\") }" || true diff --git a/registry/bpmct/templates/railway/scripts/volume_create.sh b/registry/bpmct/templates/railway/scripts/volume_create.sh new file mode 100755 index 000000000..901018107 --- /dev/null +++ b/registry/bpmct/templates/railway/scripts/volume_create.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Create the persistent volume for the workspace. +# +# CRITICAL: This must run before any deployment activity on the +# service. Railway rejects volumeCreate on services that have had +# deployments. The pure-GraphQL ordering guarantees this. +# +# Env vars required: API, TOKEN, PROJECT_NAME, STATE_DIR +set -euo pipefail +. "$(dirname "$0")/lib.sh" + +PROJECT_ID="" +SERVICE_ID="" +ENV_ID="" +load_state + +if [ -z "$PROJECT_ID" ] || [ -z "$SERVICE_ID" ] || [ -z "$ENV_ID" ]; then + PROJECT_ID=$(lookup_project_id) + [ -z "$PROJECT_ID" ] && { + echo "FATAL: project not found" + exit 1 + } + SE=$(lookup_service_and_env "$PROJECT_ID") + SERVICE_ID=$(echo "$SE" | awk '{print $1}') + ENV_ID=$(echo "$SE" | awk '{print $2}') +fi +[ -z "$SERVICE_ID" ] || [ -z "$ENV_ID" ] && { + echo "FATAL: service/env not found" + exit 1 +} + +# Idempotent: if a workspace-volume already exists in this project, +# reuse it instead of creating a duplicate. +VOLUMES=$(gql "{ project(id: \\\"$PROJECT_ID\\\") { volumes { edges { node { id name } } } } }") +EXISTING_VOL=$(echo "$VOLUMES" | grep -o '"id":"[^"]*","name":"workspace-volume"' \ + | sed 's/.*"id":"\([^"]*\)".*/\1/' | head -1 || true) +if [ -n "$EXISTING_VOL" ]; then + echo "Volume already exists: $EXISTING_VOL" + exit 0 +fi + +RESP=$(gql "mutation { volumeCreate(input: { projectId: \\\"$PROJECT_ID\\\", serviceId: \\\"$SERVICE_ID\\\", environmentId: \\\"$ENV_ID\\\", mountPath: \\\"/home/coder\\\" }) { id } }") +echo "$RESP" +if echo "$RESP" | grep -q '"errors"'; then + echo "FATAL: volumeCreate failed" + exit 1 +fi + +echo "$RESP" | sed 's/.*"volumeCreate":{"id":"\([^"]*\)".*/\1/' > "$STATE_DIR/volume_id"