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.
Files changed (61) hide show
  1. keystonectl/__init__.py +3 -0
  2. keystonectl/__main__.py +8 -0
  3. keystonectl/answers.py +335 -0
  4. keystonectl/bundles.py +556 -0
  5. keystonectl/catalog.py +345 -0
  6. keystonectl/check.py +527 -0
  7. keystonectl/cli.py +779 -0
  8. keystonectl/compat.py +205 -0
  9. keystonectl/compose_gen.py +32 -0
  10. keystonectl/envfiles.py +136 -0
  11. keystonectl/errors.py +951 -0
  12. keystonectl/fetch.py +187 -0
  13. keystonectl/generate/__init__.py +1 -0
  14. keystonectl/generate/checks.py +219 -0
  15. keystonectl/generate/compose.py +287 -0
  16. keystonectl/generate/docker.py +221 -0
  17. keystonectl/generate/files.py +33 -0
  18. keystonectl/generate/install.py +1791 -0
  19. keystonectl/generate/namespace.py +54 -0
  20. keystonectl/generate/packages_file.py +97 -0
  21. keystonectl/generate/placement.py +203 -0
  22. keystonectl/generate/registry.py +126 -0
  23. keystonectl/generate/status.py +273 -0
  24. keystonectl/generate/tokens.py +9 -0
  25. keystonectl/generate/validators.py +70 -0
  26. keystonectl/keystone_mcp_rules.py +118 -0
  27. keystonectl/layout.py +1770 -0
  28. keystonectl/lifecycle.py +1233 -0
  29. keystonectl/lint.py +227 -0
  30. keystonectl/lock.py +257 -0
  31. keystonectl/manifest.py +871 -0
  32. keystonectl/nginx/.gitkeep +0 -0
  33. keystonectl/nginx/__init__.py +1 -0
  34. keystonectl/nginx/service-https.conf.template +48 -0
  35. keystonectl/nginx/service.conf.template +28 -0
  36. keystonectl/output.py +123 -0
  37. keystonectl/plan.py +821 -0
  38. keystonectl/public.py +1328 -0
  39. keystonectl/py.typed +0 -0
  40. keystonectl/remote.py +985 -0
  41. keystonectl/resources/__init__.py +22 -0
  42. keystonectl/resources/binding.py +1747 -0
  43. keystonectl/resources/deployer.py +22 -0
  44. keystonectl/resources/interfaces.py +75 -0
  45. keystonectl/resources/network.py +54 -0
  46. keystonectl/resources/postgres_probe.py +831 -0
  47. keystonectl/resources/provision.py +287 -0
  48. keystonectl/resources/s3_probe.py +775 -0
  49. keystonectl/runner.py +109 -0
  50. keystonectl/schemas/catalog-v1.json +234 -0
  51. keystonectl/schemas/lock-v1.json +385 -0
  52. keystonectl/schemas/output.schema.json +92 -0
  53. keystonectl/schemas/service-package-v1.json +670 -0
  54. keystonectl/schemas/sources-v1.json +76 -0
  55. keystonectl/sources.py +777 -0
  56. keystonectl/tls.py +470 -0
  57. keystonectl-0.1.0.dist-info/METADATA +167 -0
  58. keystonectl-0.1.0.dist-info/RECORD +61 -0
  59. keystonectl-0.1.0.dist-info/WHEEL +4 -0
  60. keystonectl-0.1.0.dist-info/entry_points.txt +2 -0
  61. keystonectl-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,3 @@
1
+ """Install and check services behind a Keystone MCP gateway."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,8 @@
1
+ """python -m keystonectl"""
2
+
3
+ import sys
4
+
5
+ from keystonectl.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
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