railway-sdk 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- railway_sdk-0.2.0/LICENSE +21 -0
- railway_sdk-0.2.0/PKG-INFO +41 -0
- railway_sdk-0.2.0/README.md +29 -0
- railway_sdk-0.2.0/pyproject.toml +20 -0
- railway_sdk-0.2.0/setup.cfg +4 -0
- railway_sdk-0.2.0/src/railway_sdk/__init__.py +473 -0
- railway_sdk-0.2.0/src/railway_sdk.egg-info/PKG-INFO +41 -0
- railway_sdk-0.2.0/src/railway_sdk.egg-info/SOURCES.txt +11 -0
- railway_sdk-0.2.0/src/railway_sdk.egg-info/dependency_links.txt +1 -0
- railway_sdk-0.2.0/src/railway_sdk.egg-info/requires.txt +3 -0
- railway_sdk-0.2.0/src/railway_sdk.egg-info/top_level.txt +1 -0
- railway_sdk-0.2.0/tests/test_cli_eval.py +50 -0
- railway_sdk-0.2.0/tests/test_graph.py +84 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Railway Corp.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: railway-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Thin Railway Infrastructure as Code authoring helpers (Python)
|
|
5
|
+
Project-URL: Homepage, https://docs.railway.com/infrastructure-as-code
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# Railway Infrastructure as Code (IaC) authoring helpers for Python.
|
|
14
|
+
|
|
15
|
+
Install: `pip install railway-sdk` (or `pip install -e .` from this repo) then
|
|
16
|
+
author `.railway/railway.py`. Prefer **one file per project** that owns the
|
|
17
|
+
whole environment. Named partials are a last resort for split repos that cannot
|
|
18
|
+
share a file.
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from railway_sdk import define_railway, github, postgres, project, service
|
|
22
|
+
|
|
23
|
+
@define_railway
|
|
24
|
+
def main(ctx=None):
|
|
25
|
+
db = postgres("db")
|
|
26
|
+
web = service(
|
|
27
|
+
"web",
|
|
28
|
+
source=github("org/app"),
|
|
29
|
+
start="gunicorn app:app",
|
|
30
|
+
env={"DATABASE_URL": db.env.DATABASE_URL},
|
|
31
|
+
)
|
|
32
|
+
return project("my-app", resources=[db, web])
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The CLI evaluates this file and diffs against the linked environment.
|
|
36
|
+
Config as Code migration lives in the CLI (`railway config migrate --lang py`).
|
|
37
|
+
|
|
38
|
+
Last resort only: set module-level `PARTIAL = "api"` (same role as
|
|
39
|
+
`export const partial` in TypeScript). Do not rename a partial after apply.
|
|
40
|
+
|
|
41
|
+
See https://docs.railway.com/infrastructure-as-code
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Railway Infrastructure as Code (IaC) authoring helpers for Python.
|
|
2
|
+
|
|
3
|
+
Install: `pip install railway-sdk` (or `pip install -e .` from this repo) then
|
|
4
|
+
author `.railway/railway.py`. Prefer **one file per project** that owns the
|
|
5
|
+
whole environment. Named partials are a last resort for split repos that cannot
|
|
6
|
+
share a file.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from railway_sdk import define_railway, github, postgres, project, service
|
|
10
|
+
|
|
11
|
+
@define_railway
|
|
12
|
+
def main(ctx=None):
|
|
13
|
+
db = postgres("db")
|
|
14
|
+
web = service(
|
|
15
|
+
"web",
|
|
16
|
+
source=github("org/app"),
|
|
17
|
+
start="gunicorn app:app",
|
|
18
|
+
env={"DATABASE_URL": db.env.DATABASE_URL},
|
|
19
|
+
)
|
|
20
|
+
return project("my-app", resources=[db, web])
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The CLI evaluates this file and diffs against the linked environment.
|
|
24
|
+
Config as Code migration lives in the CLI (`railway config migrate --lang py`).
|
|
25
|
+
|
|
26
|
+
Last resort only: set module-level `PARTIAL = "api"` (same role as
|
|
27
|
+
`export const partial` in TypeScript). Do not rename a partial after apply.
|
|
28
|
+
|
|
29
|
+
See https://docs.railway.com/infrastructure-as-code
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "railway-sdk"
|
|
3
|
+
version = "0.2.0"
|
|
4
|
+
description = "Thin Railway Infrastructure as Code authoring helpers (Python)"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = []
|
|
8
|
+
|
|
9
|
+
[project.optional-dependencies]
|
|
10
|
+
dev = ["pytest>=8"]
|
|
11
|
+
|
|
12
|
+
[project.urls]
|
|
13
|
+
Homepage = "https://docs.railway.com/infrastructure-as-code"
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["setuptools>=61"]
|
|
17
|
+
build-backend = "setuptools.build_meta"
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
where = ["src"]
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
"""Thin Railway Infrastructure as Code authoring helpers for Python.
|
|
2
|
+
|
|
3
|
+
Mirrors `railway/iac` (TypeScript). Compiles to the same RailwayGraph shape;
|
|
4
|
+
plan/apply stay in the CLI. No Config as Code knowledge here.
|
|
5
|
+
|
|
6
|
+
Prefer one file that owns the whole environment. Set module-level
|
|
7
|
+
``PARTIAL = "api"`` only when split repos cannot share a file.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
from typing import Any, Callable, Iterable, Mapping, Sequence
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Project",
|
|
17
|
+
"Service",
|
|
18
|
+
"bucket",
|
|
19
|
+
"createRailwayContext",
|
|
20
|
+
"create_railway_context",
|
|
21
|
+
"database",
|
|
22
|
+
"defineRailway",
|
|
23
|
+
"define_railway",
|
|
24
|
+
"empty",
|
|
25
|
+
"fn",
|
|
26
|
+
"github",
|
|
27
|
+
"group",
|
|
28
|
+
"image",
|
|
29
|
+
"mongo",
|
|
30
|
+
"mysql",
|
|
31
|
+
"postgres",
|
|
32
|
+
"preserve",
|
|
33
|
+
"project",
|
|
34
|
+
"redis",
|
|
35
|
+
"ref",
|
|
36
|
+
"service",
|
|
37
|
+
"template",
|
|
38
|
+
"volume",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _EnvRefs:
|
|
43
|
+
def __init__(self, address: str) -> None:
|
|
44
|
+
self._address = address
|
|
45
|
+
|
|
46
|
+
def __getattr__(self, name: str) -> dict[str, str]:
|
|
47
|
+
if name.startswith("_"):
|
|
48
|
+
raise AttributeError(name)
|
|
49
|
+
return {"type": "reference", "resource": self._address, "output": name}
|
|
50
|
+
|
|
51
|
+
def __getitem__(self, name: str) -> dict[str, str]:
|
|
52
|
+
return {"type": "reference", "resource": self._address, "output": name}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Service:
|
|
56
|
+
"""A service or database node with ``env`` variable refs."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, node: Mapping[str, Any]) -> None:
|
|
59
|
+
self._node = dict(node)
|
|
60
|
+
self.name = self._node["name"]
|
|
61
|
+
self.address = self._node["address"]
|
|
62
|
+
self.type = self._node.get("type", "service")
|
|
63
|
+
self.env = _EnvRefs(self.address)
|
|
64
|
+
|
|
65
|
+
def to_graph(self) -> dict[str, Any]:
|
|
66
|
+
return dict(self._node)
|
|
67
|
+
|
|
68
|
+
def with_fields(self, **fields: Any) -> Service:
|
|
69
|
+
return Service({**self._node, **fields})
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Project:
|
|
73
|
+
def __init__(self, name: str, resources: Sequence[Any], extra: Mapping[str, Any] | None = None) -> None:
|
|
74
|
+
self.name = name
|
|
75
|
+
self.resources = list(resources)
|
|
76
|
+
self._extra = dict(extra or {})
|
|
77
|
+
|
|
78
|
+
def to_graph(self) -> dict[str, Any]:
|
|
79
|
+
return {
|
|
80
|
+
"name": self.name,
|
|
81
|
+
**self._extra,
|
|
82
|
+
"resources": [_to_graph(item) for item in _flatten(self.resources)],
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def define_railway(program: Callable[..., Any]) -> Callable[..., Any]:
|
|
87
|
+
return program
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
defineRailway = define_railway
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _SharedRefs:
|
|
94
|
+
def __getattr__(self, name: str) -> dict[str, str]:
|
|
95
|
+
if name.startswith("_"):
|
|
96
|
+
raise AttributeError(name)
|
|
97
|
+
return {"type": "sharedReference", "name": name}
|
|
98
|
+
|
|
99
|
+
def __getitem__(self, name: str) -> dict[str, str]:
|
|
100
|
+
return {"type": "sharedReference", "name": name}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class RailwayContext(dict):
|
|
104
|
+
def __init__(self, payload: Mapping[str, Any]) -> None:
|
|
105
|
+
super().__init__(payload)
|
|
106
|
+
self.environment = payload.get("environment") or payload.get("environmentName")
|
|
107
|
+
self.shared = _SharedRefs()
|
|
108
|
+
|
|
109
|
+
def random_string(self, label: str = "random", bytes: int = 12) -> str:
|
|
110
|
+
seed = f"railway-iac:{self.environment or 'default'}:{label}"
|
|
111
|
+
return hashlib.sha256(seed.encode()).hexdigest()[: bytes * 2]
|
|
112
|
+
|
|
113
|
+
def is_environment(self, name: str) -> bool:
|
|
114
|
+
return self.environment == name
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def create_railway_context(input: Mapping[str, Any] | None = None, **kwargs: Any) -> RailwayContext:
|
|
118
|
+
payload = {**(input or {}), **kwargs}
|
|
119
|
+
environment = payload.get("environment") or payload.get("environmentName")
|
|
120
|
+
if environment:
|
|
121
|
+
payload["environment"] = environment
|
|
122
|
+
payload["environmentName"] = environment
|
|
123
|
+
return RailwayContext(payload)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
createRailwayContext = create_railway_context
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def project(name: str, definition: Mapping[str, Any] | None = None, **kwargs: Any) -> Project:
|
|
130
|
+
payload = {**(definition or {}), **kwargs}
|
|
131
|
+
resources = payload.pop("resources", None)
|
|
132
|
+
services = payload.pop("services", None)
|
|
133
|
+
return Project(name, _flatten(resources if resources is not None else services or []), payload)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def github(repo: str, **options: Any) -> dict[str, Any]:
|
|
137
|
+
if options.get("autoUpdates") is not None:
|
|
138
|
+
raise ValueError("Image auto updates are only supported for Docker image sources.")
|
|
139
|
+
return _prune({"type": "github", "repo": repo, "branch": options.pop("branch", "main"), **options})
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def image(image_name: str, **options: Any) -> dict[str, Any]:
|
|
143
|
+
if options.get("autoUpdates") is not None and not _supports_image_auto_updates(image_name):
|
|
144
|
+
raise ValueError("Image auto updates are only supported for Docker Hub and GHCR images.")
|
|
145
|
+
return _prune({"type": "image", "image": image_name, **options})
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def template(template_name: str, **options: Any) -> dict[str, Any]:
|
|
149
|
+
return _prune({"type": "template", "template": template_name, **options})
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def empty(**options: Any) -> dict[str, Any]:
|
|
153
|
+
return _prune({"type": "empty", **options})
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def service(name: str, **config: Any) -> Service:
|
|
157
|
+
return Service(_service_node(name, config))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def fn(name: str, **config: Any) -> Service:
|
|
161
|
+
return Service({**_service_node(name, config), "kind": "function"})
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def postgres(name: str, **config: Any) -> Service:
|
|
165
|
+
return database(
|
|
166
|
+
name,
|
|
167
|
+
"postgres",
|
|
168
|
+
image="ghcr.io/railwayapp-templates/postgres-ssl:18",
|
|
169
|
+
output="DATABASE_URL",
|
|
170
|
+
defaultMountPath="/var/lib/postgresql/data",
|
|
171
|
+
**config,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def mysql(name: str, **config: Any) -> Service:
|
|
176
|
+
return database(
|
|
177
|
+
name,
|
|
178
|
+
"mysql",
|
|
179
|
+
image="mysql:9",
|
|
180
|
+
output="MYSQL_URL",
|
|
181
|
+
defaultMountPath="/var/lib/mysql",
|
|
182
|
+
**config,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def redis(name: str, **config: Any) -> Service:
|
|
187
|
+
return database(
|
|
188
|
+
name,
|
|
189
|
+
"redis",
|
|
190
|
+
image="railwayapp/redis:8.2",
|
|
191
|
+
output="REDIS_URL",
|
|
192
|
+
defaultMountPath="/bitnami",
|
|
193
|
+
**config,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def mongo(name: str, **config: Any) -> Service:
|
|
198
|
+
return database(
|
|
199
|
+
name,
|
|
200
|
+
"mongo",
|
|
201
|
+
image="mongo:8",
|
|
202
|
+
output="MONGO_URL",
|
|
203
|
+
defaultMountPath="/data/db",
|
|
204
|
+
**config,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def database(name: str, engine: str, **options: Any) -> Service:
|
|
209
|
+
image_name = options["image"]
|
|
210
|
+
output = options.get("output") or "DATABASE_URL"
|
|
211
|
+
node: dict[str, Any] = {
|
|
212
|
+
"address": f"database.{name}",
|
|
213
|
+
"type": "database",
|
|
214
|
+
"kind": "database",
|
|
215
|
+
"engine": engine,
|
|
216
|
+
"name": name,
|
|
217
|
+
"image": image_name,
|
|
218
|
+
"output": output,
|
|
219
|
+
"source": image(image_name),
|
|
220
|
+
}
|
|
221
|
+
if options.get("defaultMountPath"):
|
|
222
|
+
node["defaultMountPath"] = options["defaultMountPath"]
|
|
223
|
+
if options.get("region"):
|
|
224
|
+
node["deploy"] = {"multiRegionConfig": {options["region"]: {"numReplicas": 1}}}
|
|
225
|
+
return Service(node)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def volume(name: str, config: Mapping[str, Any] | None = None, **extra: Any) -> Service:
|
|
229
|
+
return Service(
|
|
230
|
+
{
|
|
231
|
+
"address": f"volume.{name}",
|
|
232
|
+
"type": "volume",
|
|
233
|
+
"name": name,
|
|
234
|
+
"config": {**(config or {}), **extra},
|
|
235
|
+
}
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def bucket(name: str, config: Mapping[str, Any] | None = None, **extra: Any) -> Service:
|
|
240
|
+
return Service(
|
|
241
|
+
{
|
|
242
|
+
"address": f"bucket.{name}",
|
|
243
|
+
"type": "bucket",
|
|
244
|
+
"name": name,
|
|
245
|
+
"config": {**(config or {}), **extra},
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def group(name: str, resources: Sequence[Any] | Mapping[str, Any] | None = None, options: Mapping[str, Any] | None = None) -> Service | list[Any]:
|
|
251
|
+
if isinstance(resources, Mapping) and options is None:
|
|
252
|
+
options = resources
|
|
253
|
+
resources = None
|
|
254
|
+
node = Service({"address": f"group.{name}", "type": "group", "name": name, **(options or {})})
|
|
255
|
+
if resources is None:
|
|
256
|
+
return node
|
|
257
|
+
tagged = []
|
|
258
|
+
for item in _flatten(resources):
|
|
259
|
+
if isinstance(item, Service):
|
|
260
|
+
tagged.append(item.with_fields(groupId=name))
|
|
261
|
+
elif isinstance(item, Mapping):
|
|
262
|
+
tagged.append({**item, "groupId": name})
|
|
263
|
+
else:
|
|
264
|
+
tagged.append(item)
|
|
265
|
+
return [node, *tagged]
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def ref(resource: Any, output: str) -> dict[str, str]:
|
|
269
|
+
address = resource.address if hasattr(resource, "address") else resource["address"]
|
|
270
|
+
return {"type": "reference", "resource": address, "output": output}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def preserve() -> dict[str, str]:
|
|
274
|
+
return {"type": "preserve"}
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _service_node(name: str, config: Mapping[str, Any]) -> dict[str, Any]:
|
|
278
|
+
source = _normalize_source(config.get("source"), config.get("root") or config.get("rootDirectory"))
|
|
279
|
+
kind = "empty"
|
|
280
|
+
if source:
|
|
281
|
+
kind = {
|
|
282
|
+
"github": "github",
|
|
283
|
+
"image": "docker-image",
|
|
284
|
+
"template": "template",
|
|
285
|
+
}.get(source.get("type"), "empty")
|
|
286
|
+
node: dict[str, Any] = {
|
|
287
|
+
"address": f"service.{name}",
|
|
288
|
+
"type": "service",
|
|
289
|
+
"kind": kind,
|
|
290
|
+
"name": name,
|
|
291
|
+
}
|
|
292
|
+
if source:
|
|
293
|
+
node["source"] = source
|
|
294
|
+
build = _normalize_build(config)
|
|
295
|
+
if build is not None:
|
|
296
|
+
node["build"] = build
|
|
297
|
+
deploy = _normalize_deploy(config)
|
|
298
|
+
if deploy is not None:
|
|
299
|
+
node["deploy"] = deploy
|
|
300
|
+
networking = _normalize_networking(config)
|
|
301
|
+
if networking is not None:
|
|
302
|
+
node["networking"] = networking
|
|
303
|
+
variables = config.get("env") or config.get("variables")
|
|
304
|
+
if variables:
|
|
305
|
+
merged = {**(config.get("variables") or {}), **(config.get("env") or {})}
|
|
306
|
+
node["variables"] = _normalize_variables(merged)
|
|
307
|
+
node.update(_normalize_volume_mounts(config.get("volumeMounts")))
|
|
308
|
+
for key in ("configFile", "parentServiceId", "groupId", "clusterRole", "replicaConfig", "clusterDisplay"):
|
|
309
|
+
if config.get(key) is not None:
|
|
310
|
+
node[key] = config[key]
|
|
311
|
+
return node
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _normalize_source(source: Any, root_directory: str | None) -> dict[str, Any] | None:
|
|
315
|
+
if source is None:
|
|
316
|
+
return {"type": "empty", "rootDirectory": root_directory} if root_directory else None
|
|
317
|
+
if isinstance(source, Mapping) and source.get("type"):
|
|
318
|
+
return _prune({**source, "rootDirectory": source.get("rootDirectory") or root_directory})
|
|
319
|
+
if isinstance(source, Mapping) and source.get("repo"):
|
|
320
|
+
return _prune({"type": "github", "repo": source["repo"], "branch": source.get("branch") or "main", "rootDirectory": root_directory})
|
|
321
|
+
if isinstance(source, Mapping) and source.get("image"):
|
|
322
|
+
return _prune({"type": "image", "image": source["image"], "rootDirectory": root_directory})
|
|
323
|
+
return {"type": "empty", "rootDirectory": root_directory} if root_directory else None
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _normalize_build(config: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
327
|
+
build = config.get("build")
|
|
328
|
+
if isinstance(build, str):
|
|
329
|
+
return {"buildCommand": build}
|
|
330
|
+
if isinstance(build, Mapping):
|
|
331
|
+
return _prune(dict(build))
|
|
332
|
+
return None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _normalize_deploy(config: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
336
|
+
run = config.get("run") or {}
|
|
337
|
+
deploy = dict(config.get("deploy") or {})
|
|
338
|
+
pre_deploy = config.get("preDeploy", config.get("preDeployCommand", run.get("preDeploy")))
|
|
339
|
+
if isinstance(pre_deploy, str):
|
|
340
|
+
pre_deploy = [pre_deploy]
|
|
341
|
+
replicas = _normalize_replicas(config.get("replicas"), config.get("regions"))
|
|
342
|
+
payload = {
|
|
343
|
+
**deploy,
|
|
344
|
+
"startCommand": config.get("start")
|
|
345
|
+
or config.get("startCommand")
|
|
346
|
+
or run.get("command")
|
|
347
|
+
or deploy.get("startCommand"),
|
|
348
|
+
"preDeployCommand": pre_deploy if pre_deploy is not None else deploy.get("preDeployCommand"),
|
|
349
|
+
"healthcheckPath": config.get("healthcheck")
|
|
350
|
+
or config.get("healthcheckPath")
|
|
351
|
+
or run.get("healthcheck")
|
|
352
|
+
or deploy.get("healthcheckPath"),
|
|
353
|
+
"healthcheckTimeout": config.get("healthcheckTimeout", run.get("healthcheckTimeout", deploy.get("healthcheckTimeout"))),
|
|
354
|
+
**(replicas or {}),
|
|
355
|
+
}
|
|
356
|
+
if replicas and replicas.get("multiRegionConfig"):
|
|
357
|
+
payload["multiRegionConfig"] = replicas["multiRegionConfig"]
|
|
358
|
+
elif deploy.get("multiRegionConfig"):
|
|
359
|
+
payload["multiRegionConfig"] = deploy["multiRegionConfig"]
|
|
360
|
+
return _prune(payload)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _normalize_replicas(replicas: Any, regions: Any) -> dict[str, Any] | None:
|
|
364
|
+
if isinstance(replicas, int):
|
|
365
|
+
return {"numReplicas": replicas}
|
|
366
|
+
if isinstance(replicas, Mapping):
|
|
367
|
+
return {"multiRegionConfig": _normalize_regions(replicas)}
|
|
368
|
+
if isinstance(regions, Mapping):
|
|
369
|
+
return {"multiRegionConfig": _normalize_regions(regions)}
|
|
370
|
+
return None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _normalize_regions(regions: Mapping[str, Any]) -> dict[str, Any]:
|
|
374
|
+
out: dict[str, Any] = {}
|
|
375
|
+
for region, value in regions.items():
|
|
376
|
+
if isinstance(value, int):
|
|
377
|
+
out[region] = {"numReplicas": value}
|
|
378
|
+
else:
|
|
379
|
+
out[region] = _prune(
|
|
380
|
+
{
|
|
381
|
+
"numReplicas": value.get("count", value.get("replicas")),
|
|
382
|
+
"stackerAssignment": value.get("stacker"),
|
|
383
|
+
}
|
|
384
|
+
) or {}
|
|
385
|
+
return out
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _normalize_networking(config: Mapping[str, Any]) -> dict[str, Any] | None:
|
|
389
|
+
domains = config.get("domains")
|
|
390
|
+
custom_domains = None
|
|
391
|
+
if domains:
|
|
392
|
+
custom_domains = {}
|
|
393
|
+
for domain in domains:
|
|
394
|
+
if isinstance(domain, str):
|
|
395
|
+
custom_domains[domain] = {"port": 8080}
|
|
396
|
+
else:
|
|
397
|
+
custom_domains[domain["domain"]] = {"port": domain.get("port") or 8080}
|
|
398
|
+
tcp_proxies = None
|
|
399
|
+
if config.get("tcp"):
|
|
400
|
+
tcp_proxies = {str(port): {} for port in config["tcp"]}
|
|
401
|
+
elif config.get("tcpProxies"):
|
|
402
|
+
tcp_proxies = {str(port): {} for port in config["tcpProxies"]}
|
|
403
|
+
return _prune({**(config.get("networking") or {}), "customDomains": custom_domains, "tcpProxies": tcp_proxies})
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _normalize_variables(variables: Mapping[str, Any]) -> dict[str, Any]:
|
|
407
|
+
out: dict[str, Any] = {}
|
|
408
|
+
for key, value in variables.items():
|
|
409
|
+
if isinstance(value, str):
|
|
410
|
+
out[key] = {"type": "literal", "value": value}
|
|
411
|
+
elif isinstance(value, Mapping) and "type" in value:
|
|
412
|
+
out[key] = value
|
|
413
|
+
else:
|
|
414
|
+
out[key] = {"type": "raw", "value": value}
|
|
415
|
+
return out
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _normalize_volume_mounts(volume_mounts: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
419
|
+
if not volume_mounts:
|
|
420
|
+
return {}
|
|
421
|
+
raw_mounts: dict[str, Any] = {}
|
|
422
|
+
attachments: dict[str, Any] = {}
|
|
423
|
+
for key, value in volume_mounts.items():
|
|
424
|
+
node = value.to_graph() if hasattr(value, "to_graph") else value
|
|
425
|
+
if isinstance(node, Mapping) and node.get("type") == "volume":
|
|
426
|
+
attachments[node["name"]] = _prune(
|
|
427
|
+
{
|
|
428
|
+
"volume": node["address"],
|
|
429
|
+
"mountPath": key,
|
|
430
|
+
"volumeConfig": node.get("config"),
|
|
431
|
+
}
|
|
432
|
+
)
|
|
433
|
+
continue
|
|
434
|
+
raw_mounts[key] = value
|
|
435
|
+
return _prune({"volumeMounts": raw_mounts, "volumeAttachments": attachments}) or {}
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _supports_image_auto_updates(image_name: str) -> bool:
|
|
439
|
+
normalized = image_name.strip().lower()
|
|
440
|
+
if not normalized:
|
|
441
|
+
return False
|
|
442
|
+
if "/" not in normalized:
|
|
443
|
+
return True
|
|
444
|
+
registry = normalized.split("/", 1)[0]
|
|
445
|
+
return (
|
|
446
|
+
("." not in registry and ":" not in registry and registry != "localhost")
|
|
447
|
+
or registry in {"docker.io", "ghcr.io"}
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _flatten(items: Iterable[Any] | None) -> list[Any]:
|
|
452
|
+
out: list[Any] = []
|
|
453
|
+
for item in items or []:
|
|
454
|
+
if isinstance(item, (list, tuple)):
|
|
455
|
+
out.extend(_flatten(item))
|
|
456
|
+
else:
|
|
457
|
+
out.append(item)
|
|
458
|
+
return out
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _to_graph(item: Any) -> Any:
|
|
462
|
+
if hasattr(item, "to_graph"):
|
|
463
|
+
return item.to_graph()
|
|
464
|
+
return item
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _prune(value: Any) -> Any:
|
|
468
|
+
if value is None or not isinstance(value, Mapping):
|
|
469
|
+
return value
|
|
470
|
+
entries = [(key, child) for key, child in value.items() if child is not None]
|
|
471
|
+
if not entries:
|
|
472
|
+
return None
|
|
473
|
+
return dict(entries)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: railway-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Thin Railway Infrastructure as Code authoring helpers (Python)
|
|
5
|
+
Project-URL: Homepage, https://docs.railway.com/infrastructure-as-code
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# Railway Infrastructure as Code (IaC) authoring helpers for Python.
|
|
14
|
+
|
|
15
|
+
Install: `pip install railway-sdk` (or `pip install -e .` from this repo) then
|
|
16
|
+
author `.railway/railway.py`. Prefer **one file per project** that owns the
|
|
17
|
+
whole environment. Named partials are a last resort for split repos that cannot
|
|
18
|
+
share a file.
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from railway_sdk import define_railway, github, postgres, project, service
|
|
22
|
+
|
|
23
|
+
@define_railway
|
|
24
|
+
def main(ctx=None):
|
|
25
|
+
db = postgres("db")
|
|
26
|
+
web = service(
|
|
27
|
+
"web",
|
|
28
|
+
source=github("org/app"),
|
|
29
|
+
start="gunicorn app:app",
|
|
30
|
+
env={"DATABASE_URL": db.env.DATABASE_URL},
|
|
31
|
+
)
|
|
32
|
+
return project("my-app", resources=[db, web])
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The CLI evaluates this file and diffs against the linked environment.
|
|
36
|
+
Config as Code migration lives in the CLI (`railway config migrate --lang py`).
|
|
37
|
+
|
|
38
|
+
Last resort only: set module-level `PARTIAL = "api"` (same role as
|
|
39
|
+
`export const partial` in TypeScript). Do not rename a partial after apply.
|
|
40
|
+
|
|
41
|
+
See https://docs.railway.com/infrastructure-as-code
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/railway_sdk/__init__.py
|
|
5
|
+
src/railway_sdk.egg-info/PKG-INFO
|
|
6
|
+
src/railway_sdk.egg-info/SOURCES.txt
|
|
7
|
+
src/railway_sdk.egg-info/dependency_links.txt
|
|
8
|
+
src/railway_sdk.egg-info/requires.txt
|
|
9
|
+
src/railway_sdk.egg-info/top_level.txt
|
|
10
|
+
tests/test_cli_eval.py
|
|
11
|
+
tests/test_graph.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
railway_sdk
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Replicate the CLI's python3 -c eval wrapper against this package."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
import textwrap
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
CLI_EVAL = r"""
|
|
10
|
+
import importlib.util, json, sys
|
|
11
|
+
path = sys.argv[1]
|
|
12
|
+
spec = importlib.util.spec_from_file_location("railway_sdk_user", path)
|
|
13
|
+
mod = importlib.util.module_from_spec(spec)
|
|
14
|
+
spec.loader.exec_module(mod)
|
|
15
|
+
partial = getattr(mod, "PARTIAL", None) or getattr(mod, "Partial", None) or getattr(mod, "partial", None)
|
|
16
|
+
candidate = getattr(mod, "main", None) or getattr(mod, "Railway", None) or getattr(mod, "default", None)
|
|
17
|
+
project = candidate() if callable(candidate) else candidate
|
|
18
|
+
if hasattr(project, "to_graph"):
|
|
19
|
+
project = project.to_graph()
|
|
20
|
+
print(json.dumps({"partial": partial, "project": project}, default=str))
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_cli_eval_wrapper_loads_sdk(tmp_path: Path):
|
|
25
|
+
source = tmp_path / "railway.py"
|
|
26
|
+
source.write_text(
|
|
27
|
+
textwrap.dedent(
|
|
28
|
+
"""
|
|
29
|
+
from railway_sdk import define_railway, project, service
|
|
30
|
+
|
|
31
|
+
PARTIAL = "api"
|
|
32
|
+
|
|
33
|
+
@define_railway
|
|
34
|
+
def main(ctx=None):
|
|
35
|
+
web = service("api", start="echo api")
|
|
36
|
+
return project("app", resources=[web])
|
|
37
|
+
"""
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
[sys.executable, "-c", CLI_EVAL, str(source)],
|
|
42
|
+
check=True,
|
|
43
|
+
capture_output=True,
|
|
44
|
+
text=True,
|
|
45
|
+
)
|
|
46
|
+
payload = json.loads(result.stdout)
|
|
47
|
+
assert payload["partial"] == "api"
|
|
48
|
+
resources = payload["project"]["resources"]
|
|
49
|
+
assert resources[0]["address"] == "service.api"
|
|
50
|
+
assert resources[0]["deploy"]["startCommand"] == "echo api"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from railway_sdk import (
|
|
2
|
+
bucket,
|
|
3
|
+
create_railway_context,
|
|
4
|
+
define_railway,
|
|
5
|
+
github,
|
|
6
|
+
group,
|
|
7
|
+
image,
|
|
8
|
+
postgres,
|
|
9
|
+
preserve,
|
|
10
|
+
project,
|
|
11
|
+
ref,
|
|
12
|
+
service,
|
|
13
|
+
volume,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_service_to_graph():
|
|
18
|
+
web = service("web", build="pip install -r requirements.txt", start="gunicorn app:app")
|
|
19
|
+
node = web.to_graph()
|
|
20
|
+
assert node["type"] == "service"
|
|
21
|
+
assert node["name"] == "web"
|
|
22
|
+
assert node["address"] == "service.web"
|
|
23
|
+
assert node["kind"] == "empty"
|
|
24
|
+
assert node["build"] == {"buildCommand": "pip install -r requirements.txt"}
|
|
25
|
+
assert node["deploy"] == {"startCommand": "gunicorn app:app"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_github_source_and_env_refs():
|
|
29
|
+
db = postgres("db")
|
|
30
|
+
api = service(
|
|
31
|
+
"api",
|
|
32
|
+
source=github("org/api"),
|
|
33
|
+
env={"DATABASE_URL": db.env.DATABASE_URL, "NAME": "api"},
|
|
34
|
+
domains=["api.example.com"],
|
|
35
|
+
replicas=2,
|
|
36
|
+
)
|
|
37
|
+
node = api.to_graph()
|
|
38
|
+
assert node["kind"] == "github"
|
|
39
|
+
assert node["source"] == {"type": "github", "repo": "org/api", "branch": "main"}
|
|
40
|
+
assert node["variables"]["DATABASE_URL"] == {
|
|
41
|
+
"type": "reference",
|
|
42
|
+
"resource": "database.db",
|
|
43
|
+
"output": "DATABASE_URL",
|
|
44
|
+
}
|
|
45
|
+
assert node["variables"]["NAME"] == {"type": "literal", "value": "api"}
|
|
46
|
+
assert node["deploy"]["numReplicas"] == 2
|
|
47
|
+
assert node["networking"]["customDomains"]["api.example.com"] == {"port": 8080}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_define_railway_project_graph():
|
|
51
|
+
@define_railway
|
|
52
|
+
def main(ctx=None):
|
|
53
|
+
api = service("api", start="uvicorn app:app")
|
|
54
|
+
return project("demo", resources=[api])
|
|
55
|
+
|
|
56
|
+
graph = main().to_graph()
|
|
57
|
+
assert graph["name"] == "demo"
|
|
58
|
+
assert graph["resources"][0]["name"] == "api"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_group_flatten_and_volume_mount():
|
|
62
|
+
data = volume("data")
|
|
63
|
+
web = service("web", start="./app", volumeMounts={"/data": data})
|
|
64
|
+
graph = project("demo", resources=group("app", [web, data])).to_graph()
|
|
65
|
+
types = [item["type"] for item in graph["resources"]]
|
|
66
|
+
assert types == ["group", "service", "volume"]
|
|
67
|
+
assert graph["resources"][1]["groupId"] == "app"
|
|
68
|
+
assert graph["resources"][1]["volumeAttachments"]["data"]["volume"] == "volume.data"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_context_helpers():
|
|
72
|
+
ctx = create_railway_context(environment="prod")
|
|
73
|
+
assert ctx.is_environment("prod")
|
|
74
|
+
assert not ctx.is_environment("dev")
|
|
75
|
+
assert ctx.shared.STRIPE_KEY == {"type": "sharedReference", "name": "STRIPE_KEY"}
|
|
76
|
+
assert len(ctx.random_string("secret")) == 24
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_ref_and_preserve():
|
|
80
|
+
db = postgres("db")
|
|
81
|
+
assert ref(db, "DATABASE_URL") == db.env.DATABASE_URL
|
|
82
|
+
assert preserve() == {"type": "preserve"}
|
|
83
|
+
assert image("nginx:latest")["type"] == "image"
|
|
84
|
+
assert bucket("assets").to_graph()["address"] == "bucket.assets"
|