small-software 0.6.0__py3-none-any.whl
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_cli/__init__.py +1 -0
- small_cli/__main__.py +3 -0
- small_cli/access.py +114 -0
- small_cli/archive.py +127 -0
- small_cli/cli.py +76 -0
- small_cli/commands/__init__.py +8 -0
- small_cli/commands/access.py +167 -0
- small_cli/commands/dashboard.py +49 -0
- small_cli/commands/db.py +154 -0
- small_cli/commands/deploy.py +248 -0
- small_cli/commands/destroy.py +63 -0
- small_cli/commands/domain.py +123 -0
- small_cli/commands/env.py +151 -0
- small_cli/commands/init.py +86 -0
- small_cli/commands/link.py +53 -0
- small_cli/commands/logs.py +59 -0
- small_cli/commands/mail.py +203 -0
- small_cli/commands/notify.py +147 -0
- small_cli/commands/sleep.py +61 -0
- small_cli/common.py +92 -0
- small_cli/config.py +82 -0
- small_cli/dashboard.py +648 -0
- small_cli/i18n.py +55 -0
- small_cli/railway.py +369 -0
- small_cli/scaffold.py +81 -0
- small_cli/templates/fastapi/magic_link.py +675 -0
- small_cli/templates/fastapi/main.py +67 -0
- small_cli/templates/fastapi/requirements.txt +2 -0
- small_cli/templates/node-http/magic-link.js +509 -0
- small_cli/templates/node-http/package.json +11 -0
- small_cli/templates/node-http/server.js +33 -0
- small_cli/templates/python-http/app.py +47 -0
- small_cli/templates/python-http/magic_link.py +675 -0
- small_cli/templates/streamlit/app.py +181 -0
- small_cli/templates/streamlit/requirements.txt +1 -0
- small_software-0.6.0.dist-info/METADATA +559 -0
- small_software-0.6.0.dist-info/RECORD +41 -0
- small_software-0.6.0.dist-info/WHEEL +5 -0
- small_software-0.6.0.dist-info/entry_points.txt +2 -0
- small_software-0.6.0.dist-info/licenses/LICENSE +21 -0
- small_software-0.6.0.dist-info/top_level.txt +1 -0
small_cli/commands/db.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""small db: база данных для приложения в один шаг.
|
|
2
|
+
|
|
3
|
+
add — разворачивает шаблон Railway (как `railway add --database`) в проекте приложения и передаёт приложению
|
|
4
|
+
ссылку на базу через переменную-ссылку Railway (${{Postgres.DATABASE_URL}}), поэтому пароль нигде не хранится
|
|
5
|
+
у нас. База записывается в .small.json, и small destroy удаляет её вместе с приложением.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from ..common import CliError, app_dir, ask, linked_state, require_token, say
|
|
12
|
+
from ..config import STATE_FILE, save_state
|
|
13
|
+
from ..i18n import tr
|
|
14
|
+
from ..railway import Railway, RailwayError
|
|
15
|
+
from .env import add_no_deploy, change_env, report_env_change
|
|
16
|
+
|
|
17
|
+
# Тип -> (код шаблона Railway, переменная приложения, переменная базы).
|
|
18
|
+
DATABASES = {
|
|
19
|
+
"postgres": ("postgres", "DATABASE_URL", "DATABASE_URL"),
|
|
20
|
+
}
|
|
21
|
+
WORKFLOW_TIMEOUT = 300
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def register(sub):
|
|
25
|
+
d = sub.add_parser("db", help=tr("databases: add, list, remove", "база данных: добавить, список, удалить"),
|
|
26
|
+
description=tr("App databases. add deploys a database in the app's project and passes the "
|
|
27
|
+
"app a variable that points to it (for postgres, DATABASE_URL). "
|
|
28
|
+
"small destroy deletes databases together with the app.",
|
|
29
|
+
"Базы данных приложения. add разворачивает базу в проекте приложения и передаёт "
|
|
30
|
+
"приложению переменную со ссылкой на неё (для postgres — DATABASE_URL). "
|
|
31
|
+
"small destroy удаляет базы вместе с приложением."))
|
|
32
|
+
d.add_argument("-C", "--path", default=".", help=tr("app folder (default: current folder)",
|
|
33
|
+
"папка приложения (по умолчанию текущая)"))
|
|
34
|
+
d.set_defaults(func=cmd_db_list)
|
|
35
|
+
db_sub = d.add_subparsers(dest="db_command", metavar="<list|add|remove>")
|
|
36
|
+
dl = db_sub.add_parser("list", help=tr("app databases", "базы приложения"))
|
|
37
|
+
dl.set_defaults(func=cmd_db_list)
|
|
38
|
+
da = db_sub.add_parser("add", help=tr("add a database: small db add postgres",
|
|
39
|
+
"добавить базу: small db add postgres"))
|
|
40
|
+
da.add_argument("kind", choices=sorted(DATABASES))
|
|
41
|
+
add_no_deploy(da)
|
|
42
|
+
da.set_defaults(func=cmd_db_add)
|
|
43
|
+
dr = db_sub.add_parser("remove", help=tr("delete a database with all its data: small db remove postgres",
|
|
44
|
+
"удалить базу вместе с данными: small db remove postgres"))
|
|
45
|
+
dr.add_argument("kind", choices=sorted(DATABASES))
|
|
46
|
+
dr.add_argument("--yes", action="store_true", help=tr("don't ask for confirmation (for scripts)",
|
|
47
|
+
"не спрашивать подтверждение (для скриптов)"))
|
|
48
|
+
add_no_deploy(dr)
|
|
49
|
+
dr.set_defaults(func=cmd_db_remove)
|
|
50
|
+
for parser in (dl, da, dr):
|
|
51
|
+
parser.add_argument("-C", "--path", default=argparse.SUPPRESS, help=tr("app folder", "папка приложения"))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cmd_db_list(args):
|
|
55
|
+
root = app_dir(args.path)
|
|
56
|
+
state = linked_state(root)
|
|
57
|
+
databases = state.get("databases", [])
|
|
58
|
+
if not databases:
|
|
59
|
+
say(tr("No databases. Add one: small db add postgres", "Баз данных нет. Добавить: small db add postgres"))
|
|
60
|
+
return 0
|
|
61
|
+
api = Railway(require_token())
|
|
62
|
+
for db in databases:
|
|
63
|
+
try:
|
|
64
|
+
deployment = api.instance_status(db["serviceId"], state["environmentId"])["deployment"]
|
|
65
|
+
status = deployment["status"] if deployment else tr("no deploys", "нет деплоев")
|
|
66
|
+
except (RailwayError, KeyError, TypeError) as e: # базу могли удалить в дашборде Railway
|
|
67
|
+
status = tr("unavailable: %s", "недоступна: %s") % e
|
|
68
|
+
print("%-10s %-20s %s" % (db["type"], db["name"], status))
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def cmd_db_add(args):
|
|
73
|
+
root = app_dir(args.path)
|
|
74
|
+
state = linked_state(root)
|
|
75
|
+
if any(db["type"] == args.kind for db in state.get("databases", [])):
|
|
76
|
+
raise CliError(tr("%s is already added. List: small db list", "%s уже подключена. Список: small db list")
|
|
77
|
+
% args.kind)
|
|
78
|
+
template_code, app_var, db_var = DATABASES[args.kind]
|
|
79
|
+
api = Railway(require_token())
|
|
80
|
+
project, env = state["projectId"], state["environmentId"]
|
|
81
|
+
|
|
82
|
+
before = {s["id"] for s in api.project_services(project)[1]}
|
|
83
|
+
template = api.template(template_code)
|
|
84
|
+
config = template["serializedConfig"]
|
|
85
|
+
for service in config.get("services", {}).values(): # как `railway add`: значения по умолчанию шаблона
|
|
86
|
+
for variable in (service.get("variables") or {}).values():
|
|
87
|
+
if variable and variable.get("defaultValue"):
|
|
88
|
+
variable["value"] = variable["defaultValue"]
|
|
89
|
+
say(tr("Deploying %s in project %s…", "Разворачиваю %s в проекте %s…")
|
|
90
|
+
% (template["name"], state.get("name", project)))
|
|
91
|
+
workflow = api.deploy_template(project, env, template["id"], config)
|
|
92
|
+
if workflow:
|
|
93
|
+
wait_workflow(api, workflow)
|
|
94
|
+
|
|
95
|
+
created = [s for s in api.project_services(project)[1] if s["id"] not in before]
|
|
96
|
+
if len(created) != 1:
|
|
97
|
+
raise CliError(tr("expected one new database service, got: %s",
|
|
98
|
+
"ожидался один новый сервис базы, появилось: %s")
|
|
99
|
+
% (", ".join(s["name"] for s in created) or "0"))
|
|
100
|
+
service = created[0]
|
|
101
|
+
state.setdefault("databases", []).append({"type": args.kind, "serviceId": service["id"], "name": service["name"]})
|
|
102
|
+
save_state(root / STATE_FILE, state) # сразу: даже если дальше что-то упадёт, destroy удалит и базу
|
|
103
|
+
|
|
104
|
+
reference = "${{%s.%s}}" % (service["name"], db_var)
|
|
105
|
+
linked = change_env(root, {app_var: reference}, deploy=not args.no_deploy)
|
|
106
|
+
report_env_change(linked, not args.no_deploy, tr("Database %s (%s) added, %s = %s",
|
|
107
|
+
"База %s (%s) добавлена, %s = %s")
|
|
108
|
+
% (args.kind, service["name"], app_var, reference))
|
|
109
|
+
say(tr("The database takes about a minute to start; if the app connects to it on startup, "
|
|
110
|
+
"the first start may happen before it is ready.",
|
|
111
|
+
"База запускается около минуты; если приложение подключается к ней при старте, "
|
|
112
|
+
"первый запуск может не застать её."))
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def wait_workflow(api, workflow_id):
|
|
117
|
+
deadline = time.monotonic() + WORKFLOW_TIMEOUT
|
|
118
|
+
while True:
|
|
119
|
+
result = api.workflow_status(workflow_id)
|
|
120
|
+
if result["status"] == "Complete":
|
|
121
|
+
return
|
|
122
|
+
if result["status"] in ("Error", "NotFound"):
|
|
123
|
+
raise CliError(tr("Railway could not deploy the database: %s", "Railway не смог развернуть базу: %s")
|
|
124
|
+
% (result.get("error") or result["status"]))
|
|
125
|
+
if time.monotonic() > deadline:
|
|
126
|
+
raise CliError(tr("the database was not deployed within %d s; check the project in the Railway dashboard",
|
|
127
|
+
"база не развернулась за %d с; проверьте проект в дашборде Railway") % WORKFLOW_TIMEOUT)
|
|
128
|
+
time.sleep(2)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def cmd_db_remove(args):
|
|
132
|
+
root = app_dir(args.path)
|
|
133
|
+
state = linked_state(root)
|
|
134
|
+
db = next((d for d in state.get("databases", []) if d["type"] == args.kind), None)
|
|
135
|
+
if db is None:
|
|
136
|
+
raise CliError(tr("%s is not added. List: small db list", "%s не подключена. Список: small db list")
|
|
137
|
+
% args.kind)
|
|
138
|
+
say(tr("This will delete the %s database (%s) with all its data.",
|
|
139
|
+
"Будет удалена база %s (%s) вместе со всеми данными.") % (args.kind, db["name"]))
|
|
140
|
+
if not args.yes:
|
|
141
|
+
answer = ask(tr("Type %s to confirm: ", "Введите %s для подтверждения: ") % db["name"])
|
|
142
|
+
if answer is None:
|
|
143
|
+
raise CliError(tr("confirm the deletion with --yes. Nothing was deleted",
|
|
144
|
+
"подтвердите удаление флагом --yes. Ничего не удалено"))
|
|
145
|
+
if answer != db["name"]:
|
|
146
|
+
raise CliError(tr("the name did not match. Nothing was deleted", "имя не совпало. Ничего не удалено"))
|
|
147
|
+
api = Railway(require_token())
|
|
148
|
+
api.delete_service(db["serviceId"])
|
|
149
|
+
state["databases"] = [d for d in state["databases"] if d is not db]
|
|
150
|
+
save_state(root / STATE_FILE, state)
|
|
151
|
+
linked = change_env(root, unset=[DATABASES[args.kind][1]], deploy=not args.no_deploy)
|
|
152
|
+
report_env_change(linked, not args.no_deploy, tr("Database %s deleted, %s removed", "База %s удалена, %s убрана")
|
|
153
|
+
% (args.kind, DATABASES[args.kind][1]))
|
|
154
|
+
return 0
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""small deploy: упаковать код -> найти проект по имени или создать его (projectCreate + serviceCreate)
|
|
2
|
+
-> serviceDomainCreate -> записать переменные из small.json (variableCollectionUpsert) -> загрузить архив
|
|
3
|
+
(POST .../up) -> опрашивать deployment(id).status -> напечатать https://<домен>.
|
|
4
|
+
|
|
5
|
+
Прогресс пишется в stderr, итоговый URL — в stdout, поэтому `URL=$(small deploy)` работает.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import os
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from ..archive import collect_files, make_tarball
|
|
13
|
+
from ..common import CliError, app_dir, format_elapsed, human_size, require_token, say, slugify
|
|
14
|
+
from ..config import CONFIG_FILE, STATE_FILE, load_config, load_state, save_state
|
|
15
|
+
from ..i18n import lang, tr
|
|
16
|
+
from ..railway import Railway, RailwayError
|
|
17
|
+
|
|
18
|
+
OK_STATUSES = {"SUCCESS", "SLEEPING"}
|
|
19
|
+
FAILED_STATUSES = {"FAILED", "CRASHED", "REMOVED", "REMOVING", "SKIPPED", "NEEDS_APPROVAL"}
|
|
20
|
+
MAX_POLL_ERRORS = 5
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def register(sub):
|
|
24
|
+
d = sub.add_parser("deploy", help=tr("deploy a code folder and print its public URL",
|
|
25
|
+
"задеплоить папку с кодом и вывести публичный URL"),
|
|
26
|
+
description=tr("Deploy a code folder to Railway and print its public URL. "
|
|
27
|
+
"The token is read from RAILWAY_API_TOKEN.",
|
|
28
|
+
"Задеплоить папку с кодом в Railway и вывести публичный URL. "
|
|
29
|
+
"Токен берётся из RAILWAY_API_TOKEN."))
|
|
30
|
+
d.add_argument("path", nargs="?", default=".",
|
|
31
|
+
help=tr("code folder (default: current folder)", "папка с кодом (по умолчанию текущая)"))
|
|
32
|
+
d.add_argument("--name", help=tr("project and service name (default: name from small.json, "
|
|
33
|
+
"otherwise the folder name)",
|
|
34
|
+
"имя проекта и сервиса (по умолчанию — name из small.json, иначе имя папки)"))
|
|
35
|
+
d.add_argument("--workspace", help=tr("workspace ID if you have several (or the RAILWAY_WORKSPACE_ID variable)",
|
|
36
|
+
"ID workspace, если их несколько (или переменная RAILWAY_WORKSPACE_ID)"))
|
|
37
|
+
d.add_argument("--port", type=int, help=tr("port the app listens on (by default Railway uses $PORT)",
|
|
38
|
+
"порт, который слушает приложение (по умолчанию Railway берёт $PORT)"))
|
|
39
|
+
d.add_argument("-m", "--message", help=tr("deploy message", "подпись деплоя"))
|
|
40
|
+
d.add_argument("--new", action="store_true",
|
|
41
|
+
help=tr("create a new project even if %s exists", "создать новый проект, даже если есть %s")
|
|
42
|
+
% STATE_FILE)
|
|
43
|
+
d.add_argument("--no-wait", action="store_true",
|
|
44
|
+
help=tr("do not wait for the build to finish", "не ждать окончания сборки"))
|
|
45
|
+
d.add_argument("--timeout", type=int, default=900,
|
|
46
|
+
help=tr("how many seconds to wait for the build (default: 900)",
|
|
47
|
+
"сколько секунд ждать сборку (по умолчанию 900)"))
|
|
48
|
+
d.add_argument("--interval", type=float, default=3.0, help=argparse.SUPPRESS)
|
|
49
|
+
d.set_defaults(func=cmd_deploy)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cmd_deploy(args):
|
|
53
|
+
token = require_token()
|
|
54
|
+
root = app_dir(args.path)
|
|
55
|
+
|
|
56
|
+
config = load_config(root / CONFIG_FILE)
|
|
57
|
+
state_path = root / STATE_FILE
|
|
58
|
+
state = {} if args.new else load_state(state_path)
|
|
59
|
+
reused = bool(state.get("serviceId"))
|
|
60
|
+
explicit_name = args.name or config["name"]
|
|
61
|
+
name = slugify(explicit_name or root.name)
|
|
62
|
+
|
|
63
|
+
files = collect_files(str(root), skip_root_files=(CONFIG_FILE, STATE_FILE))
|
|
64
|
+
if not files:
|
|
65
|
+
raise CliError(tr("nothing to deploy in %s (everything filtered out by .gitignore/.railwayignore?)",
|
|
66
|
+
"в %s нечего деплоить (всё отфильтровано .gitignore/.railwayignore?)") % root)
|
|
67
|
+
tarball = make_tarball(str(root), files)
|
|
68
|
+
say(tr("Packed %d file (%s)" if len(files) == 1 else "Packed %d files (%s)", "Упаковано файлов: %d (%s)")
|
|
69
|
+
% (len(files), human_size(len(tarball))))
|
|
70
|
+
|
|
71
|
+
api = Railway(token)
|
|
72
|
+
if reused:
|
|
73
|
+
say(tr("Project %s from %s", "Проект %s из %s") % (state["name"], STATE_FILE))
|
|
74
|
+
if explicit_name and name != state["name"]:
|
|
75
|
+
say(tr(" Name %s not applied: the folder is already linked to %s. To link it to %s, delete %s.",
|
|
76
|
+
" Имя %s не применено: папка уже привязана к %s. Чтобы привязать её к %s, удалите %s.")
|
|
77
|
+
% (name, state["name"], name, STATE_FILE))
|
|
78
|
+
else:
|
|
79
|
+
workspace_id = args.workspace or os.environ.get("RAILWAY_WORKSPACE_ID") or pick_workspace(api)
|
|
80
|
+
# Явно заданное имя (small.json или --name) идентифицирует сервис: если проект уже есть
|
|
81
|
+
# в Railway (например, деплой с другого компьютера), подключаемся к нему, а не создаём дубликат.
|
|
82
|
+
state = bind_existing(api, name, workspace_id) if explicit_name and not args.new else None
|
|
83
|
+
if state:
|
|
84
|
+
say(tr("Found project %s in Railway, linked the folder to it",
|
|
85
|
+
"Найден проект %s в Railway, папка привязана к нему") % name)
|
|
86
|
+
else:
|
|
87
|
+
project_id, environment_id = api.create_project(name, workspace_id)
|
|
88
|
+
say(tr("Created project %s", "Создан проект %s") % name)
|
|
89
|
+
service_id = api.create_service(project_id, environment_id, name)
|
|
90
|
+
say(tr("Created service %s", "Создан сервис %s") % name)
|
|
91
|
+
state = {"name": name, "projectId": project_id,
|
|
92
|
+
"environmentId": environment_id, "serviceId": service_id}
|
|
93
|
+
# Сохраняем сразу: если сборка упадёт, повторный запуск не наплодит новых проектов.
|
|
94
|
+
save_state(state_path, state)
|
|
95
|
+
|
|
96
|
+
if not state.get("domain"):
|
|
97
|
+
# Домен — до загрузки кода: его адрес нужен уже первой сборке (ссылки в письмах для входа по почте).
|
|
98
|
+
state["domain"] = ensure_domain(api, state, args.port)
|
|
99
|
+
save_state(state_path, state)
|
|
100
|
+
say(tr("Public domain: %s", "Публичный домен: %s") % state["domain"])
|
|
101
|
+
|
|
102
|
+
variables = dict(config["env"])
|
|
103
|
+
if variables.get("ACCESS_EMAILS") and "PUBLIC_URL" not in variables:
|
|
104
|
+
# После переименования домена Railway оставляет в RAILWAY_PUBLIC_DOMAIN прежний адрес — ссылки для входа
|
|
105
|
+
# вели бы на 404. Поэтому настоящий адрес передаём сами (только в Railway: домен может смениться).
|
|
106
|
+
variables["PUBLIC_URL"] = "https://" + state["domain"]
|
|
107
|
+
if variables and "OWNER_LANG" not in variables:
|
|
108
|
+
variables["OWNER_LANG"] = lang() # строки лога и уведомления в Telegram — на языке владельца
|
|
109
|
+
if variables:
|
|
110
|
+
# До загрузки кода, чтобы новая сборка сразу получила переменные. Значения не печатаем.
|
|
111
|
+
api.upsert_variables(state["projectId"], state["environmentId"], state["serviceId"], variables)
|
|
112
|
+
say(tr("Variables from %s: %s%s", "Переменные из %s: %s%s")
|
|
113
|
+
% (CONFIG_FILE, ", ".join(sorted(config["env"])),
|
|
114
|
+
tr(" (+ PUBLIC_URL for sign-in emails)", " (+ PUBLIC_URL для писем входа)")
|
|
115
|
+
if "PUBLIC_URL" not in config["env"] and "PUBLIC_URL" in variables else ""))
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
up = api.upload(state["projectId"], state["environmentId"], state["serviceId"], tarball, args.message)
|
|
119
|
+
except RailwayError as e:
|
|
120
|
+
if reused:
|
|
121
|
+
raise RailwayError(tr("%s\nIf the project was deleted in Railway, run `small deploy --new`.",
|
|
122
|
+
"%s\nЕсли проект удалён в Railway, запустите `small deploy --new`.") % e) from None
|
|
123
|
+
raise
|
|
124
|
+
deployment_id = up["deploymentId"]
|
|
125
|
+
say(tr("Code uploaded, deploy %s", "Код загружен, деплой %s") % deployment_id)
|
|
126
|
+
if up.get("logsUrl"):
|
|
127
|
+
say(tr("Logs: %s", "Логи: %s") % up["logsUrl"].rstrip("&"))
|
|
128
|
+
|
|
129
|
+
url = "https://" + state["domain"]
|
|
130
|
+
|
|
131
|
+
if args.no_wait:
|
|
132
|
+
print(url)
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
status = wait_for_deployment(api, deployment_id, args.timeout, args.interval)
|
|
136
|
+
if status not in OK_STATUSES:
|
|
137
|
+
show_logs(api, deployment_id, build=(status == "FAILED"))
|
|
138
|
+
raise CliError(tr("deploy finished with status %s", "деплой завершился со статусом %s") % status)
|
|
139
|
+
say(tr("Done!", "Готово!"))
|
|
140
|
+
print(url)
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def pick_workspace(api):
|
|
145
|
+
try:
|
|
146
|
+
workspaces = api.workspaces()
|
|
147
|
+
except RailwayError:
|
|
148
|
+
return None # токен workspace: `me` ему недоступен, проект создастся в его же workspace
|
|
149
|
+
if len(workspaces) <= 1:
|
|
150
|
+
return workspaces[0]["id"] if workspaces else None
|
|
151
|
+
listing = "\n".join(" %s %s" % (w["id"], w["name"]) for w in workspaces)
|
|
152
|
+
raise CliError(tr("the account has several workspaces — pick one: small deploy --workspace <id>\n",
|
|
153
|
+
"у аккаунта несколько workspace — укажите нужный: small deploy --workspace <id>\n") + listing)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def bind_existing(api, name, workspace_id):
|
|
157
|
+
"""Ищет в Railway проект с именем name и возвращает state для привязки к нему, либо None."""
|
|
158
|
+
try:
|
|
159
|
+
projects = api.find_projects(name, workspace_id)
|
|
160
|
+
except RailwayError as e:
|
|
161
|
+
say(tr("Could not check whether project %s exists in Railway (%s), creating a new one",
|
|
162
|
+
"Не удалось проверить, есть ли проект %s в Railway (%s), создаю новый") % (name, e))
|
|
163
|
+
return None
|
|
164
|
+
if not projects:
|
|
165
|
+
return None
|
|
166
|
+
if len(projects) > 1:
|
|
167
|
+
raise CliError(tr("Railway has several projects named %s (%s). Delete the extra ones or choose another name.",
|
|
168
|
+
"в Railway несколько проектов с именем %s (%s). Удалите лишние или задайте другое имя.")
|
|
169
|
+
% (name, ", ".join(p["id"] for p in projects)))
|
|
170
|
+
project = projects[0]
|
|
171
|
+
if not project["environments"]:
|
|
172
|
+
raise CliError(tr("project %s in Railway has no environments", "у проекта %s в Railway нет окружений") % name)
|
|
173
|
+
env = next((e for e in project["environments"] if e["name"] == "production"), project["environments"][0])
|
|
174
|
+
services = project["services"]
|
|
175
|
+
service = next((s for s in services if s["name"] == name), services[0] if len(services) == 1 else None)
|
|
176
|
+
if service is None and services:
|
|
177
|
+
raise CliError(tr("project %s has several services (%s), none of them is named %s",
|
|
178
|
+
"в проекте %s несколько сервисов (%s), ни один не называется %s")
|
|
179
|
+
% (name, ", ".join(s["name"] for s in services), name))
|
|
180
|
+
state = {"name": name, "projectId": project["id"], "environmentId": env["id"]}
|
|
181
|
+
if service is None:
|
|
182
|
+
state["serviceId"] = api.create_service(project["id"], env["id"], name)
|
|
183
|
+
say(tr("Created service %s", "Создан сервис %s") % name)
|
|
184
|
+
return state
|
|
185
|
+
state["serviceId"] = service["id"]
|
|
186
|
+
domains = api.service_domains(project["id"], env["id"], service["id"])
|
|
187
|
+
if domains:
|
|
188
|
+
state["domain"] = domains[0]["domain"]
|
|
189
|
+
return state
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def ensure_domain(api, state, port):
|
|
193
|
+
"""Создаёт домен *.up.railway.app и пытается переименовать его в <name>.up.railway.app."""
|
|
194
|
+
sid, eid = state["serviceId"], state["environmentId"]
|
|
195
|
+
domain_id, domain = api.create_domain(sid, eid, port)
|
|
196
|
+
wanted = state["name"] + ".up.railway.app"
|
|
197
|
+
if domain == wanted:
|
|
198
|
+
return domain
|
|
199
|
+
try:
|
|
200
|
+
if not api.domain_available(wanted):
|
|
201
|
+
return domain
|
|
202
|
+
api.rename_domain(domain_id, sid, eid, wanted, port)
|
|
203
|
+
except RailwayError as e:
|
|
204
|
+
say(tr("Could not claim %s (%s), keeping %s", "Не удалось занять %s (%s), оставляю %s") % (wanted, e, domain))
|
|
205
|
+
return domain
|
|
206
|
+
try:
|
|
207
|
+
return api.service_domain(state["projectId"], eid, sid, domain_id) or wanted
|
|
208
|
+
except RailwayError:
|
|
209
|
+
return wanted
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def wait_for_deployment(api, deployment_id, timeout, interval):
|
|
213
|
+
started = time.monotonic()
|
|
214
|
+
last, errors = None, 0
|
|
215
|
+
while True:
|
|
216
|
+
try:
|
|
217
|
+
status = api.deployment_status(deployment_id)
|
|
218
|
+
errors = 0
|
|
219
|
+
except RailwayError as e:
|
|
220
|
+
errors += 1
|
|
221
|
+
if errors >= MAX_POLL_ERRORS:
|
|
222
|
+
raise
|
|
223
|
+
say(tr(" retrying status request: %s", " повторяю запрос статуса: %s") % e)
|
|
224
|
+
status = last
|
|
225
|
+
elapsed = time.monotonic() - started
|
|
226
|
+
if status != last:
|
|
227
|
+
say("[%s] %s" % (format_elapsed(elapsed), status))
|
|
228
|
+
last = status
|
|
229
|
+
if status in OK_STATUSES or status in FAILED_STATUSES:
|
|
230
|
+
return status
|
|
231
|
+
if elapsed > timeout:
|
|
232
|
+
raise CliError(tr("build did not finish in %d s (status %s); it keeps running in Railway",
|
|
233
|
+
"сборка не завершилась за %d с (статус %s); она продолжается в Railway")
|
|
234
|
+
% (timeout, status))
|
|
235
|
+
time.sleep(interval)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def show_logs(api, deployment_id, build):
|
|
239
|
+
try:
|
|
240
|
+
lines = api.logs(deployment_id, build=build)
|
|
241
|
+
except RailwayError as e:
|
|
242
|
+
say(tr("Could not fetch logs: %s", "Не удалось получить логи: %s") % e)
|
|
243
|
+
return
|
|
244
|
+
if lines:
|
|
245
|
+
say(tr("Last lines of the %s:", "Последние строки %s:")
|
|
246
|
+
% (tr("build log", "лога сборки") if build else tr("app log", "лога приложения")))
|
|
247
|
+
for line in lines:
|
|
248
|
+
say(" " + line)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""small destroy: удалить приложение из Railway — проект целиком, если в нём только сервисы этого приложения
|
|
2
|
+
(сам сервис и его базы данных), иначе только их. Затем удаляется .small.json; small.json остаётся."""
|
|
3
|
+
|
|
4
|
+
from ..common import CliError, app_dir, ask, linked_state, require_token, say
|
|
5
|
+
from ..config import CONFIG_FILE, STATE_FILE
|
|
6
|
+
from ..i18n import tr
|
|
7
|
+
from ..railway import Railway, RailwayError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register(sub):
|
|
11
|
+
x = sub.add_parser("destroy", help=tr("delete the app from Railway", "удалить приложение из Railway"),
|
|
12
|
+
description=tr("Delete the app from Railway: the whole project if it only has this app's "
|
|
13
|
+
"services (the service itself and its databases), otherwise just those. "
|
|
14
|
+
"Then %s is deleted; %s stays.",
|
|
15
|
+
"Удалить приложение из Railway: проект целиком, если в нём только сервисы этого "
|
|
16
|
+
"приложения (сам сервис и его базы данных), иначе только их. Затем удаляется %s; "
|
|
17
|
+
"%s остаётся.") % (STATE_FILE, CONFIG_FILE))
|
|
18
|
+
x.add_argument("path", nargs="?", default=".", help=tr("app folder (default: current folder)",
|
|
19
|
+
"папка приложения (по умолчанию текущая)"))
|
|
20
|
+
x.add_argument("--yes", action="store_true", help=tr("don't ask for confirmation (for scripts)",
|
|
21
|
+
"не спрашивать подтверждение (для скриптов)"))
|
|
22
|
+
x.set_defaults(func=cmd_destroy)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cmd_destroy(args):
|
|
26
|
+
root = app_dir(args.path)
|
|
27
|
+
state = linked_state(root)
|
|
28
|
+
api = Railway(require_token())
|
|
29
|
+
try:
|
|
30
|
+
project_name, services = api.project_services(state["projectId"])
|
|
31
|
+
except RailwayError as e:
|
|
32
|
+
raise CliError(tr("project %s not found in Railway (%s). If it is already deleted, delete %s manually",
|
|
33
|
+
"проект %s не найден в Railway (%s). Если он уже удалён, удалите %s вручную")
|
|
34
|
+
% (state["projectId"], e, root / STATE_FILE)) from None
|
|
35
|
+
own_ids = {state["serviceId"]} | {db["serviceId"] for db in state.get("databases", [])}
|
|
36
|
+
own = [s for s in services if s["id"] in own_ids]
|
|
37
|
+
others = [s["name"] for s in services if s["id"] not in own_ids]
|
|
38
|
+
if others:
|
|
39
|
+
say(tr("This will delete services %s from project %s. Other services in the project stay: %s",
|
|
40
|
+
"Будут удалены сервисы %s из проекта %s. Другие сервисы проекта останутся: %s")
|
|
41
|
+
% (", ".join(s["name"] for s in own) or state.get("name", ""), project_name, ", ".join(others)))
|
|
42
|
+
else:
|
|
43
|
+
say(tr("This will delete the whole project %s: %s, domain %s, variables and all deploys.",
|
|
44
|
+
"Будет удалён проект %s целиком: %s, домен %s, переменные и все деплои.")
|
|
45
|
+
% (project_name, ", ".join(s["name"] for s in own) or tr("service", "сервис"), state.get("domain") or "—"))
|
|
46
|
+
if not args.yes:
|
|
47
|
+
answer = ask(tr("Type the project name (%s) to confirm: ", "Введите имя проекта (%s) для подтверждения: ")
|
|
48
|
+
% project_name)
|
|
49
|
+
if answer is None:
|
|
50
|
+
raise CliError(tr("confirm the deletion with --yes. Nothing was deleted",
|
|
51
|
+
"подтвердите удаление флагом --yes. Ничего не удалено"))
|
|
52
|
+
if answer != project_name:
|
|
53
|
+
raise CliError(tr("the name did not match. Nothing was deleted", "имя не совпало. Ничего не удалено"))
|
|
54
|
+
|
|
55
|
+
if others:
|
|
56
|
+
for service in own:
|
|
57
|
+
api.delete_service(service["id"])
|
|
58
|
+
else:
|
|
59
|
+
api.delete_project(state["projectId"])
|
|
60
|
+
(root / STATE_FILE).unlink()
|
|
61
|
+
say(tr("Deleted. %s removed, %s kept: the next small deploy creates a new project.",
|
|
62
|
+
"Удалено. %s удалён, %s оставлен: следующий small deploy создаст новый проект.") % (STATE_FILE, CONFIG_FILE))
|
|
63
|
+
return 0
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""small domain: свой домен вместо *.up.railway.app — Railway выдаёт DNS-записи, их нужно прописать у регистратора."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from ..common import CliError, app_dir, linked_state, require_token, say
|
|
7
|
+
from ..i18n import tr
|
|
8
|
+
from ..railway import Railway
|
|
9
|
+
|
|
10
|
+
DOMAIN_RE = re.compile(r"(?=.{1,253}\Z)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}\Z")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register(sub):
|
|
14
|
+
d = sub.add_parser("domain", help=tr("custom domains: add, list, remove", "свой домен: добавить, список, удалить"),
|
|
15
|
+
description=tr("A custom domain for the app. add registers the domain in Railway and prints "
|
|
16
|
+
"the DNS records to set at your registrar; Railway issues the HTTPS "
|
|
17
|
+
"certificate itself once the records work.",
|
|
18
|
+
"Свой домен для приложения. add регистрирует домен в Railway и печатает "
|
|
19
|
+
"DNS-записи, которые нужно прописать у регистратора; HTTPS-сертификат Railway "
|
|
20
|
+
"выпустит сам, когда записи заработают."))
|
|
21
|
+
d.add_argument("-C", "--path", default=".", help=tr("app folder (default: current folder)",
|
|
22
|
+
"папка приложения (по умолчанию текущая)"))
|
|
23
|
+
d.set_defaults(func=cmd_domain_list)
|
|
24
|
+
domain_sub = d.add_subparsers(dest="domain_command", metavar="<list|add|remove>")
|
|
25
|
+
dl = domain_sub.add_parser("list", help=tr("app domains and their status", "домены приложения и их состояние"))
|
|
26
|
+
dl.set_defaults(func=cmd_domain_list)
|
|
27
|
+
da = domain_sub.add_parser("add", help=tr("add a domain: small domain add app.example.com",
|
|
28
|
+
"добавить домен: small domain add app.example.com"))
|
|
29
|
+
da.add_argument("domain")
|
|
30
|
+
da.add_argument("--port", type=int, help=tr("app port, if the app doesn't listen on $PORT",
|
|
31
|
+
"порт приложения, если оно не слушает $PORT"))
|
|
32
|
+
da.set_defaults(func=cmd_domain_add)
|
|
33
|
+
dr = domain_sub.add_parser("remove", help=tr("remove a domain: small domain remove app.example.com",
|
|
34
|
+
"убрать домен: small domain remove app.example.com"))
|
|
35
|
+
dr.add_argument("domain")
|
|
36
|
+
dr.set_defaults(func=cmd_domain_remove)
|
|
37
|
+
for parser in (dl, da, dr):
|
|
38
|
+
parser.add_argument("-C", "--path", default=argparse.SUPPRESS, help=tr("app folder", "папка приложения"))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def normalize(domain):
|
|
42
|
+
domain = domain.strip().lower().rstrip(".")
|
|
43
|
+
for prefix in ("https://", "http://"):
|
|
44
|
+
if domain.startswith(prefix):
|
|
45
|
+
domain = domain[len(prefix):]
|
|
46
|
+
domain = domain.split("/")[0]
|
|
47
|
+
if not DOMAIN_RE.match(domain):
|
|
48
|
+
raise CliError(tr("%r does not look like a domain (example: app.example.com)",
|
|
49
|
+
"%r не похож на домен (пример: app.example.com)") % domain)
|
|
50
|
+
return domain
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def ids(state):
|
|
54
|
+
return state["projectId"], state["environmentId"], state["serviceId"]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def cmd_domain_list(args):
|
|
58
|
+
state = linked_state(app_dir(args.path))
|
|
59
|
+
domains = Railway(require_token()).custom_domains(*ids(state))
|
|
60
|
+
say(tr("Railway URL: https://%s", "Адрес Railway: https://%s") % state.get("domain", "—"))
|
|
61
|
+
if not domains:
|
|
62
|
+
say(tr("No custom domains. Add one: small domain add app.example.com",
|
|
63
|
+
"Своих доменов нет. Добавить: small domain add app.example.com"))
|
|
64
|
+
return 0
|
|
65
|
+
for item in domains:
|
|
66
|
+
print("%-40s %s" % (item["domain"], summary(item.get("status") or {})))
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def cmd_domain_add(args):
|
|
71
|
+
domain = normalize(args.domain)
|
|
72
|
+
state = linked_state(app_dir(args.path))
|
|
73
|
+
api = Railway(require_token())
|
|
74
|
+
if any(item["domain"] == domain for item in api.custom_domains(*ids(state))):
|
|
75
|
+
raise CliError(tr("%s is already added. Records: small domain list",
|
|
76
|
+
"%s уже добавлен. Записи: small domain list") % domain)
|
|
77
|
+
created = api.create_custom_domain(*ids(state), domain, args.port)
|
|
78
|
+
say(tr("Domain %s added. Set these DNS records at your registrar:",
|
|
79
|
+
"Домен %s добавлен. Пропишите у регистратора DNS-записи:") % domain)
|
|
80
|
+
print_records(created.get("status") or {})
|
|
81
|
+
say(tr("Once the records work (from a few minutes to a day), Railway issues the HTTPS certificate itself.",
|
|
82
|
+
"Когда записи заработают (от минут до суток), Railway сам выпустит HTTPS-сертификат."))
|
|
83
|
+
say(tr("Check: small domain list · the invite link will also work at https://%s/?token=…",
|
|
84
|
+
"Проверить: small domain list · ссылка-приглашение будет работать и на https://%s/?token=…") % domain)
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cmd_domain_remove(args):
|
|
89
|
+
domain = normalize(args.domain)
|
|
90
|
+
state = linked_state(app_dir(args.path))
|
|
91
|
+
api = Railway(require_token())
|
|
92
|
+
item = next((d for d in api.custom_domains(*ids(state)) if d["domain"] == domain), None)
|
|
93
|
+
if item is None:
|
|
94
|
+
raise CliError(tr("%s is not one of the app's domains. List: small domain list",
|
|
95
|
+
"%s не найден среди доменов приложения. Список: small domain list") % domain)
|
|
96
|
+
api.delete_custom_domain(item["id"])
|
|
97
|
+
say(tr("Domain %s removed from Railway. You can delete its DNS records at your registrar.",
|
|
98
|
+
"Домен %s убран из Railway. DNS-записи у регистратора можно удалить.") % domain)
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def print_records(status):
|
|
103
|
+
rows = [(r["recordType"].replace("DNS_RECORD_TYPE_", ""), r.get("fqdn") or r.get("hostlabel") or "@",
|
|
104
|
+
r["requiredValue"]) for r in status.get("dnsRecords") or []]
|
|
105
|
+
if status.get("verificationToken") and not status.get("verified"):
|
|
106
|
+
rows.append(("TXT", status.get("verificationDnsHost") or "", status["verificationToken"]))
|
|
107
|
+
if not rows:
|
|
108
|
+
say(tr(" Railway returned no records; check later: small domain list",
|
|
109
|
+
" Railway не вернул записи — посмотрите позже: small domain list"))
|
|
110
|
+
for kind, name, value in rows:
|
|
111
|
+
print(" %-6s %-40s %s" % (kind, name, value))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def summary(status):
|
|
115
|
+
records = status.get("dnsRecords") or []
|
|
116
|
+
dns_ok = bool(records) and all("PROPAGATED" in (r.get("status") or "") for r in records)
|
|
117
|
+
cert = (status.get("certificateStatus") or "").replace("CERTIFICATE_STATUS_TYPE_", "").lower()
|
|
118
|
+
parts = [tr("DNS configured", "DNS настроен") if dns_ok else tr("waiting for DNS records", "ждёт DNS-записей")]
|
|
119
|
+
if cert:
|
|
120
|
+
parts.append(tr("certificate: %s", "сертификат: %s") % cert)
|
|
121
|
+
if status.get("verificationToken") and not status.get("verified"):
|
|
122
|
+
parts.append(tr("TXT verification needed", "нужна TXT-проверка"))
|
|
123
|
+
return ", ".join(parts)
|