small-software 0.8.0__tar.gz → 0.9.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.
- {small_software-0.8.0/small_software.egg-info → small_software-0.9.0}/PKG-INFO +45 -6
- {small_software-0.8.0 → small_software-0.9.0}/README.md +44 -5
- {small_software-0.8.0 → small_software-0.9.0}/pyproject.toml +1 -1
- small_software-0.9.0/small_cli/__init__.py +1 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/access.py +22 -10
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/__init__.py +4 -3
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/access.py +7 -5
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/deploy.py +80 -7
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/destroy.py +2 -1
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/domain.py +3 -1
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/env.py +23 -10
- small_software-0.9.0/small_cli/commands/gateway.py +251 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/logs.py +7 -1
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/sleep.py +6 -3
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/sso.py +2 -2
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/volume.py +20 -4
- small_software-0.9.0/small_cli/gateway.py +100 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/mcp.py +5 -2
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/railway.py +3 -0
- {small_software-0.8.0/small_cli/templates/streamlit → small_software-0.9.0/small_cli/templates/fastapi}/magic_link.py +131 -25
- small_software-0.9.0/small_cli/templates/gateway/Caddyfile +41 -0
- small_software-0.9.0/small_cli/templates/gateway/Dockerfile +7 -0
- small_software-0.9.0/small_cli/templates/gateway/gateway.py +94 -0
- {small_software-0.8.0/small_cli/templates/fastapi → small_software-0.9.0/small_cli/templates/gateway}/magic_link.py +131 -25
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/templates/node-http/magic-link.js +68 -11
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/templates/python-http/magic_link.py +131 -25
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/templates/streamlit/app.py +20 -1
- small_software-0.9.0/small_cli/templates/streamlit/magic_link.py +1503 -0
- {small_software-0.8.0 → small_software-0.9.0/small_software.egg-info}/PKG-INFO +45 -6
- {small_software-0.8.0 → small_software-0.9.0}/small_software.egg-info/SOURCES.txt +7 -0
- small_software-0.9.0/tests/test_gateway.py +514 -0
- {small_software-0.8.0 → small_software-0.9.0}/tests/test_small.py +18 -4
- {small_software-0.8.0 → small_software-0.9.0}/tests/test_streamlit_email.py +18 -0
- small_software-0.8.0/small_cli/__init__.py +0 -1
- {small_software-0.8.0 → small_software-0.9.0}/LICENSE +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/setup.cfg +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/__main__.py +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/archive.py +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/cli.py +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/dashboard.py +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/db.py +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/init.py +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/link.py +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/mail.py +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/mcp.py +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/notify.py +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/common.py +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/config.py +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/dashboard.py +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/i18n.py +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/scaffold.py +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/templates/fastapi/main.py +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/templates/fastapi/requirements.txt +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/templates/node-http/package.json +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/templates/node-http/server.js +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/templates/python-http/app.py +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_cli/templates/streamlit/requirements.txt +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_software.egg-info/dependency_links.txt +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_software.egg-info/entry_points.txt +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/small_software.egg-info/top_level.txt +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/tests/test_dashboard_share.py +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/tests/test_mcp.py +0 -0
- {small_software-0.8.0 → small_software-0.9.0}/tests/test_sso_volume.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: small-software
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: A cloud for small software: deploy team apps to Railway in one command, private by default, shared by work email
|
|
5
5
|
Author: RuslanKazyradzi13
|
|
6
6
|
License-Expression: MIT
|
|
@@ -319,7 +319,7 @@ share apps itself: *"Deploy this folder and give ivan@company.com access."*
|
|
|
319
319
|
## `small deploy`
|
|
320
320
|
|
|
321
321
|
```
|
|
322
|
-
small deploy [folder] [--name NAME] [--workspace ID] [--port N] [-m MESSAGE] [--new] [--no-wait] [--timeout SEC]
|
|
322
|
+
small deploy [folder] [--name NAME] [--workspace ID] [--port N] [-m MESSAGE] [--public] [--new] [--no-wait] [--timeout SEC]
|
|
323
323
|
```
|
|
324
324
|
|
|
325
325
|
| Flag | What it does |
|
|
@@ -328,6 +328,7 @@ small deploy [folder] [--name NAME] [--workspace ID] [--port N] [-m MESSAGE] [--
|
|
|
328
328
|
| `--workspace` | workspace ID if the account has several (or `RAILWAY_WORKSPACE_ID`) |
|
|
329
329
|
| `--port` | port for the public domain if the app does not listen on `$PORT` |
|
|
330
330
|
| `-m`, `--message` | deploy message shown in Railway |
|
|
331
|
+
| `--public` | an app without sign-in of its own: deploy it open to anyone, without the gateway (see `small gateway`) |
|
|
331
332
|
| `--new` | create a new project even if the folder has been deployed before |
|
|
332
333
|
| `--no-wait` | print the URL right away without waiting for the build |
|
|
333
334
|
| `--timeout` | how many seconds to wait for the build (default 900) |
|
|
@@ -335,6 +336,42 @@ small deploy [folder] [--name NAME] [--workspace ID] [--port N] [-m MESSAGE] [--
|
|
|
335
336
|
Progress goes to stderr and the final URL to stdout: `URL=$(small deploy)`.
|
|
336
337
|
Exit codes: `0` success, `1` error or failed build (the last log lines are printed), `130` interrupted.
|
|
337
338
|
|
|
339
|
+
## `small gateway`: sign-in in front of any app
|
|
340
|
+
|
|
341
|
+
```
|
|
342
|
+
small gateway [on [--port N] | off [--yes]] # no subcommand: the status
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
The gateway protects an app that has no sign-in of its own — any language, any framework, a Dockerfile — without
|
|
346
|
+
changing its code. It is a second service in the app's Railway project, `<app>-gateway`: [Caddy](https://caddyserver.com)
|
|
347
|
+
takes every request, and magic-link next to it decides who gets in (Google and Microsoft sign-on, email links, invite
|
|
348
|
+
links, all `small access` / `small sso` / `small mail` settings). Requests it lets in go on to the app over Railway's
|
|
349
|
+
private network; the app itself has no public address.
|
|
350
|
+
|
|
351
|
+
- **Private by default.** The first `small deploy` of a folder without `magic_link.py` / `magic-link.js` sets the
|
|
352
|
+
gateway up before the app ever gets an address, and puts your own invite link in `small.json` (`small open`).
|
|
353
|
+
`small deploy --public` deploys such an app open to anyone instead; redeploying an app that is open prints a warning.
|
|
354
|
+
- **`on`** for an app already deployed: creates the gateway, moves the sign-in variables to it, moves the address
|
|
355
|
+
(keeping its name), and restarts the app without a public address. The app must listen on `$PORT` (small sets it to
|
|
356
|
+
`8080` if it isn't set) or pass `--port`. A custom domain has to be removed first and added back after; it then
|
|
357
|
+
points to the gateway.
|
|
358
|
+
- **Who is signed in.** The gateway adds `X-Small-User` (the address, or the invite-link name), `X-Small-Method`
|
|
359
|
+
(`email`, `google`, `microsoft`, `link`) and `X-Small-Auth`, an HMAC signature of both with `SMALL_GATEWAY_KEY`, which
|
|
360
|
+
the app gets as a Railway reference. Any `X-Small-*` header a visitor sends is dropped. magic-link in the app (0.9+)
|
|
361
|
+
trusts that signature and nothing else, so an app that keeps its own sign-in code works behind the gateway too.
|
|
362
|
+
- **Websockets** go through the same check. For Streamlit this means an `HttpOnly` session cookie set by the gateway.
|
|
363
|
+
- **Where things live.** Sign-in variables (`ACCESS_*`, `SESSION_SECRET`, `GOOGLE_*`, `MICROSOFT_*`,
|
|
364
|
+
`SESSIONS_REVOKED`…) belong to the gateway; mail and Telegram variables go to both; everything else stays the
|
|
365
|
+
app's. `small env`, `small access`, `small sso`, `small mail` and `small deploy` route them for you, and restart only
|
|
366
|
+
the service that changed.
|
|
367
|
+
- **The rest follows:** `small access log` reads the gateway's log, `small logs --gateway` shows it, `small sleep`
|
|
368
|
+
puts both to sleep (the first request wakes both, a few seconds), `small domain` attaches to the gateway,
|
|
369
|
+
`small volume add --gateway` keeps sign-in state across the gateway's restarts, `small destroy` removes both.
|
|
370
|
+
- **`small deploy`** also rebuilds the gateway when it was built by an older small.
|
|
371
|
+
- **`off`** gives the address and the variables back to the app and deletes the gateway. If the app has no sign-in of
|
|
372
|
+
its own, small asks first (or `--yes`): without the gateway anyone with the address gets in.
|
|
373
|
+
- **Cost:** one more small service. Caddy and the sign-in server idle at a few tens of MB; with sleep on, both sleep.
|
|
374
|
+
|
|
338
375
|
## After deploy
|
|
339
376
|
|
|
340
377
|
These commands work in the folder of a deployed app, that is, where `.small.json` is. `link`, `open`, `logs` and
|
|
@@ -351,10 +388,11 @@ small mail [resend | smtp [--from FROM] [--to TO] | log | off] # emails for sig
|
|
|
351
388
|
small notify [telegram [--chat-id ID] | off] # access requests to Telegram
|
|
352
389
|
small env [list [--values] | set K=V ... | unset K ...] [--no-deploy]
|
|
353
390
|
small db [list | add postgres | remove postgres [--yes]]
|
|
354
|
-
small volume [add [--mount /data]]
|
|
391
|
+
small volume [add [--mount /data]] [--gateway] # persistent disk; no argument shows the status
|
|
392
|
+
small gateway [on [--port N] | off [--yes]] # sign-in in front of any app (see above)
|
|
355
393
|
small domain [list | add DOMAIN [--port N] | remove DOMAIN]
|
|
356
394
|
small sleep [on | off] # sleep mode; no argument shows the status
|
|
357
|
-
small logs [folder] [--build] [-n N] [-f]
|
|
395
|
+
small logs [folder] [--build | --gateway] [-n N] [-f] # log of the latest deploy
|
|
358
396
|
small destroy [folder] [--yes] # remove from Railway
|
|
359
397
|
```
|
|
360
398
|
|
|
@@ -501,7 +539,7 @@ then takes effect on the next deploy.
|
|
|
501
539
|
that it disappears from Railway too.
|
|
502
540
|
|
|
503
541
|
**`small logs`** shows the log of the latest deploy.
|
|
504
|
-
- **Which log.** The app log by default, the build log with `--build`. `-n` sets the number of lines (default 100).
|
|
542
|
+
- **Which log.** The app log by default, the build log with `--build`, the gateway's with `--gateway`. `-n` sets the number of lines (default 100).
|
|
505
543
|
- **Follow mode.** `-f` appends new lines every 2 seconds; `Ctrl+C` exits.
|
|
506
544
|
- **Format.** Every line is printed with its local time. Requests from the **Request access** button show up as
|
|
507
545
|
`[access-request] ...`, and they are easy to filter: `small logs | findstr access-request`.
|
|
@@ -667,4 +705,5 @@ network, timeout, wrong token, garbage status, no token in the response):
|
|
|
667
705
|
## Limitations
|
|
668
706
|
|
|
669
707
|
- Only the root `.gitignore`/`.railwayignore` are read; nested ones are ignored.
|
|
670
|
-
- One service is created, in the `production` environment. Databases: Postgres only
|
|
708
|
+
- One service is created (two with the gateway), in the `production` environment. Databases: Postgres only
|
|
709
|
+
(`small db`).
|
|
@@ -291,7 +291,7 @@ share apps itself: *"Deploy this folder and give ivan@company.com access."*
|
|
|
291
291
|
## `small deploy`
|
|
292
292
|
|
|
293
293
|
```
|
|
294
|
-
small deploy [folder] [--name NAME] [--workspace ID] [--port N] [-m MESSAGE] [--new] [--no-wait] [--timeout SEC]
|
|
294
|
+
small deploy [folder] [--name NAME] [--workspace ID] [--port N] [-m MESSAGE] [--public] [--new] [--no-wait] [--timeout SEC]
|
|
295
295
|
```
|
|
296
296
|
|
|
297
297
|
| Flag | What it does |
|
|
@@ -300,6 +300,7 @@ small deploy [folder] [--name NAME] [--workspace ID] [--port N] [-m MESSAGE] [--
|
|
|
300
300
|
| `--workspace` | workspace ID if the account has several (or `RAILWAY_WORKSPACE_ID`) |
|
|
301
301
|
| `--port` | port for the public domain if the app does not listen on `$PORT` |
|
|
302
302
|
| `-m`, `--message` | deploy message shown in Railway |
|
|
303
|
+
| `--public` | an app without sign-in of its own: deploy it open to anyone, without the gateway (see `small gateway`) |
|
|
303
304
|
| `--new` | create a new project even if the folder has been deployed before |
|
|
304
305
|
| `--no-wait` | print the URL right away without waiting for the build |
|
|
305
306
|
| `--timeout` | how many seconds to wait for the build (default 900) |
|
|
@@ -307,6 +308,42 @@ small deploy [folder] [--name NAME] [--workspace ID] [--port N] [-m MESSAGE] [--
|
|
|
307
308
|
Progress goes to stderr and the final URL to stdout: `URL=$(small deploy)`.
|
|
308
309
|
Exit codes: `0` success, `1` error or failed build (the last log lines are printed), `130` interrupted.
|
|
309
310
|
|
|
311
|
+
## `small gateway`: sign-in in front of any app
|
|
312
|
+
|
|
313
|
+
```
|
|
314
|
+
small gateway [on [--port N] | off [--yes]] # no subcommand: the status
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
The gateway protects an app that has no sign-in of its own — any language, any framework, a Dockerfile — without
|
|
318
|
+
changing its code. It is a second service in the app's Railway project, `<app>-gateway`: [Caddy](https://caddyserver.com)
|
|
319
|
+
takes every request, and magic-link next to it decides who gets in (Google and Microsoft sign-on, email links, invite
|
|
320
|
+
links, all `small access` / `small sso` / `small mail` settings). Requests it lets in go on to the app over Railway's
|
|
321
|
+
private network; the app itself has no public address.
|
|
322
|
+
|
|
323
|
+
- **Private by default.** The first `small deploy` of a folder without `magic_link.py` / `magic-link.js` sets the
|
|
324
|
+
gateway up before the app ever gets an address, and puts your own invite link in `small.json` (`small open`).
|
|
325
|
+
`small deploy --public` deploys such an app open to anyone instead; redeploying an app that is open prints a warning.
|
|
326
|
+
- **`on`** for an app already deployed: creates the gateway, moves the sign-in variables to it, moves the address
|
|
327
|
+
(keeping its name), and restarts the app without a public address. The app must listen on `$PORT` (small sets it to
|
|
328
|
+
`8080` if it isn't set) or pass `--port`. A custom domain has to be removed first and added back after; it then
|
|
329
|
+
points to the gateway.
|
|
330
|
+
- **Who is signed in.** The gateway adds `X-Small-User` (the address, or the invite-link name), `X-Small-Method`
|
|
331
|
+
(`email`, `google`, `microsoft`, `link`) and `X-Small-Auth`, an HMAC signature of both with `SMALL_GATEWAY_KEY`, which
|
|
332
|
+
the app gets as a Railway reference. Any `X-Small-*` header a visitor sends is dropped. magic-link in the app (0.9+)
|
|
333
|
+
trusts that signature and nothing else, so an app that keeps its own sign-in code works behind the gateway too.
|
|
334
|
+
- **Websockets** go through the same check. For Streamlit this means an `HttpOnly` session cookie set by the gateway.
|
|
335
|
+
- **Where things live.** Sign-in variables (`ACCESS_*`, `SESSION_SECRET`, `GOOGLE_*`, `MICROSOFT_*`,
|
|
336
|
+
`SESSIONS_REVOKED`…) belong to the gateway; mail and Telegram variables go to both; everything else stays the
|
|
337
|
+
app's. `small env`, `small access`, `small sso`, `small mail` and `small deploy` route them for you, and restart only
|
|
338
|
+
the service that changed.
|
|
339
|
+
- **The rest follows:** `small access log` reads the gateway's log, `small logs --gateway` shows it, `small sleep`
|
|
340
|
+
puts both to sleep (the first request wakes both, a few seconds), `small domain` attaches to the gateway,
|
|
341
|
+
`small volume add --gateway` keeps sign-in state across the gateway's restarts, `small destroy` removes both.
|
|
342
|
+
- **`small deploy`** also rebuilds the gateway when it was built by an older small.
|
|
343
|
+
- **`off`** gives the address and the variables back to the app and deletes the gateway. If the app has no sign-in of
|
|
344
|
+
its own, small asks first (or `--yes`): without the gateway anyone with the address gets in.
|
|
345
|
+
- **Cost:** one more small service. Caddy and the sign-in server idle at a few tens of MB; with sleep on, both sleep.
|
|
346
|
+
|
|
310
347
|
## After deploy
|
|
311
348
|
|
|
312
349
|
These commands work in the folder of a deployed app, that is, where `.small.json` is. `link`, `open`, `logs` and
|
|
@@ -323,10 +360,11 @@ small mail [resend | smtp [--from FROM] [--to TO] | log | off] # emails for sig
|
|
|
323
360
|
small notify [telegram [--chat-id ID] | off] # access requests to Telegram
|
|
324
361
|
small env [list [--values] | set K=V ... | unset K ...] [--no-deploy]
|
|
325
362
|
small db [list | add postgres | remove postgres [--yes]]
|
|
326
|
-
small volume [add [--mount /data]]
|
|
363
|
+
small volume [add [--mount /data]] [--gateway] # persistent disk; no argument shows the status
|
|
364
|
+
small gateway [on [--port N] | off [--yes]] # sign-in in front of any app (see above)
|
|
327
365
|
small domain [list | add DOMAIN [--port N] | remove DOMAIN]
|
|
328
366
|
small sleep [on | off] # sleep mode; no argument shows the status
|
|
329
|
-
small logs [folder] [--build] [-n N] [-f]
|
|
367
|
+
small logs [folder] [--build | --gateway] [-n N] [-f] # log of the latest deploy
|
|
330
368
|
small destroy [folder] [--yes] # remove from Railway
|
|
331
369
|
```
|
|
332
370
|
|
|
@@ -473,7 +511,7 @@ then takes effect on the next deploy.
|
|
|
473
511
|
that it disappears from Railway too.
|
|
474
512
|
|
|
475
513
|
**`small logs`** shows the log of the latest deploy.
|
|
476
|
-
- **Which log.** The app log by default, the build log with `--build`. `-n` sets the number of lines (default 100).
|
|
514
|
+
- **Which log.** The app log by default, the build log with `--build`, the gateway's with `--gateway`. `-n` sets the number of lines (default 100).
|
|
477
515
|
- **Follow mode.** `-f` appends new lines every 2 seconds; `Ctrl+C` exits.
|
|
478
516
|
- **Format.** Every line is printed with its local time. Requests from the **Request access** button show up as
|
|
479
517
|
`[access-request] ...`, and they are easy to filter: `small logs | findstr access-request`.
|
|
@@ -639,4 +677,5 @@ network, timeout, wrong token, garbage status, no token in the response):
|
|
|
639
677
|
## Limitations
|
|
640
678
|
|
|
641
679
|
- Only the root `.gitignore`/`.railwayignore` are read; nested ones are ignored.
|
|
642
|
-
- One service is created, in the `production` environment. Databases: Postgres only
|
|
680
|
+
- One service is created (two with the gateway), in the `production` environment. Databases: Postgres only
|
|
681
|
+
(`small db`).
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "small-software"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.9.0"
|
|
8
8
|
description = "A cloud for small software: deploy team apps to Railway in one command, private by default, shared by work email"
|
|
9
9
|
license = "MIT"
|
|
10
10
|
license-files = ["LICENSE"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.9.0"
|
|
@@ -9,6 +9,7 @@ import re
|
|
|
9
9
|
import secrets
|
|
10
10
|
from urllib.parse import quote
|
|
11
11
|
|
|
12
|
+
from . import gateway
|
|
12
13
|
from .config import CONFIG_FILE, load_config
|
|
13
14
|
from .i18n import tr
|
|
14
15
|
from .railway import Railway, RailwayError, token_from_env
|
|
@@ -60,13 +61,19 @@ def parse_emails(value):
|
|
|
60
61
|
|
|
61
62
|
|
|
62
63
|
def app_env(root, state, warn=None):
|
|
63
|
-
"""(app variables, where each one comes from): Railway, overlaid with non-empty values from small.json.
|
|
64
|
+
"""(app variables, where each one comes from): Railway, overlaid with non-empty values from small.json. Behind the
|
|
65
|
+
gateway, the sign-in variables come from the gateway's service."""
|
|
64
66
|
env, sources = {}, {}
|
|
65
67
|
linked = all(state.get(k) for k in ("projectId", "environmentId", "serviceId"))
|
|
66
68
|
if linked and token_from_env():
|
|
67
69
|
try:
|
|
68
|
-
|
|
69
|
-
|
|
70
|
+
api = Railway(token_from_env())
|
|
71
|
+
env = dict(api.service_variables(state["projectId"], state["environmentId"], state["serviceId"]))
|
|
72
|
+
if gateway.is_on(state):
|
|
73
|
+
for name in gateway.GATEWAY_ONLY:
|
|
74
|
+
env.pop(name, None)
|
|
75
|
+
ours = api.service_variables(state["projectId"], state["environmentId"], state["gateway"]["serviceId"])
|
|
76
|
+
env.update({k: v for k, v in ours.items() if k in gateway.GATEWAY_ONLY + gateway.SHARED})
|
|
70
77
|
sources = dict.fromkeys(env, "Railway")
|
|
71
78
|
except RailwayError as e:
|
|
72
79
|
if warn:
|
|
@@ -121,11 +128,11 @@ def app_url(root, state):
|
|
|
121
128
|
|
|
122
129
|
def redirect_uri(root, state):
|
|
123
130
|
"""Where Google and Microsoft send people back after sign-in: /_access/sso/callback, or the page itself for
|
|
124
|
-
Streamlit (it can't serve other paths). None until the app has an address."""
|
|
131
|
+
Streamlit without the gateway (it can't serve other paths). None until the app has an address."""
|
|
125
132
|
base = app_url(root, state)
|
|
126
133
|
if base is None:
|
|
127
134
|
return None
|
|
128
|
-
return base + "/" if is_streamlit(root) else base + SSO_CALLBACK
|
|
135
|
+
return base + "/" if is_streamlit(root) and not gateway.is_on(state) else base + SSO_CALLBACK
|
|
129
136
|
|
|
130
137
|
|
|
131
138
|
def parse_revoked(value):
|
|
@@ -148,9 +155,11 @@ def add_revoked(value, who, now):
|
|
|
148
155
|
return ", ".join("%s=%d" % pair for pair in keep)
|
|
149
156
|
|
|
150
157
|
|
|
151
|
-
def feature_problem(root):
|
|
152
|
-
"""None if the app's code knows sign-in with Google and Microsoft and signing out (small 0.8)
|
|
153
|
-
in the way and how to fix it."""
|
|
158
|
+
def feature_problem(root, state=None):
|
|
159
|
+
"""None if the app's code knows sign-in with Google and Microsoft and signing out (small 0.8), or the gateway
|
|
160
|
+
does it for the app; otherwise what is in the way and how to fix it."""
|
|
161
|
+
if gateway.is_on(state or {}):
|
|
162
|
+
return None
|
|
154
163
|
streamlit = is_streamlit(root)
|
|
155
164
|
if streamlit and "sso_finish" not in (root / "app.py").read_text(encoding="utf-8", errors="replace"):
|
|
156
165
|
return (tr("app.py is from a streamlit template older than small 0.8, without sign-in with Google and "
|
|
@@ -170,11 +179,14 @@ def feature_problem(root):
|
|
|
170
179
|
return None # no magic_link next to the code: the app may keep it elsewhere, as for email sign-in
|
|
171
180
|
|
|
172
181
|
|
|
173
|
-
def email_login_problem(root):
|
|
182
|
+
def email_login_problem(root, state=None):
|
|
174
183
|
"""None if the app code supports sign-in by email; otherwise, what is in the way and how to fix it.
|
|
175
184
|
|
|
176
185
|
Streamlit cannot be wrapped in middleware: there sign-in by email is done by require_access() in app.py via
|
|
177
|
-
MagicLinkGate from magic_link.py, so new versions of both files are needed.
|
|
186
|
+
MagicLinkGate from magic_link.py, so new versions of both files are needed. Behind the gateway the app's code
|
|
187
|
+
doesn't matter: the gateway signs people in."""
|
|
188
|
+
if gateway.is_on(state or {}):
|
|
189
|
+
return None
|
|
178
190
|
app = root / "app.py"
|
|
179
191
|
source = app.read_text(encoding="utf-8", errors="replace") if app.is_file() else ""
|
|
180
192
|
streamlit = "streamlit" in source
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
The order here is the order in `small --help`: from creating an app to deleting it.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
-
from . import (access, dashboard, db, deploy, destroy, domain, env, init, link, logs, mail, mcp, notify,
|
|
7
|
-
sso, volume)
|
|
6
|
+
from . import (access, dashboard, db, deploy, destroy, domain, env, gateway, init, link, logs, mail, mcp, notify,
|
|
7
|
+
sleep, sso, volume)
|
|
8
8
|
|
|
9
|
-
ALL = (init, deploy, link, access, sso, mail, notify, env, db, volume, domain, sleep, logs, dashboard, mcp,
|
|
9
|
+
ALL = (init, deploy, link, access, sso, gateway, mail, notify, env, db, volume, domain, sleep, logs, dashboard, mcp,
|
|
10
|
+
destroy)
|
|
@@ -11,6 +11,7 @@ import time
|
|
|
11
11
|
from datetime import datetime, timezone
|
|
12
12
|
|
|
13
13
|
from .. import access as tokens
|
|
14
|
+
from .. import gateway
|
|
14
15
|
from ..common import CliError, app_dir, linked_state, require_token, say
|
|
15
16
|
from ..config import CONFIG_FILE, STATE_FILE, load_config, load_state
|
|
16
17
|
from ..i18n import tr
|
|
@@ -143,10 +144,10 @@ def grant_access(root, who, deploy=True, warn=None):
|
|
|
143
144
|
|
|
144
145
|
|
|
145
146
|
def _grant_email(root, email, deploy, warn):
|
|
146
|
-
|
|
147
|
+
state = load_state(root / STATE_FILE)
|
|
148
|
+
problem = tokens.email_login_problem(root, state)
|
|
147
149
|
if problem:
|
|
148
150
|
raise CliError(problem)
|
|
149
|
-
state = load_state(root / STATE_FILE)
|
|
150
151
|
env, _ = tokens.app_env(root, state, warn=warn)
|
|
151
152
|
emails = tokens.parse_emails(env.get("ACCESS_EMAILS"))
|
|
152
153
|
if email in emails:
|
|
@@ -269,10 +270,11 @@ def signout_access(root, who, deploy=True, warn=None):
|
|
|
269
270
|
if who != "*" and not tokens.is_email(who):
|
|
270
271
|
raise CliError(tr("specify an email address (ivan@company.com), a domain (@company.com) or --all",
|
|
271
272
|
"укажите адрес почты (ivan@company.com), домен (@company.com) или --all"))
|
|
272
|
-
|
|
273
|
+
state = load_state(root / STATE_FILE)
|
|
274
|
+
problem = tokens.feature_problem(root, state)
|
|
273
275
|
if problem:
|
|
274
276
|
raise CliError(problem)
|
|
275
|
-
env, _ = tokens.app_env(root,
|
|
277
|
+
env, _ = tokens.app_env(root, state, warn=warn)
|
|
276
278
|
value = tokens.add_revoked(env.get("SESSIONS_REVOKED"), who, time.time())
|
|
277
279
|
return {"who": who, "linked": change_env(root, {"SESSIONS_REVOKED": value}, deploy=deploy)}
|
|
278
280
|
|
|
@@ -309,7 +311,7 @@ def access_log(root, limit=50):
|
|
|
309
311
|
plan keeps logs."""
|
|
310
312
|
state = linked_state(root)
|
|
311
313
|
entries = Railway(require_token()).environment_logs(
|
|
312
|
-
state["environmentId"], "@service:%s @magic_link:*" % state
|
|
314
|
+
state["environmentId"], "@service:%s @magic_link:*" % gateway.public_service(state), limit=limit)
|
|
313
315
|
rows = []
|
|
314
316
|
for entry in entries:
|
|
315
317
|
fields = entry["attributes"]
|
|
@@ -2,16 +2,23 @@
|
|
|
2
2
|
-> serviceDomainCreate -> write the variables from small.json (variableCollectionUpsert) -> upload the archive
|
|
3
3
|
(POST .../up) -> poll deployment(id).status -> print https://<domain>.
|
|
4
4
|
|
|
5
|
+
Private by default: the first deploy of an app without sign-in of its own (no magic_link next to its code) puts the
|
|
6
|
+
small gateway in front of it before it ever gets an address (see small gateway); --public opts out.
|
|
7
|
+
|
|
5
8
|
Progress goes to stderr and the final URL to stdout, so `URL=$(small deploy)` works.
|
|
6
9
|
"""
|
|
7
10
|
|
|
8
11
|
import argparse
|
|
12
|
+
import json
|
|
9
13
|
import os
|
|
10
14
|
import time
|
|
11
15
|
|
|
16
|
+
from .. import access as tokens
|
|
17
|
+
from .. import gateway
|
|
12
18
|
from ..archive import collect_files, make_tarball
|
|
13
19
|
from ..common import CliError, app_dir, format_elapsed, human_size, require_token, say, slugify
|
|
14
20
|
from ..config import CONFIG_FILE, STATE_FILE, load_config, load_state, save_state
|
|
21
|
+
from .. import __version__
|
|
15
22
|
from ..i18n import lang, tr
|
|
16
23
|
from ..railway import Railway, RailwayError
|
|
17
24
|
|
|
@@ -37,6 +44,9 @@ def register(sub):
|
|
|
37
44
|
d.add_argument("--port", type=int, help=tr("port the app listens on (by default Railway uses $PORT)",
|
|
38
45
|
"порт, который слушает приложение (по умолчанию Railway берёт $PORT)"))
|
|
39
46
|
d.add_argument("-m", "--message", help=tr("deploy message", "подпись деплоя"))
|
|
47
|
+
d.add_argument("--public", action="store_true",
|
|
48
|
+
help=tr("an app without sign-in of its own: deploy it open to anyone, without the gateway",
|
|
49
|
+
"приложение без своего входа: задеплоить открытым для всех, без шлюза"))
|
|
40
50
|
d.add_argument("--new", action="store_true",
|
|
41
51
|
help=tr("create a new project even if %s exists", "создать новый проект, даже если есть %s")
|
|
42
52
|
% STATE_FILE)
|
|
@@ -86,16 +96,34 @@ def cmd_deploy(args):
|
|
|
86
96
|
else:
|
|
87
97
|
project_id, environment_id = api.create_project(name, workspace_id)
|
|
88
98
|
say(tr("Created project %s", "Создан проект %s") % name)
|
|
89
|
-
|
|
99
|
+
try:
|
|
100
|
+
service_id = api.create_service(project_id, environment_id, name)
|
|
101
|
+
except RailwayError:
|
|
102
|
+
api.delete_project(project_id) # an empty project of our own, e.g. after Railway's daily limit
|
|
103
|
+
raise
|
|
90
104
|
say(tr("Created service %s", "Создан сервис %s") % name)
|
|
91
105
|
state = {"name": name, "projectId": project_id,
|
|
92
106
|
"environmentId": environment_id, "serviceId": service_id}
|
|
93
107
|
# Save right away: if the build fails, a rerun won't spawn new projects.
|
|
94
108
|
save_state(state_path, state)
|
|
95
109
|
|
|
110
|
+
private = not reused and not gateway.is_on(state) and not args.public and not gateway.has_own_sign_in(root)
|
|
111
|
+
if private:
|
|
112
|
+
# No sign-in of its own: the gateway comes first, so the app never has an open address.
|
|
113
|
+
from .gateway import create
|
|
114
|
+
|
|
115
|
+
config = ensure_owner_link(root, config)
|
|
116
|
+
create(api, root, state, config["env"], args.port)
|
|
117
|
+
save_state(state_path, state)
|
|
118
|
+
elif not gateway.is_on(state) and not gateway.has_own_sign_in(root) and not args.public:
|
|
119
|
+
say(tr("Warning: the app has no sign-in of its own, so anyone with its address gets in. Protect it: "
|
|
120
|
+
"small gateway on",
|
|
121
|
+
"Внимание: своего входа у приложения нет, в него попадёт любой, у кого есть адрес. Защитить: "
|
|
122
|
+
"small gateway on"))
|
|
123
|
+
|
|
96
124
|
if not state.get("domain"):
|
|
97
125
|
# Domain before the code upload: the very first build already needs its address (links in sign-in emails).
|
|
98
|
-
state["domain"] = ensure_domain(api, state, args.port)
|
|
126
|
+
state["domain"] = ensure_domain(api, state, gateway.GATEWAY_PORT if gateway.is_on(state) else args.port)
|
|
99
127
|
save_state(state_path, state)
|
|
100
128
|
say(tr("Public domain: %s", "Публичный домен: %s") % state["domain"])
|
|
101
129
|
|
|
@@ -109,8 +137,10 @@ def cmd_deploy(args):
|
|
|
109
137
|
if variables and "OWNER_LANG" not in variables:
|
|
110
138
|
variables["OWNER_LANG"] = lang() # log lines and Telegram notifications in the owner's language
|
|
111
139
|
if variables:
|
|
112
|
-
# Before the code upload, so the new build gets the variables right away. Values are not printed.
|
|
113
|
-
|
|
140
|
+
# Before the code upload, so the new build gets the variables right away. Values are not printed. Behind the
|
|
141
|
+
# gateway, the sign-in variables go to the gateway (gateway.services_for).
|
|
142
|
+
for service_id, values in gateway.split(variables, state).items():
|
|
143
|
+
api.upsert_variables(state["projectId"], state["environmentId"], service_id, values)
|
|
114
144
|
say(tr("Variables from %s: %s%s", "Переменные из %s: %s%s")
|
|
115
145
|
% (CONFIG_FILE, ", ".join(sorted(config["env"])),
|
|
116
146
|
tr(" (+ PUBLIC_URL for sign-in emails)", " (+ PUBLIC_URL для писем входа)")
|
|
@@ -125,6 +155,16 @@ def cmd_deploy(args):
|
|
|
125
155
|
raise
|
|
126
156
|
deployment_id = up["deploymentId"]
|
|
127
157
|
say(tr("Code uploaded, deploy %s", "Код загружен, деплой %s") % deployment_id)
|
|
158
|
+
gateway_deployment = None
|
|
159
|
+
if gateway.outdated(state): # a new gateway, or one built by another version of small: this version's code
|
|
160
|
+
if not gateway.wait_for_private_address(api, state, interval=args.interval or 0.1):
|
|
161
|
+
say(tr("Railway hasn't given the app its private address yet: if the app answers 508, run small deploy "
|
|
162
|
+
"again",
|
|
163
|
+
"Railway ещё не выдал приложению внутренний адрес: если приложение ответит 508, запустите "
|
|
164
|
+
"small deploy ещё раз"))
|
|
165
|
+
say(tr("Building the gateway (small %s)…", "Собираю шлюз (small %s)…") % __version__)
|
|
166
|
+
gateway_deployment = gateway.upload(api, state)
|
|
167
|
+
save_state(state_path, state)
|
|
128
168
|
if up.get("logsUrl"):
|
|
129
169
|
say(tr("Logs: %s", "Логи: %s") % up["logsUrl"].rstrip("&"))
|
|
130
170
|
|
|
@@ -138,11 +178,40 @@ def cmd_deploy(args):
|
|
|
138
178
|
if status not in OK_STATUSES:
|
|
139
179
|
show_logs(api, deployment_id, build=(status == "FAILED"))
|
|
140
180
|
raise CliError(tr("deploy finished with status %s", "деплой завершился со статусом %s") % status)
|
|
181
|
+
if gateway_deployment:
|
|
182
|
+
status = wait_for_deployment(api, gateway_deployment, args.timeout, args.interval)
|
|
183
|
+
if status not in OK_STATUSES:
|
|
184
|
+
show_logs(api, gateway_deployment, build=(status == "FAILED"))
|
|
185
|
+
raise CliError(tr("the gateway's deploy finished with status %s; the app is not reachable until it works "
|
|
186
|
+
"(small logs --gateway)",
|
|
187
|
+
"деплой шлюза завершился со статусом %s; пока он не заработает, приложение недоступно "
|
|
188
|
+
"(small logs --gateway)") % status)
|
|
141
189
|
say(tr("Done!", "Готово!"))
|
|
190
|
+
if private:
|
|
191
|
+
say(tr("The app is private: it has no sign-in of its own, so small put its gateway in front. Open it yourself: "
|
|
192
|
+
"small open; let people in: small access add ivan@company.com (or small deploy --public next time to "
|
|
193
|
+
"deploy an app open to anyone).",
|
|
194
|
+
"Приложение закрыто: своего входа у него нет, поэтому small поставил перед ним шлюз. Открыть самому: "
|
|
195
|
+
"small open; пустить людей: small access add ivan@company.com (а чтобы задеплоить открытое для всех "
|
|
196
|
+
"приложение, в следующий раз: small deploy --public)."))
|
|
142
197
|
print(url)
|
|
143
198
|
return 0
|
|
144
199
|
|
|
145
200
|
|
|
201
|
+
def ensure_owner_link(root, config):
|
|
202
|
+
"""The owner's invite link (ACCESS_TOKEN in small.json, as small init makes it), so that small open lets the owner
|
|
203
|
+
into a private app right away. Returns small.json as read again."""
|
|
204
|
+
if config["env"].get("ACCESS_TOKEN") or config["env"].get("ACCESS_EMAILS"):
|
|
205
|
+
return config
|
|
206
|
+
path = root / CONFIG_FILE
|
|
207
|
+
raw = json.loads(path.read_text(encoding="utf-8-sig")) if path.is_file() else {}
|
|
208
|
+
raw.setdefault("env", {})["ACCESS_TOKEN"] = tokens.new_token()
|
|
209
|
+
path.write_text(json.dumps(raw, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
210
|
+
say(tr("ACCESS_TOKEN for your own invite link is in %s (small open uses it; the file is for you only).",
|
|
211
|
+
"ACCESS_TOKEN для вашей личной ссылки — в %s (её использует small open; файл только для вас).") % CONFIG_FILE)
|
|
212
|
+
return load_config(path)
|
|
213
|
+
|
|
214
|
+
|
|
146
215
|
def pick_workspace(api):
|
|
147
216
|
try:
|
|
148
217
|
workspaces = api.workspaces()
|
|
@@ -185,15 +254,19 @@ def bind_existing(api, name, workspace_id):
|
|
|
185
254
|
say(tr("Created service %s", "Создан сервис %s") % name)
|
|
186
255
|
return state
|
|
187
256
|
state["serviceId"] = service["id"]
|
|
188
|
-
|
|
257
|
+
gateway_service = next((s for s in services if s["name"] == service["name"] + "-gateway"), None)
|
|
258
|
+
if gateway_service: # deployed with the gateway from another computer: rebuilt by this version on deploy
|
|
259
|
+
state["gateway"] = {"serviceId": gateway_service["id"], "name": gateway_service["name"]}
|
|
260
|
+
domains = api.service_domains(project["id"], env["id"], gateway.public_service(state))
|
|
189
261
|
if domains:
|
|
190
262
|
state["domain"] = domains[0]["domain"]
|
|
191
263
|
return state
|
|
192
264
|
|
|
193
265
|
|
|
194
266
|
def ensure_domain(api, state, port):
|
|
195
|
-
"""Creates a *.up.railway.app domain and tries to rename it
|
|
196
|
-
|
|
267
|
+
"""Creates a *.up.railway.app domain for the public service (the gateway, if there is one) and tries to rename it
|
|
268
|
+
to <name>.up.railway.app."""
|
|
269
|
+
sid, eid = gateway.public_service(state), state["environmentId"]
|
|
197
270
|
domain_id, domain = api.create_domain(sid, eid, port)
|
|
198
271
|
wanted = state["name"] + ".up.railway.app"
|
|
199
272
|
if domain == wanted:
|
|
@@ -32,7 +32,8 @@ def cmd_destroy(args):
|
|
|
32
32
|
raise CliError(tr("project %s not found in Railway (%s). If it is already deleted, delete %s manually",
|
|
33
33
|
"проект %s не найден в Railway (%s). Если он уже удалён, удалите %s вручную")
|
|
34
34
|
% (state["projectId"], e, root / STATE_FILE)) from None
|
|
35
|
-
own_ids = {state["serviceId"]} | {db["serviceId"] for db in state.get("databases", [])}
|
|
35
|
+
own_ids = ({state["serviceId"]} | {db["serviceId"] for db in state.get("databases", [])}
|
|
36
|
+
| ({state["gateway"]["serviceId"]} if (state.get("gateway") or {}).get("serviceId") else set()))
|
|
36
37
|
own = [s for s in services if s["id"] in own_ids]
|
|
37
38
|
others = [s["name"] for s in services if s["id"] not in own_ids]
|
|
38
39
|
if others:
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import argparse
|
|
4
4
|
import re
|
|
5
5
|
|
|
6
|
+
from .. import gateway
|
|
6
7
|
from ..common import CliError, app_dir, linked_state, require_token, say
|
|
7
8
|
from ..i18n import tr
|
|
8
9
|
from ..railway import Railway
|
|
@@ -51,7 +52,8 @@ def normalize(domain):
|
|
|
51
52
|
|
|
52
53
|
|
|
53
54
|
def ids(state):
|
|
54
|
-
|
|
55
|
+
"""The service that has the public address: behind the gateway, the gateway."""
|
|
56
|
+
return state["projectId"], state["environmentId"], gateway.public_service(state)
|
|
55
57
|
|
|
56
58
|
|
|
57
59
|
def cmd_domain_list(args):
|
|
@@ -4,6 +4,7 @@ next small deploy would bring back the old values from small.json."""
|
|
|
4
4
|
import argparse
|
|
5
5
|
import json
|
|
6
6
|
|
|
7
|
+
from .. import gateway
|
|
7
8
|
from ..common import CliError, app_dir, is_linked, require_token, say
|
|
8
9
|
from ..config import CONFIG_FILE, ENV_NAME_RE, STATE_FILE, load_config, load_state
|
|
9
10
|
from ..i18n import tr
|
|
@@ -50,7 +51,9 @@ def change_env(root, set_vars=None, unset=(), deploy=True, railway_only=None):
|
|
|
50
51
|
"""Changes variables in Railway (if the app is deployed) and in small.json, so that they don't diverge.
|
|
51
52
|
|
|
52
53
|
Railway first: if it refuses, small.json stays as it was. railway_only are variables that small manages
|
|
53
|
-
itself and doesn't write to small.json (e.g. PUBLIC_URL).
|
|
54
|
+
itself and doesn't write to small.json (e.g. PUBLIC_URL). Behind the gateway, each variable goes to its service
|
|
55
|
+
(sign-in variables to the gateway, see gateway.services_for), and only the services that changed restart.
|
|
56
|
+
Returns True if Railway was touched.
|
|
54
57
|
"""
|
|
55
58
|
set_vars, unset = dict(set_vars or {}), list(unset)
|
|
56
59
|
config_path, state = root / CONFIG_FILE, load_state(root / STATE_FILE)
|
|
@@ -59,16 +62,26 @@ def change_env(root, set_vars=None, unset=(), deploy=True, railway_only=None):
|
|
|
59
62
|
|
|
60
63
|
if is_linked(state):
|
|
61
64
|
api = Railway(require_token())
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
65
|
+
services = [state["serviceId"]] + ([state["gateway"]["serviceId"]] if gateway.is_on(state) else [])
|
|
66
|
+
changed = []
|
|
67
|
+
for service_id in services:
|
|
68
|
+
mine = {k: v for k, v in set_vars.items() if service_id in gateway.services_for(k, state)}
|
|
69
|
+
if service_id == gateway.public_service(state):
|
|
70
|
+
mine.update(railway_only or {}) # small's own (the public address): for the service that has it
|
|
71
|
+
gone = [name for name in unset if service_id in gateway.services_for(name, state)]
|
|
72
|
+
if len(services) > 1 and not (mine or gone):
|
|
73
|
+
continue
|
|
74
|
+
ids = (state["projectId"], state["environmentId"], service_id)
|
|
75
|
+
existing = api.service_variables(*ids)
|
|
76
|
+
if mine:
|
|
77
|
+
api.upsert_variables(*ids, mine)
|
|
78
|
+
for name in gone:
|
|
79
|
+
if name in existing:
|
|
80
|
+
api.delete_variable(*ids, name)
|
|
81
|
+
changed.append(service_id)
|
|
70
82
|
if deploy:
|
|
71
|
-
|
|
83
|
+
for service_id in changed or [state["serviceId"]]:
|
|
84
|
+
api.redeploy(service_id, state["environmentId"])
|
|
72
85
|
|
|
73
86
|
env = raw.setdefault("env", {})
|
|
74
87
|
env.update(set_vars)
|