keystonectl 0.1.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.
- keystonectl/__init__.py +3 -0
- keystonectl/__main__.py +8 -0
- keystonectl/answers.py +335 -0
- keystonectl/bundles.py +556 -0
- keystonectl/catalog.py +345 -0
- keystonectl/check.py +527 -0
- keystonectl/cli.py +779 -0
- keystonectl/compat.py +205 -0
- keystonectl/compose_gen.py +32 -0
- keystonectl/envfiles.py +136 -0
- keystonectl/errors.py +951 -0
- keystonectl/fetch.py +187 -0
- keystonectl/generate/__init__.py +1 -0
- keystonectl/generate/checks.py +219 -0
- keystonectl/generate/compose.py +287 -0
- keystonectl/generate/docker.py +221 -0
- keystonectl/generate/files.py +33 -0
- keystonectl/generate/install.py +1791 -0
- keystonectl/generate/namespace.py +54 -0
- keystonectl/generate/packages_file.py +97 -0
- keystonectl/generate/placement.py +203 -0
- keystonectl/generate/registry.py +126 -0
- keystonectl/generate/status.py +273 -0
- keystonectl/generate/tokens.py +9 -0
- keystonectl/generate/validators.py +70 -0
- keystonectl/keystone_mcp_rules.py +118 -0
- keystonectl/layout.py +1770 -0
- keystonectl/lifecycle.py +1233 -0
- keystonectl/lint.py +227 -0
- keystonectl/lock.py +257 -0
- keystonectl/manifest.py +871 -0
- keystonectl/nginx/.gitkeep +0 -0
- keystonectl/nginx/__init__.py +1 -0
- keystonectl/nginx/service-https.conf.template +48 -0
- keystonectl/nginx/service.conf.template +28 -0
- keystonectl/output.py +123 -0
- keystonectl/plan.py +821 -0
- keystonectl/public.py +1328 -0
- keystonectl/py.typed +0 -0
- keystonectl/remote.py +985 -0
- keystonectl/resources/__init__.py +22 -0
- keystonectl/resources/binding.py +1747 -0
- keystonectl/resources/deployer.py +22 -0
- keystonectl/resources/interfaces.py +75 -0
- keystonectl/resources/network.py +54 -0
- keystonectl/resources/postgres_probe.py +831 -0
- keystonectl/resources/provision.py +287 -0
- keystonectl/resources/s3_probe.py +775 -0
- keystonectl/runner.py +109 -0
- keystonectl/schemas/catalog-v1.json +234 -0
- keystonectl/schemas/lock-v1.json +385 -0
- keystonectl/schemas/output.schema.json +92 -0
- keystonectl/schemas/service-package-v1.json +670 -0
- keystonectl/schemas/sources-v1.json +76 -0
- keystonectl/sources.py +777 -0
- keystonectl/tls.py +470 -0
- keystonectl-0.1.0.dist-info/METADATA +167 -0
- keystonectl-0.1.0.dist-info/RECORD +61 -0
- keystonectl-0.1.0.dist-info/WHEEL +4 -0
- keystonectl-0.1.0.dist-info/entry_points.txt +2 -0
- keystonectl-0.1.0.dist-info/licenses/LICENSE +202 -0
keystonectl/__init__.py
ADDED
keystonectl/__main__.py
ADDED
keystonectl/answers.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""Questions, answer lookup, and KEYSTONE_SETUP_* names."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from keystonectl.errors import ERRORS
|
|
12
|
+
from keystonectl.output import SecretRegistry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Question:
|
|
17
|
+
var: str
|
|
18
|
+
text: str
|
|
19
|
+
kind: str
|
|
20
|
+
choices: tuple[str, ...] | None = None
|
|
21
|
+
default: str | None = None
|
|
22
|
+
secret: bool = False
|
|
23
|
+
help: str | None = None
|
|
24
|
+
|
|
25
|
+
def as_missing(self) -> dict:
|
|
26
|
+
return {
|
|
27
|
+
"var": self.var,
|
|
28
|
+
"question": self.text,
|
|
29
|
+
"kind": self.kind,
|
|
30
|
+
"choices": list(self.choices) if self.choices is not None else None,
|
|
31
|
+
"default": self.default,
|
|
32
|
+
"secret": self.secret,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
QUESTION_TEMPLATES: dict[str, Question] = {
|
|
37
|
+
"KEYSTONE_SETUP_KEYSTONE_MCP_VERSION": Question(
|
|
38
|
+
"KEYSTONE_SETUP_KEYSTONE_MCP_VERSION",
|
|
39
|
+
"Which keystone-mcp version runs here (for example 0.5.0)?",
|
|
40
|
+
"text",
|
|
41
|
+
),
|
|
42
|
+
"KEYSTONE_SETUP_SERVICE_{PKG}_ENV_{NAME}": Question(
|
|
43
|
+
"KEYSTONE_SETUP_SERVICE_{PKG}_ENV_{NAME}",
|
|
44
|
+
"Value of {NAME} for package {PKG}.",
|
|
45
|
+
"text",
|
|
46
|
+
),
|
|
47
|
+
"KEYSTONE_SETUP_SERVICE_{PKG}_SECRET_{NAME}_FILE": Question(
|
|
48
|
+
"KEYSTONE_SETUP_SERVICE_{PKG}_SECRET_{NAME}_FILE",
|
|
49
|
+
"Path to a mode 600 file holding secret {NAME} for package {PKG}.",
|
|
50
|
+
"file",
|
|
51
|
+
secret=True,
|
|
52
|
+
),
|
|
53
|
+
"KEYSTONE_SETUP_SERVICE_{PKG}_NAMESPACE": Question(
|
|
54
|
+
"KEYSTONE_SETUP_SERVICE_{PKG}_NAMESPACE",
|
|
55
|
+
"Choose another prefix for {PKG}'s tools (letters, digits, '-' or '_').",
|
|
56
|
+
"text",
|
|
57
|
+
),
|
|
58
|
+
"KEYSTONE_SETUP_RESOURCE_S3": Question(
|
|
59
|
+
"KEYSTONE_SETUP_RESOURCE_S3",
|
|
60
|
+
"Where should the package keep files: in S3 storage you already have (external), "
|
|
61
|
+
"or in a new MinIO on this server (catalog)?",
|
|
62
|
+
"choice",
|
|
63
|
+
choices=("external", "catalog"),
|
|
64
|
+
help="Answer external or catalog.",
|
|
65
|
+
),
|
|
66
|
+
"KEYSTONE_SETUP_RESOURCE_POSTGRES": Question(
|
|
67
|
+
"KEYSTONE_SETUP_RESOURCE_POSTGRES",
|
|
68
|
+
"Which PostgreSQL should the package use: a server you already have (external), "
|
|
69
|
+
"or a new PostgreSQL on this server (catalog)?",
|
|
70
|
+
"choice",
|
|
71
|
+
choices=("external", "catalog"),
|
|
72
|
+
help="Answer external or catalog.",
|
|
73
|
+
),
|
|
74
|
+
"KEYSTONE_SETUP_RESOURCE_S3_PACKAGE": Question(
|
|
75
|
+
"KEYSTONE_SETUP_RESOURCE_S3_PACKAGE",
|
|
76
|
+
"Which S3 provider from the catalogs should be installed?",
|
|
77
|
+
"choice",
|
|
78
|
+
help="Answer source/name, for example keystone-codes/minio.",
|
|
79
|
+
),
|
|
80
|
+
"KEYSTONE_SETUP_RESOURCE_POSTGRES_PACKAGE": Question(
|
|
81
|
+
"KEYSTONE_SETUP_RESOURCE_POSTGRES_PACKAGE",
|
|
82
|
+
"Which PostgreSQL provider from the catalogs should be installed?",
|
|
83
|
+
"choice",
|
|
84
|
+
help="Answer source/name, for example keystone-codes/postgres.",
|
|
85
|
+
),
|
|
86
|
+
"KEYSTONE_SETUP_S3_ENDPOINT": Question(
|
|
87
|
+
"KEYSTONE_SETUP_S3_ENDPOINT",
|
|
88
|
+
"S3 endpoint URL (for example https://s3.amazonaws.com).",
|
|
89
|
+
"text",
|
|
90
|
+
help="Example: https://s3.amazonaws.com",
|
|
91
|
+
),
|
|
92
|
+
"KEYSTONE_SETUP_S3_REGION": Question(
|
|
93
|
+
"KEYSTONE_SETUP_S3_REGION",
|
|
94
|
+
"S3 region (for example us-east-1).",
|
|
95
|
+
"text",
|
|
96
|
+
default="us-east-1",
|
|
97
|
+
help="Example: us-east-1",
|
|
98
|
+
),
|
|
99
|
+
"KEYSTONE_SETUP_S3_PATH_STYLE": Question(
|
|
100
|
+
"KEYSTONE_SETUP_S3_PATH_STYLE",
|
|
101
|
+
"Use path-style S3 URLs? Answer true or false.",
|
|
102
|
+
"text",
|
|
103
|
+
default="true",
|
|
104
|
+
help="Example: true",
|
|
105
|
+
),
|
|
106
|
+
"KEYSTONE_SETUP_S3_BUCKET": Question(
|
|
107
|
+
"KEYSTONE_SETUP_S3_BUCKET",
|
|
108
|
+
"S3 bucket name for this package (for example docs-mcp).",
|
|
109
|
+
"text",
|
|
110
|
+
help="Example: docs-mcp",
|
|
111
|
+
),
|
|
112
|
+
"KEYSTONE_SETUP_S3_ACCESS_KEY_FILE": Question(
|
|
113
|
+
"KEYSTONE_SETUP_S3_ACCESS_KEY_FILE",
|
|
114
|
+
"Path to a mode 600 file with the S3 access key.",
|
|
115
|
+
"file",
|
|
116
|
+
secret=True,
|
|
117
|
+
help="Example: /home/operator/s3-access-key",
|
|
118
|
+
),
|
|
119
|
+
"KEYSTONE_SETUP_S3_SECRET_KEY_FILE": Question(
|
|
120
|
+
"KEYSTONE_SETUP_S3_SECRET_KEY_FILE",
|
|
121
|
+
"Path to a mode 600 file with the S3 secret key.",
|
|
122
|
+
"file",
|
|
123
|
+
secret=True,
|
|
124
|
+
help="Example: /home/operator/s3-secret-key",
|
|
125
|
+
),
|
|
126
|
+
"KEYSTONE_SETUP_POSTGRES_HOST": Question(
|
|
127
|
+
"KEYSTONE_SETUP_POSTGRES_HOST",
|
|
128
|
+
"PostgreSQL host name (for example db.example.com).",
|
|
129
|
+
"text",
|
|
130
|
+
help="Example: db.example.com",
|
|
131
|
+
),
|
|
132
|
+
"KEYSTONE_SETUP_POSTGRES_PORT": Question(
|
|
133
|
+
"KEYSTONE_SETUP_POSTGRES_PORT",
|
|
134
|
+
"PostgreSQL port.",
|
|
135
|
+
"text",
|
|
136
|
+
default="5432",
|
|
137
|
+
help="Example: 5432",
|
|
138
|
+
),
|
|
139
|
+
"KEYSTONE_SETUP_POSTGRES_SSLMODE": Question(
|
|
140
|
+
"KEYSTONE_SETUP_POSTGRES_SSLMODE",
|
|
141
|
+
"PostgreSQL sslmode.",
|
|
142
|
+
"choice",
|
|
143
|
+
choices=("disable", "prefer", "require", "verify-ca", "verify-full"),
|
|
144
|
+
default="require",
|
|
145
|
+
help="Example: require",
|
|
146
|
+
),
|
|
147
|
+
"KEYSTONE_SETUP_POSTGRES_DATABASE": Question(
|
|
148
|
+
"KEYSTONE_SETUP_POSTGRES_DATABASE",
|
|
149
|
+
"PostgreSQL database name for this package.",
|
|
150
|
+
"text",
|
|
151
|
+
help="Example: docs_mcp",
|
|
152
|
+
),
|
|
153
|
+
"KEYSTONE_SETUP_POSTGRES_USER": Question(
|
|
154
|
+
"KEYSTONE_SETUP_POSTGRES_USER",
|
|
155
|
+
"PostgreSQL user name for this package.",
|
|
156
|
+
"text",
|
|
157
|
+
help="Example: docs_mcp",
|
|
158
|
+
),
|
|
159
|
+
"KEYSTONE_SETUP_POSTGRES_PASSWORD_FILE": Question(
|
|
160
|
+
"KEYSTONE_SETUP_POSTGRES_PASSWORD_FILE",
|
|
161
|
+
"Path to a mode 600 file with the PostgreSQL password.",
|
|
162
|
+
"file",
|
|
163
|
+
secret=True,
|
|
164
|
+
help="Example: /home/operator/postgres-password",
|
|
165
|
+
),
|
|
166
|
+
"KEYSTONE_SETUP_POSTGRES_URL_FILE": Question(
|
|
167
|
+
"KEYSTONE_SETUP_POSTGRES_URL_FILE",
|
|
168
|
+
"Path to a mode 600 file with a postgresql:// URL that includes sslmode. "
|
|
169
|
+
"This replaces the other PostgreSQL questions.",
|
|
170
|
+
"file",
|
|
171
|
+
secret=True,
|
|
172
|
+
help="Example: /home/operator/postgres-url",
|
|
173
|
+
),
|
|
174
|
+
"KEYSTONE_SETUP_SERVICE_{PKG}_S3_BUCKET": Question(
|
|
175
|
+
"KEYSTONE_SETUP_SERVICE_{PKG}_S3_BUCKET",
|
|
176
|
+
"S3 bucket for package {PKG}. Overrides KEYSTONE_SETUP_S3_BUCKET.",
|
|
177
|
+
"text",
|
|
178
|
+
),
|
|
179
|
+
"KEYSTONE_SETUP_SERVICE_{PKG}_S3_ACCESS_KEY_FILE": Question(
|
|
180
|
+
"KEYSTONE_SETUP_SERVICE_{PKG}_S3_ACCESS_KEY_FILE",
|
|
181
|
+
"Path to a mode 600 file with the S3 access key for package {PKG}.",
|
|
182
|
+
"file",
|
|
183
|
+
secret=True,
|
|
184
|
+
),
|
|
185
|
+
"KEYSTONE_SETUP_SERVICE_{PKG}_S3_SECRET_KEY_FILE": Question(
|
|
186
|
+
"KEYSTONE_SETUP_SERVICE_{PKG}_S3_SECRET_KEY_FILE",
|
|
187
|
+
"Path to a mode 600 file with the S3 secret key for package {PKG}.",
|
|
188
|
+
"file",
|
|
189
|
+
secret=True,
|
|
190
|
+
),
|
|
191
|
+
"KEYSTONE_SETUP_SERVICE_{PKG}_POSTGRES_DATABASE": Question(
|
|
192
|
+
"KEYSTONE_SETUP_SERVICE_{PKG}_POSTGRES_DATABASE",
|
|
193
|
+
"PostgreSQL database for package {PKG}. Overrides KEYSTONE_SETUP_POSTGRES_DATABASE.",
|
|
194
|
+
"text",
|
|
195
|
+
),
|
|
196
|
+
"KEYSTONE_SETUP_SERVICE_{PKG}_POSTGRES_USER": Question(
|
|
197
|
+
"KEYSTONE_SETUP_SERVICE_{PKG}_POSTGRES_USER",
|
|
198
|
+
"PostgreSQL user for package {PKG}. Overrides KEYSTONE_SETUP_POSTGRES_USER.",
|
|
199
|
+
"text",
|
|
200
|
+
),
|
|
201
|
+
"KEYSTONE_SETUP_SERVICE_{PKG}_POSTGRES_PASSWORD_FILE": Question(
|
|
202
|
+
"KEYSTONE_SETUP_SERVICE_{PKG}_POSTGRES_PASSWORD_FILE",
|
|
203
|
+
"Path to a mode 600 file with the PostgreSQL password for package {PKG}.",
|
|
204
|
+
"file",
|
|
205
|
+
secret=True,
|
|
206
|
+
),
|
|
207
|
+
"KEYSTONE_SETUP_SERVICE_{PKG}_POSTGRES_URL_FILE": Question(
|
|
208
|
+
"KEYSTONE_SETUP_SERVICE_{PKG}_POSTGRES_URL_FILE",
|
|
209
|
+
"Path to a mode 600 file with a postgresql:// URL for package {PKG}.",
|
|
210
|
+
"file",
|
|
211
|
+
secret=True,
|
|
212
|
+
),
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def pkg_var(name: str, suffix: str) -> str:
|
|
217
|
+
"""KEYSTONE_SETUP_SERVICE_<PKG>_<SUFFIX>, with '-' in the package name as '_'."""
|
|
218
|
+
pkg = name.upper().replace("-", "_")
|
|
219
|
+
return f"KEYSTONE_SETUP_SERVICE_{pkg}_{suffix}"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def is_secret_var(var: str) -> bool:
|
|
223
|
+
if var.endswith("_FILE"):
|
|
224
|
+
return True
|
|
225
|
+
template = QUESTION_TEMPLATES.get(var)
|
|
226
|
+
return bool(template and template.secret)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class AnswerError(Exception):
|
|
230
|
+
def __init__(self, error_id: str, message: str, hint: str | None = None) -> None:
|
|
231
|
+
super().__init__(message)
|
|
232
|
+
self.error_id = error_id
|
|
233
|
+
self.message = message
|
|
234
|
+
spec = ERRORS[error_id]
|
|
235
|
+
self.hint = hint if hint is not None else spec.action
|
|
236
|
+
self.code = spec.code
|
|
237
|
+
self.retryable = spec.retryable
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def parse_sets(pairs: list[str]) -> dict[str, str]:
|
|
241
|
+
"""Split KEY=VALUE pairs. A secret key is refused before the value is kept."""
|
|
242
|
+
found: dict[str, str] = {}
|
|
243
|
+
for pair in pairs:
|
|
244
|
+
if "=" not in pair:
|
|
245
|
+
raise AnswerError("usage", f"--set must be KEY=VALUE, got {pair!r}.")
|
|
246
|
+
key, value = pair.split("=", 1)
|
|
247
|
+
if not key:
|
|
248
|
+
raise AnswerError("usage", "--set must be KEY=VALUE.")
|
|
249
|
+
if is_secret_var(key):
|
|
250
|
+
raise AnswerError(
|
|
251
|
+
"secret_in_argument",
|
|
252
|
+
"Refusing to accept a secret on the command line.",
|
|
253
|
+
)
|
|
254
|
+
found[key] = value
|
|
255
|
+
return found
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def read_secret_file(path: Path, secrets: SecretRegistry) -> str:
|
|
259
|
+
if path.is_symlink() or not path.is_file():
|
|
260
|
+
raise AnswerError(
|
|
261
|
+
"secret_file_unsafe",
|
|
262
|
+
"The secret file must be a regular file, not a link or a directory.",
|
|
263
|
+
"chmod 600",
|
|
264
|
+
)
|
|
265
|
+
stat = path.stat()
|
|
266
|
+
if stat.st_mode & 0o077:
|
|
267
|
+
raise AnswerError(
|
|
268
|
+
"secret_file_unsafe",
|
|
269
|
+
"The secret file is readable by group or others.",
|
|
270
|
+
"chmod 600",
|
|
271
|
+
)
|
|
272
|
+
if stat.st_uid not in {os.getuid(), 0}:
|
|
273
|
+
raise AnswerError(
|
|
274
|
+
"secret_file_unsafe",
|
|
275
|
+
"The secret file must be owned by the current user or by root.",
|
|
276
|
+
"chmod 600",
|
|
277
|
+
)
|
|
278
|
+
text = path.read_text(encoding="utf-8")
|
|
279
|
+
text = text.removesuffix("\n")
|
|
280
|
+
text = text.removesuffix("\r")
|
|
281
|
+
secrets.register(text)
|
|
282
|
+
return str(path)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _prompt(question: Question) -> str:
|
|
286
|
+
if question.help:
|
|
287
|
+
print(question.help, file=sys.stderr)
|
|
288
|
+
if question.kind == "choice" and question.choices:
|
|
289
|
+
print(question.text, file=sys.stderr)
|
|
290
|
+
for index, choice in enumerate(question.choices, start=1):
|
|
291
|
+
print(f" {index}) {choice}", file=sys.stderr)
|
|
292
|
+
raw = input("choice number: ").strip()
|
|
293
|
+
try:
|
|
294
|
+
picked = question.choices[int(raw) - 1]
|
|
295
|
+
except (ValueError, IndexError) as exc:
|
|
296
|
+
raise AnswerError("usage", f"Not one of the choices for {question.var}.") from exc
|
|
297
|
+
return picked
|
|
298
|
+
if question.secret or question.kind == "file":
|
|
299
|
+
print(question.text, file=sys.stderr)
|
|
300
|
+
return input("path to secret file: ").strip()
|
|
301
|
+
print(question.text, file=sys.stderr)
|
|
302
|
+
return input(f"{question.var}: ").strip()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def resolve_answer(
|
|
306
|
+
question: Question,
|
|
307
|
+
*,
|
|
308
|
+
sets: Mapping[str, str],
|
|
309
|
+
environ: Mapping[str, str],
|
|
310
|
+
lock_answers: Mapping[str, str],
|
|
311
|
+
interactive: bool,
|
|
312
|
+
secrets: SecretRegistry,
|
|
313
|
+
) -> str | None:
|
|
314
|
+
"""Return the answer, or None when it is still missing. Never returns a secret value."""
|
|
315
|
+
if question.var in sets:
|
|
316
|
+
if question.secret or is_secret_var(question.var):
|
|
317
|
+
raise AnswerError(
|
|
318
|
+
"secret_in_argument",
|
|
319
|
+
"Refusing to accept a secret on the command line.",
|
|
320
|
+
)
|
|
321
|
+
return sets[question.var]
|
|
322
|
+
env_value = environ.get(question.var)
|
|
323
|
+
if env_value:
|
|
324
|
+
value: str | None = env_value
|
|
325
|
+
elif question.var in lock_answers and lock_answers[question.var] != "":
|
|
326
|
+
value = str(lock_answers[question.var])
|
|
327
|
+
elif question.default is not None:
|
|
328
|
+
value = question.default
|
|
329
|
+
elif interactive:
|
|
330
|
+
value = _prompt(question)
|
|
331
|
+
else:
|
|
332
|
+
return None
|
|
333
|
+
if question.secret or question.kind == "file" or is_secret_var(question.var):
|
|
334
|
+
return read_secret_file(Path(value), secrets)
|
|
335
|
+
return value
|