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/i18n.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Язык сообщений small: английский или русский.
|
|
2
|
+
|
|
3
|
+
SMALL_LANG=en|ru задаёт язык явно. Иначе — по локали: LC_ALL, LC_MESSAGES, LANG, LANGUAGE, а на Windows, где их
|
|
4
|
+
обычно нет, — по языку интерфейса системы. Всё, что не русский, — английский.
|
|
5
|
+
|
|
6
|
+
В коде: tr("Done", "Готово"). Язык выбирается при вызове, поэтому переводить надо внутри функций, а не в
|
|
7
|
+
константах модуля.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
LANGS = ("en", "ru")
|
|
14
|
+
_lang = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def detect(environ=None, platform=None):
|
|
18
|
+
environ = os.environ if environ is None else environ
|
|
19
|
+
platform = sys.platform if platform is None else platform
|
|
20
|
+
explicit = environ.get("SMALL_LANG", "").strip().lower()[:2]
|
|
21
|
+
if explicit in LANGS:
|
|
22
|
+
return explicit
|
|
23
|
+
for name in ("LC_ALL", "LC_MESSAGES", "LANG", "LANGUAGE"):
|
|
24
|
+
value = environ.get(name, "").strip()
|
|
25
|
+
if value and value != "POSIX" and not value.startswith("C"): # C, C.UTF-8 — локаль не выбрана
|
|
26
|
+
return "ru" if value.lower().startswith("ru") else "en"
|
|
27
|
+
if platform == "win32":
|
|
28
|
+
return "ru" if _windows_ui_language() & 0x3FF == 0x19 else "en" # 0x19 — LANG_RUSSIAN
|
|
29
|
+
return "en"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _windows_ui_language():
|
|
33
|
+
try:
|
|
34
|
+
import ctypes
|
|
35
|
+
|
|
36
|
+
return ctypes.windll.kernel32.GetUserDefaultUILanguage()
|
|
37
|
+
except Exception: # не Windows или нет kernel32 — значит, не русский
|
|
38
|
+
return 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def lang():
|
|
42
|
+
global _lang
|
|
43
|
+
if _lang is None:
|
|
44
|
+
_lang = detect()
|
|
45
|
+
return _lang
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def use(language):
|
|
49
|
+
"""Задать язык явно (тесты). None — снова определить по окружению."""
|
|
50
|
+
global _lang
|
|
51
|
+
_lang = language if language in LANGS else None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def tr(en, ru):
|
|
55
|
+
return ru if lang() == "ru" else en
|
small_cli/railway.py
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
"""Тонкий клиент Railway: GraphQL API v2 и загрузка исходников (тот же эндпоинт, что у `railway up`)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import urllib.request
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .i18n import tr
|
|
12
|
+
|
|
13
|
+
DEFAULT_API_URL = "https://backboard.railway.com"
|
|
14
|
+
TOKEN_ENV_VARS = ("RAILWAY_API_TOKEN", "RAILWAY_TOKEN")
|
|
15
|
+
CUSTOM_DOMAIN_STATUS = ("dnsRecords { recordType hostlabel fqdn requiredValue currentValue status purpose } "
|
|
16
|
+
"verified verificationDnsHost verificationToken certificateStatus")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RailwayError(Exception):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def token_from_env():
|
|
24
|
+
"""Токен Railway из окружения. Пробелы, кавычки и угловые скобки по краям отбрасываются: в токене их не бывает,
|
|
25
|
+
а из заготовки вида "<токен>" их легко скопировать вместе со значением."""
|
|
26
|
+
raw = next((os.environ[v] for v in TOKEN_ENV_VARS if os.environ.get(v)), "")
|
|
27
|
+
return raw.strip().strip("\"'<>").strip() or None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Railway:
|
|
31
|
+
def __init__(self, token, base_url=None):
|
|
32
|
+
self.token = token
|
|
33
|
+
self.base_url = (base_url or os.environ.get("SMALL_API_URL") or DEFAULT_API_URL).rstrip("/")
|
|
34
|
+
|
|
35
|
+
# --- транспорт ---
|
|
36
|
+
|
|
37
|
+
def _post(self, url, body, content_type, timeout=60):
|
|
38
|
+
req = urllib.request.Request(url, data=body, method="POST", headers={
|
|
39
|
+
"Authorization": "Bearer " + self.token,
|
|
40
|
+
"Content-Type": content_type,
|
|
41
|
+
"Accept": "application/json",
|
|
42
|
+
"User-Agent": "small-cli/" + __version__,
|
|
43
|
+
})
|
|
44
|
+
try:
|
|
45
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
46
|
+
raw = resp.read()
|
|
47
|
+
except urllib.error.HTTPError as e:
|
|
48
|
+
raise RailwayError("HTTP %d: %s" % (e.code, _error_text(e.read()))) from None
|
|
49
|
+
except OSError as e: # URLError, таймауты, разрыв соединения
|
|
50
|
+
raise RailwayError(tr("cannot reach %s: %s", "нет связи с %s: %s")
|
|
51
|
+
% (self.base_url, getattr(e, "reason", e))) from None
|
|
52
|
+
try:
|
|
53
|
+
return json.loads(raw or b"{}")
|
|
54
|
+
except ValueError:
|
|
55
|
+
raise RailwayError(tr("unexpected server response: %s", "неожиданный ответ сервера: %s")
|
|
56
|
+
% _error_text(raw)) from None
|
|
57
|
+
|
|
58
|
+
def graphql(self, query, variables=None, timeout=60):
|
|
59
|
+
body = json.dumps({"query": query, "variables": variables or {}}).encode("utf-8")
|
|
60
|
+
res = self._post(self.base_url + "/graphql/v2", body, "application/json", timeout=timeout)
|
|
61
|
+
if res.get("errors"):
|
|
62
|
+
raise RailwayError("; ".join(e.get("message", "?") for e in res["errors"]))
|
|
63
|
+
return res.get("data") or {}
|
|
64
|
+
|
|
65
|
+
# --- операции ---
|
|
66
|
+
|
|
67
|
+
def workspaces(self):
|
|
68
|
+
"""Работает только с токеном аккаунта; токен workspace получит ошибку."""
|
|
69
|
+
data = self.graphql("query { me { workspaces { id name } } }")
|
|
70
|
+
return data["me"]["workspaces"]
|
|
71
|
+
|
|
72
|
+
def create_project(self, name, workspace_id=None):
|
|
73
|
+
inp = {"name": name}
|
|
74
|
+
if workspace_id:
|
|
75
|
+
inp["workspaceId"] = workspace_id
|
|
76
|
+
data = self.graphql(
|
|
77
|
+
"""mutation($input: ProjectCreateInput!) {
|
|
78
|
+
projectCreate(input: $input) { id environments { edges { node { id name } } } }
|
|
79
|
+
}""",
|
|
80
|
+
{"input": inp},
|
|
81
|
+
)
|
|
82
|
+
project = data["projectCreate"]
|
|
83
|
+
envs = [edge["node"] for edge in project["environments"]["edges"]]
|
|
84
|
+
if not envs:
|
|
85
|
+
raise RailwayError(tr("the new project has no environments", "у созданного проекта нет окружений"))
|
|
86
|
+
env = next((e for e in envs if e["name"] == "production"), envs[0])
|
|
87
|
+
return project["id"], env["id"]
|
|
88
|
+
|
|
89
|
+
def find_projects(self, name, workspace_id=None):
|
|
90
|
+
"""Неудалённые проекты с точно таким именем, вместе с окружениями и сервисами."""
|
|
91
|
+
data = self.graphql(
|
|
92
|
+
"""query($workspaceId: String) {
|
|
93
|
+
projects(workspaceId: $workspaceId, first: 500) { edges { node {
|
|
94
|
+
id name deletedAt
|
|
95
|
+
environments { edges { node { id name } } }
|
|
96
|
+
services { edges { node { id name } } }
|
|
97
|
+
} } }
|
|
98
|
+
}""",
|
|
99
|
+
{"workspaceId": workspace_id},
|
|
100
|
+
)
|
|
101
|
+
found = []
|
|
102
|
+
for edge in data["projects"]["edges"]:
|
|
103
|
+
node = edge["node"]
|
|
104
|
+
if node["name"] == name and not node.get("deletedAt"):
|
|
105
|
+
found.append({
|
|
106
|
+
"id": node["id"],
|
|
107
|
+
"environments": [e["node"] for e in node["environments"]["edges"]],
|
|
108
|
+
"services": [s["node"] for s in node["services"]["edges"]],
|
|
109
|
+
})
|
|
110
|
+
return found
|
|
111
|
+
|
|
112
|
+
def create_service(self, project_id, environment_id, name):
|
|
113
|
+
data = self.graphql(
|
|
114
|
+
"mutation($input: ServiceCreateInput!) { serviceCreate(input: $input) { id } }",
|
|
115
|
+
{"input": {"projectId": project_id, "environmentId": environment_id, "name": name}},
|
|
116
|
+
)
|
|
117
|
+
return data["serviceCreate"]["id"]
|
|
118
|
+
|
|
119
|
+
def upsert_variables(self, project_id, environment_id, service_id, variables):
|
|
120
|
+
"""Добавляет/обновляет переменные сервиса одним запросом. Остальные переменные не трогает,
|
|
121
|
+
повторный деплой не запускает: их подхватит следующая сборка."""
|
|
122
|
+
self.graphql(
|
|
123
|
+
"mutation($input: VariableCollectionUpsertInput!) { variableCollectionUpsert(input: $input) }",
|
|
124
|
+
{"input": {"projectId": project_id, "environmentId": environment_id, "serviceId": service_id,
|
|
125
|
+
"variables": variables, "replace": False, "skipDeploys": True}},
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def upload(self, project_id, environment_id, service_id, tarball, message=None):
|
|
129
|
+
"""Загружает .tar.gz с кодом и запускает сборку. Возвращает {deploymentId, url, logsUrl, ...}."""
|
|
130
|
+
params = {"serviceId": service_id}
|
|
131
|
+
if message:
|
|
132
|
+
params["message"] = message
|
|
133
|
+
url = "%s/project/%s/environment/%s/up?%s" % (
|
|
134
|
+
self.base_url, project_id, environment_id, urllib.parse.urlencode(params))
|
|
135
|
+
res = self._post(url, tarball, "application/gzip", timeout=600)
|
|
136
|
+
if not res.get("deploymentId"):
|
|
137
|
+
raise RailwayError(tr("Railway did not return a deploymentId: %s",
|
|
138
|
+
"Railway не вернул deploymentId: %s") % res)
|
|
139
|
+
return res
|
|
140
|
+
|
|
141
|
+
def create_domain(self, service_id, environment_id, port=None):
|
|
142
|
+
inp = {"serviceId": service_id, "environmentId": environment_id}
|
|
143
|
+
if port:
|
|
144
|
+
inp["targetPort"] = port
|
|
145
|
+
data = self.graphql(
|
|
146
|
+
"mutation($input: ServiceDomainCreateInput!) { serviceDomainCreate(input: $input) { id domain } }",
|
|
147
|
+
{"input": inp},
|
|
148
|
+
)
|
|
149
|
+
domain = data["serviceDomainCreate"]
|
|
150
|
+
return domain["id"], domain["domain"]
|
|
151
|
+
|
|
152
|
+
def domain_available(self, domain):
|
|
153
|
+
data = self.graphql(
|
|
154
|
+
"query($domain: String!) { serviceDomainAvailable(domain: $domain) { available } }",
|
|
155
|
+
{"domain": domain},
|
|
156
|
+
)
|
|
157
|
+
return bool(data["serviceDomainAvailable"]["available"])
|
|
158
|
+
|
|
159
|
+
def rename_domain(self, domain_id, service_id, environment_id, domain, port=None):
|
|
160
|
+
inp = {"serviceDomainId": domain_id, "serviceId": service_id,
|
|
161
|
+
"environmentId": environment_id, "domain": domain}
|
|
162
|
+
if port:
|
|
163
|
+
inp["targetPort"] = port
|
|
164
|
+
self.graphql("mutation($input: ServiceDomainUpdateInput!) { serviceDomainUpdate(input: $input) }",
|
|
165
|
+
{"input": inp})
|
|
166
|
+
|
|
167
|
+
def service_domains(self, project_id, environment_id, service_id):
|
|
168
|
+
data = self.graphql(
|
|
169
|
+
"""query($p: String!, $e: String!, $s: String!) {
|
|
170
|
+
domains(projectId: $p, environmentId: $e, serviceId: $s) { serviceDomains { id domain } }
|
|
171
|
+
}""",
|
|
172
|
+
{"p": project_id, "e": environment_id, "s": service_id},
|
|
173
|
+
)
|
|
174
|
+
return data["domains"]["serviceDomains"]
|
|
175
|
+
|
|
176
|
+
def service_domain(self, project_id, environment_id, service_id, domain_id):
|
|
177
|
+
for d in self.service_domains(project_id, environment_id, service_id):
|
|
178
|
+
if d["id"] == domain_id:
|
|
179
|
+
return d["domain"]
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
def latest_deployment(self, service_id, environment_id, timeout=60):
|
|
183
|
+
"""{"id", "status", "createdAt"} последнего деплоя сервиса или None, если деплоев не было."""
|
|
184
|
+
data = self.graphql(
|
|
185
|
+
"""query($serviceId: String!, $environmentId: String!) {
|
|
186
|
+
serviceInstance(serviceId: $serviceId, environmentId: $environmentId) {
|
|
187
|
+
latestDeployment { id status createdAt }
|
|
188
|
+
}
|
|
189
|
+
}""",
|
|
190
|
+
{"serviceId": service_id, "environmentId": environment_id},
|
|
191
|
+
timeout=timeout,
|
|
192
|
+
)
|
|
193
|
+
return data["serviceInstance"]["latestDeployment"]
|
|
194
|
+
|
|
195
|
+
def instance_status(self, service_id, environment_id, timeout=60):
|
|
196
|
+
"""{"sleep": включён ли режим сна, "deployment": последний деплой или None}."""
|
|
197
|
+
data = self.graphql(
|
|
198
|
+
"""query($serviceId: String!, $environmentId: String!) {
|
|
199
|
+
serviceInstance(serviceId: $serviceId, environmentId: $environmentId) {
|
|
200
|
+
sleepApplication
|
|
201
|
+
latestDeployment { id status createdAt }
|
|
202
|
+
}
|
|
203
|
+
}""",
|
|
204
|
+
{"serviceId": service_id, "environmentId": environment_id},
|
|
205
|
+
timeout=timeout,
|
|
206
|
+
)
|
|
207
|
+
instance = data["serviceInstance"]
|
|
208
|
+
return {"sleep": bool(instance.get("sleepApplication")), "deployment": instance["latestDeployment"]}
|
|
209
|
+
|
|
210
|
+
def update_instance(self, service_id, environment_id, **settings):
|
|
211
|
+
"""Настройки сервиса в окружении (sleepApplication, startCommand...). Вступают в силу со следующим деплоем."""
|
|
212
|
+
self.graphql(
|
|
213
|
+
"mutation($s: String!, $e: String, $input: ServiceInstanceUpdateInput!) "
|
|
214
|
+
"{ serviceInstanceUpdate(serviceId: $s, environmentId: $e, input: $input) }",
|
|
215
|
+
{"s": service_id, "e": environment_id, "input": settings},
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
def average_load(self, project_id, start, end, step_seconds=3600, timeout=60):
|
|
219
|
+
"""Средняя нагрузка проекта (все его сервисы, включая базы) за период: {"CPU_USAGE": ядра,
|
|
220
|
+
"MEMORY_USAGE_GB": ГБ, "DISK_USAGE_GB": ГБ}.
|
|
221
|
+
|
|
222
|
+
Берём metrics, а не usage: у metrics известные единицы (средние ядра и ГБ за интервал), а usage
|
|
223
|
+
накапливается с задержкой. Для каждого сервиса среднее считается с его первого замера до конца периода,
|
|
224
|
+
пропуски (сервис спал) — как ноль; затем средние сервисов складываются.
|
|
225
|
+
"""
|
|
226
|
+
data = self.graphql(
|
|
227
|
+
"""query($p: String!, $start: DateTime!, $end: DateTime!, $step: Int!) {
|
|
228
|
+
metrics(projectId: $p, startDate: $start, endDate: $end, sampleRateSeconds: $step,
|
|
229
|
+
averagingWindowSeconds: $step, groupBy: [SERVICE_ID],
|
|
230
|
+
measurements: [CPU_USAGE, MEMORY_USAGE_GB, DISK_USAGE_GB]) {
|
|
231
|
+
measurement values { ts value }
|
|
232
|
+
}
|
|
233
|
+
}""",
|
|
234
|
+
{"p": project_id, "start": start, "end": end, "step": step_seconds},
|
|
235
|
+
timeout=timeout,
|
|
236
|
+
)
|
|
237
|
+
end_ts = int(_parse_time(end))
|
|
238
|
+
load = {}
|
|
239
|
+
for series in data["metrics"]:
|
|
240
|
+
values = series["values"]
|
|
241
|
+
if not values:
|
|
242
|
+
continue
|
|
243
|
+
expected = max(1, (end_ts - min(v["ts"] for v in values)) // step_seconds + 1)
|
|
244
|
+
average = sum(float(v["value"]) for v in values) / max(expected, len(values))
|
|
245
|
+
load[series["measurement"]] = load.get(series["measurement"], 0.0) + average
|
|
246
|
+
return load
|
|
247
|
+
|
|
248
|
+
def template(self, code):
|
|
249
|
+
data = self.graphql("query($code: String!) { template(code: $code) { id name serializedConfig } }",
|
|
250
|
+
{"code": code})
|
|
251
|
+
return data["template"]
|
|
252
|
+
|
|
253
|
+
def deploy_template(self, project_id, environment_id, template_id, config):
|
|
254
|
+
data = self.graphql(
|
|
255
|
+
"""mutation($input: TemplateDeployV2Input!) { templateDeployV2(input: $input) { projectId workflowId } }""",
|
|
256
|
+
{"input": {"projectId": project_id, "environmentId": environment_id, "templateId": template_id,
|
|
257
|
+
"serializedConfig": config}},
|
|
258
|
+
)
|
|
259
|
+
return data["templateDeployV2"].get("workflowId")
|
|
260
|
+
|
|
261
|
+
def workflow_status(self, workflow_id):
|
|
262
|
+
data = self.graphql("query($id: String!) { workflowStatus(workflowId: $id) { status error } }",
|
|
263
|
+
{"id": workflow_id})
|
|
264
|
+
return data["workflowStatus"]
|
|
265
|
+
|
|
266
|
+
def custom_domains(self, project_id, environment_id, service_id):
|
|
267
|
+
data = self.graphql(
|
|
268
|
+
"""query($p: String!, $e: String!, $s: String!) {
|
|
269
|
+
domains(projectId: $p, environmentId: $e, serviceId: $s) {
|
|
270
|
+
customDomains { id domain targetPort status { %s } }
|
|
271
|
+
}
|
|
272
|
+
}""" % CUSTOM_DOMAIN_STATUS,
|
|
273
|
+
{"p": project_id, "e": environment_id, "s": service_id},
|
|
274
|
+
)
|
|
275
|
+
return data["domains"]["customDomains"]
|
|
276
|
+
|
|
277
|
+
def create_custom_domain(self, project_id, environment_id, service_id, domain, port=None):
|
|
278
|
+
inp = {"projectId": project_id, "environmentId": environment_id, "serviceId": service_id, "domain": domain}
|
|
279
|
+
if port:
|
|
280
|
+
inp["targetPort"] = port
|
|
281
|
+
data = self.graphql(
|
|
282
|
+
"mutation($input: CustomDomainCreateInput!) "
|
|
283
|
+
"{ customDomainCreate(input: $input) { id domain targetPort status { %s } } }" % CUSTOM_DOMAIN_STATUS,
|
|
284
|
+
{"input": inp},
|
|
285
|
+
)
|
|
286
|
+
return data["customDomainCreate"]
|
|
287
|
+
|
|
288
|
+
def delete_custom_domain(self, domain_id):
|
|
289
|
+
self.graphql("mutation($id: String!) { customDomainDelete(id: $id) }", {"id": domain_id})
|
|
290
|
+
|
|
291
|
+
def deployment_status(self, deployment_id):
|
|
292
|
+
data = self.graphql("query($id: String!) { deployment(id: $id) { status } }", {"id": deployment_id})
|
|
293
|
+
return data["deployment"]["status"]
|
|
294
|
+
|
|
295
|
+
def log_entries(self, deployment_id, build=False, limit=100, start_date=None):
|
|
296
|
+
"""Строки лога: [{"timestamp", "message", "severity"}]. start_date (ISO) — только строки начиная с него."""
|
|
297
|
+
field = "buildLogs" if build else "deploymentLogs"
|
|
298
|
+
data = self.graphql(
|
|
299
|
+
"query($id: String!, $limit: Int, $startDate: DateTime) "
|
|
300
|
+
"{ %s(deploymentId: $id, limit: $limit, startDate: $startDate) { timestamp message severity } }" % field,
|
|
301
|
+
{"id": deployment_id, "limit": limit, "startDate": start_date},
|
|
302
|
+
)
|
|
303
|
+
return data[field]
|
|
304
|
+
|
|
305
|
+
def logs(self, deployment_id, build=True, limit=40):
|
|
306
|
+
return [entry["message"] for entry in self.log_entries(deployment_id, build=build, limit=limit)]
|
|
307
|
+
|
|
308
|
+
def service_variables(self, project_id, environment_id, service_id, unrendered=False):
|
|
309
|
+
"""Переменные сервиса. unrendered=True — как записаны, без подстановки ссылок: `${{Postgres.DATABASE_URL}}`,
|
|
310
|
+
а не готовый адрес с паролем."""
|
|
311
|
+
ids = {"projectId": project_id, "environmentId": environment_id, "serviceId": service_id}
|
|
312
|
+
if not unrendered:
|
|
313
|
+
data = self.graphql(
|
|
314
|
+
"query($projectId: String!, $environmentId: String!, $serviceId: String) "
|
|
315
|
+
"{ variables(projectId: $projectId, environmentId: $environmentId, serviceId: $serviceId) }", ids)
|
|
316
|
+
else:
|
|
317
|
+
data = self.graphql(
|
|
318
|
+
"query($projectId: String!, $environmentId: String!, $serviceId: String, $unrendered: Boolean) "
|
|
319
|
+
"{ variables(projectId: $projectId, environmentId: $environmentId, serviceId: $serviceId, "
|
|
320
|
+
"unrendered: $unrendered) }", dict(ids, unrendered=True))
|
|
321
|
+
return data["variables"]
|
|
322
|
+
|
|
323
|
+
def project_services(self, project_id):
|
|
324
|
+
"""(имя проекта, [{"id", "name"}] его сервисов)."""
|
|
325
|
+
data = self.graphql(
|
|
326
|
+
"query($id: String!) { project(id: $id) { name services { edges { node { id name } } } } }",
|
|
327
|
+
{"id": project_id},
|
|
328
|
+
)
|
|
329
|
+
project = data["project"]
|
|
330
|
+
return project["name"], [edge["node"] for edge in project["services"]["edges"]]
|
|
331
|
+
|
|
332
|
+
def delete_variable(self, project_id, environment_id, service_id, name):
|
|
333
|
+
self.graphql(
|
|
334
|
+
"mutation($input: VariableDeleteInput!) { variableDelete(input: $input) }",
|
|
335
|
+
{"input": {"projectId": project_id, "environmentId": environment_id, "serviceId": service_id,
|
|
336
|
+
"name": name}},
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
def redeploy(self, service_id, environment_id):
|
|
340
|
+
"""Перезапустить сервис с текущими переменными (последний образ пересобирать не нужно)."""
|
|
341
|
+
self.graphql(
|
|
342
|
+
"mutation($s: String!, $e: String!) { serviceInstanceRedeploy(serviceId: $s, environmentId: $e) }",
|
|
343
|
+
{"s": service_id, "e": environment_id},
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
def delete_project(self, project_id):
|
|
347
|
+
self.graphql("mutation($id: String!) { projectDelete(id: $id) }", {"id": project_id})
|
|
348
|
+
|
|
349
|
+
def delete_service(self, service_id):
|
|
350
|
+
self.graphql("mutation($id: String!) { serviceDelete(id: $id) }", {"id": service_id})
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _parse_time(iso):
|
|
354
|
+
"""'2026-10-10T12:00:00Z' -> unix-время."""
|
|
355
|
+
return datetime.strptime(iso[:19], "%Y-%m-%dT%H:%M:%S").replace(tzinfo=timezone.utc).timestamp()
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _error_text(raw):
|
|
359
|
+
text = raw.decode("utf-8", "replace").strip() if raw else ""
|
|
360
|
+
try:
|
|
361
|
+
payload = json.loads(text)
|
|
362
|
+
except ValueError:
|
|
363
|
+
return text[:300] or tr("(empty response)", "(пустой ответ)")
|
|
364
|
+
if isinstance(payload, dict):
|
|
365
|
+
if payload.get("message"):
|
|
366
|
+
return payload["message"]
|
|
367
|
+
if payload.get("errors"):
|
|
368
|
+
return "; ".join(e.get("message", "?") for e in payload["errors"])
|
|
369
|
+
return text[:300]
|
small_cli/scaffold.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Шаблоны для `small init`: файлы приложения и правила .gitignore / .railwayignore."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .i18n import tr
|
|
6
|
+
|
|
7
|
+
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
|
8
|
+
DEFAULT_TEMPLATE = "python-http"
|
|
9
|
+
|
|
10
|
+
# description — (английский, русский): язык выбирается при вызове, см. description().
|
|
11
|
+
TEMPLATES = {
|
|
12
|
+
"python-http": {
|
|
13
|
+
"description": ("Python 3, standard library (wsgiref) + magic_link.py",
|
|
14
|
+
"Python 3, стандартная библиотека (wsgiref) + magic_link.py"),
|
|
15
|
+
"gitignore": ["__pycache__/", "*.pyc", ".venv/", "venv/"],
|
|
16
|
+
},
|
|
17
|
+
"node-http": {
|
|
18
|
+
"description": ("Node.js 18+, http module + magic-link.js",
|
|
19
|
+
"Node.js 18+, модуль http + magic-link.js"),
|
|
20
|
+
"gitignore": ["node_modules/", "npm-debug.log*"],
|
|
21
|
+
},
|
|
22
|
+
"fastapi": {
|
|
23
|
+
"description": ("FastAPI + uvicorn (ASGI), protected by MagicLinkASGI from magic_link.py",
|
|
24
|
+
"FastAPI + uvicorn (ASGI), защита MagicLinkASGI из magic_link.py"),
|
|
25
|
+
"gitignore": ["__pycache__/", "*.pyc", ".venv/", "venv/"],
|
|
26
|
+
},
|
|
27
|
+
"streamlit": {
|
|
28
|
+
"description": ("Streamlit: interactive Python UI, invite-link sign-in built into app.py",
|
|
29
|
+
"Streamlit: интерактивный интерфейс на Python, вход по ссылке встроен в app.py"),
|
|
30
|
+
"gitignore": ["__pycache__/", "*.pyc", ".venv/", "venv/", ".streamlit/secrets.toml"],
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# small.json хранит ACCESS_TOKEN и другие секреты, .small.json — ID проекта на этой машине.
|
|
35
|
+
GITIGNORE_COMMON = ["small.json", ".small.json", ".env"]
|
|
36
|
+
# Нужно в git, но не нужно в Railway.
|
|
37
|
+
RAILWAYIGNORE = ["*.md", "tests/", ".vscode/", ".idea/"]
|
|
38
|
+
|
|
39
|
+
IGNORE_HEADERS = { # (английский, русский)
|
|
40
|
+
".gitignore": ("# Do not commit", "# Не коммитить"),
|
|
41
|
+
".railwayignore": ("# Do not upload to Railway (in addition to .gitignore)",
|
|
42
|
+
"# Не загружать в Railway (в дополнение к .gitignore)"),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def description(template):
|
|
47
|
+
"""Описание шаблона на языке пользователя (для `small init --help`)."""
|
|
48
|
+
return tr(*TEMPLATES[template]["description"])
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def template_files(template):
|
|
52
|
+
"""{имя файла: содержимое} файлов приложения из шаблона."""
|
|
53
|
+
folder = TEMPLATES_DIR / template
|
|
54
|
+
return {p.name: p.read_bytes() for p in sorted(folder.iterdir()) if p.is_file()}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def ignore_rules(template):
|
|
58
|
+
return {
|
|
59
|
+
".gitignore": GITIGNORE_COMMON + TEMPLATES[template]["gitignore"],
|
|
60
|
+
".railwayignore": RAILWAYIGNORE,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def merge_ignore(path, lines):
|
|
65
|
+
"""Создаёт ignore-файл или дописывает в него недостающие строки, ничего не удаляя.
|
|
66
|
+
|
|
67
|
+
Возвращает "created", "updated" или "unchanged".
|
|
68
|
+
"""
|
|
69
|
+
exists = path.is_file()
|
|
70
|
+
text = path.read_text(encoding="utf-8-sig") if exists else ""
|
|
71
|
+
present = {line.strip() for line in text.splitlines()}
|
|
72
|
+
missing = [line for line in lines if line not in present]
|
|
73
|
+
if not missing:
|
|
74
|
+
return "unchanged"
|
|
75
|
+
if text and not text.endswith("\n"):
|
|
76
|
+
text += "\n"
|
|
77
|
+
header = tr("\n# Added by small init\n", "\n# Добавлено small init\n") if exists \
|
|
78
|
+
else tr(*IGNORE_HEADERS[path.name]) + "\n"
|
|
79
|
+
text += header + "\n".join(missing) + "\n"
|
|
80
|
+
path.write_bytes(text.encode("utf-8"))
|
|
81
|
+
return "updated" if exists else "created"
|