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.
Files changed (63) hide show
  1. {small_software-0.8.0/small_software.egg-info → small_software-0.9.0}/PKG-INFO +45 -6
  2. {small_software-0.8.0 → small_software-0.9.0}/README.md +44 -5
  3. {small_software-0.8.0 → small_software-0.9.0}/pyproject.toml +1 -1
  4. small_software-0.9.0/small_cli/__init__.py +1 -0
  5. {small_software-0.8.0 → small_software-0.9.0}/small_cli/access.py +22 -10
  6. {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/__init__.py +4 -3
  7. {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/access.py +7 -5
  8. {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/deploy.py +80 -7
  9. {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/destroy.py +2 -1
  10. {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/domain.py +3 -1
  11. {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/env.py +23 -10
  12. small_software-0.9.0/small_cli/commands/gateway.py +251 -0
  13. {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/logs.py +7 -1
  14. {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/sleep.py +6 -3
  15. {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/sso.py +2 -2
  16. {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/volume.py +20 -4
  17. small_software-0.9.0/small_cli/gateway.py +100 -0
  18. {small_software-0.8.0 → small_software-0.9.0}/small_cli/mcp.py +5 -2
  19. {small_software-0.8.0 → small_software-0.9.0}/small_cli/railway.py +3 -0
  20. {small_software-0.8.0/small_cli/templates/streamlit → small_software-0.9.0/small_cli/templates/fastapi}/magic_link.py +131 -25
  21. small_software-0.9.0/small_cli/templates/gateway/Caddyfile +41 -0
  22. small_software-0.9.0/small_cli/templates/gateway/Dockerfile +7 -0
  23. small_software-0.9.0/small_cli/templates/gateway/gateway.py +94 -0
  24. {small_software-0.8.0/small_cli/templates/fastapi → small_software-0.9.0/small_cli/templates/gateway}/magic_link.py +131 -25
  25. {small_software-0.8.0 → small_software-0.9.0}/small_cli/templates/node-http/magic-link.js +68 -11
  26. {small_software-0.8.0 → small_software-0.9.0}/small_cli/templates/python-http/magic_link.py +131 -25
  27. {small_software-0.8.0 → small_software-0.9.0}/small_cli/templates/streamlit/app.py +20 -1
  28. small_software-0.9.0/small_cli/templates/streamlit/magic_link.py +1503 -0
  29. {small_software-0.8.0 → small_software-0.9.0/small_software.egg-info}/PKG-INFO +45 -6
  30. {small_software-0.8.0 → small_software-0.9.0}/small_software.egg-info/SOURCES.txt +7 -0
  31. small_software-0.9.0/tests/test_gateway.py +514 -0
  32. {small_software-0.8.0 → small_software-0.9.0}/tests/test_small.py +18 -4
  33. {small_software-0.8.0 → small_software-0.9.0}/tests/test_streamlit_email.py +18 -0
  34. small_software-0.8.0/small_cli/__init__.py +0 -1
  35. {small_software-0.8.0 → small_software-0.9.0}/LICENSE +0 -0
  36. {small_software-0.8.0 → small_software-0.9.0}/setup.cfg +0 -0
  37. {small_software-0.8.0 → small_software-0.9.0}/small_cli/__main__.py +0 -0
  38. {small_software-0.8.0 → small_software-0.9.0}/small_cli/archive.py +0 -0
  39. {small_software-0.8.0 → small_software-0.9.0}/small_cli/cli.py +0 -0
  40. {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/dashboard.py +0 -0
  41. {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/db.py +0 -0
  42. {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/init.py +0 -0
  43. {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/link.py +0 -0
  44. {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/mail.py +0 -0
  45. {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/mcp.py +0 -0
  46. {small_software-0.8.0 → small_software-0.9.0}/small_cli/commands/notify.py +0 -0
  47. {small_software-0.8.0 → small_software-0.9.0}/small_cli/common.py +0 -0
  48. {small_software-0.8.0 → small_software-0.9.0}/small_cli/config.py +0 -0
  49. {small_software-0.8.0 → small_software-0.9.0}/small_cli/dashboard.py +0 -0
  50. {small_software-0.8.0 → small_software-0.9.0}/small_cli/i18n.py +0 -0
  51. {small_software-0.8.0 → small_software-0.9.0}/small_cli/scaffold.py +0 -0
  52. {small_software-0.8.0 → small_software-0.9.0}/small_cli/templates/fastapi/main.py +0 -0
  53. {small_software-0.8.0 → small_software-0.9.0}/small_cli/templates/fastapi/requirements.txt +0 -0
  54. {small_software-0.8.0 → small_software-0.9.0}/small_cli/templates/node-http/package.json +0 -0
  55. {small_software-0.8.0 → small_software-0.9.0}/small_cli/templates/node-http/server.js +0 -0
  56. {small_software-0.8.0 → small_software-0.9.0}/small_cli/templates/python-http/app.py +0 -0
  57. {small_software-0.8.0 → small_software-0.9.0}/small_cli/templates/streamlit/requirements.txt +0 -0
  58. {small_software-0.8.0 → small_software-0.9.0}/small_software.egg-info/dependency_links.txt +0 -0
  59. {small_software-0.8.0 → small_software-0.9.0}/small_software.egg-info/entry_points.txt +0 -0
  60. {small_software-0.8.0 → small_software-0.9.0}/small_software.egg-info/top_level.txt +0 -0
  61. {small_software-0.8.0 → small_software-0.9.0}/tests/test_dashboard_share.py +0 -0
  62. {small_software-0.8.0 → small_software-0.9.0}/tests/test_mcp.py +0 -0
  63. {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.8.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]] # persistent disk; no argument shows the status
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] # log of the latest deploy
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 (`small db`).
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]] # persistent disk; no argument shows the status
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] # log of the latest deploy
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 (`small db`).
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.8.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
- env = dict(Railway(token_from_env()).service_variables(
69
- state["projectId"], state["environmentId"], state["serviceId"]))
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); otherwise what is
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, sleep,
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, destroy)
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
- problem = tokens.email_login_problem(root)
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
- problem = tokens.feature_problem(root)
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, load_state(root / STATE_FILE), warn=warn)
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["serviceId"], limit=limit)
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
- service_id = api.create_service(project_id, environment_id, name)
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
- api.upsert_variables(state["projectId"], state["environmentId"], state["serviceId"], variables)
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
- domains = api.service_domains(project["id"], env["id"], service["id"])
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 to <name>.up.railway.app."""
196
- sid, eid = state["serviceId"], state["environmentId"]
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
- return state["projectId"], state["environmentId"], state["serviceId"]
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). Returns True if Railway was touched.
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
- ids = (state["projectId"], state["environmentId"], state["serviceId"])
63
- existing = api.service_variables(*ids)
64
- upsert = dict(set_vars, **(railway_only or {}))
65
- if upsert:
66
- api.upsert_variables(*ids, upsert)
67
- for name in unset:
68
- if name in existing:
69
- api.delete_variable(*ids, name)
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
- api.redeploy(state["serviceId"], state["environmentId"])
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)