ocmo-cli 0.8.19__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 (81) hide show
  1. LICENSE +201 -0
  2. NOTICE +16 -0
  3. ocmo_cli/__init__.py +3 -0
  4. ocmo_cli/__main__.py +4 -0
  5. ocmo_cli/_address.py +182 -0
  6. ocmo_cli/_audit_item.py +33 -0
  7. ocmo_cli/_auth_resolve.py +66 -0
  8. ocmo_cli/_body_payload.py +203 -0
  9. ocmo_cli/_click_groups.py +227 -0
  10. ocmo_cli/_client.py +351 -0
  11. ocmo_cli/_client_commands.py +35 -0
  12. ocmo_cli/_command_registry.py +29 -0
  13. ocmo_cli/_commands_map.py +65 -0
  14. ocmo_cli/_config.py +183 -0
  15. ocmo_cli/_config_content.py +43 -0
  16. ocmo_cli/_delete_item_output.py +129 -0
  17. ocmo_cli/_diff_input.py +144 -0
  18. ocmo_cli/_dry_run.py +292 -0
  19. ocmo_cli/_errors.py +151 -0
  20. ocmo_cli/_exit.py +13 -0
  21. ocmo_cli/_export_metadata.py +47 -0
  22. ocmo_cli/_generated_registry.py +45 -0
  23. ocmo_cli/_get_cast_output.py +52 -0
  24. ocmo_cli/_get_item_list.py +23 -0
  25. ocmo_cli/_globalpermission_output.py +98 -0
  26. ocmo_cli/_import_metadata.py +140 -0
  27. ocmo_cli/_import_plan.py +221 -0
  28. ocmo_cli/_item_output.py +223 -0
  29. ocmo_cli/_lazy_mixin.py +29 -0
  30. ocmo_cli/_ls_wide.py +286 -0
  31. ocmo_cli/_mutating.py +39 -0
  32. ocmo_cli/_notice.py +17 -0
  33. ocmo_cli/_options.py +77 -0
  34. ocmo_cli/_output.py +424 -0
  35. ocmo_cli/_output_manifest.py +332 -0
  36. ocmo_cli/_resolve_draft.py +50 -0
  37. ocmo_cli/_resolve_options.py +143 -0
  38. ocmo_cli/_resolve_output.py +228 -0
  39. ocmo_cli/_resolve_parameters_output.py +72 -0
  40. ocmo_cli/_resolve_series.py +89 -0
  41. ocmo_cli/_resolve_series_chart.py +409 -0
  42. ocmo_cli/_resolve_write.py +185 -0
  43. ocmo_cli/_resolver_output.py +34 -0
  44. ocmo_cli/_resource_aliases.py +22 -0
  45. ocmo_cli/_sdk_dispatch.py +310 -0
  46. ocmo_cli/_typing.py +27 -0
  47. ocmo_cli/_version_output.py +39 -0
  48. ocmo_cli/_whoami_output.py +43 -0
  49. ocmo_cli/commands/__init__.py +1 -0
  50. ocmo_cli/commands/api.py +125 -0
  51. ocmo_cli/commands/api_health.py +12 -0
  52. ocmo_cli/commands/apply.py +153 -0
  53. ocmo_cli/commands/auth.py +786 -0
  54. ocmo_cli/commands/can_i.py +51 -0
  55. ocmo_cli/commands/completion.py +59 -0
  56. ocmo_cli/commands/config_cmd.py +235 -0
  57. ocmo_cli/commands/describe.py +109 -0
  58. ocmo_cli/commands/diff.py +86 -0
  59. ocmo_cli/commands/edit.py +200 -0
  60. ocmo_cli/commands/export.py +242 -0
  61. ocmo_cli/commands/generated.py +907 -0
  62. ocmo_cli/commands/gp_move.py +108 -0
  63. ocmo_cli/commands/import_.py +595 -0
  64. ocmo_cli/commands/item_relocate.py +187 -0
  65. ocmo_cli/commands/ls.py +481 -0
  66. ocmo_cli/commands/resolve.py +658 -0
  67. ocmo_cli/commands/resolve_draft.py +102 -0
  68. ocmo_cli/commands/resolve_parameters.py +65 -0
  69. ocmo_cli/commands/resolve_series.py +109 -0
  70. ocmo_cli/commands/schema.py +112 -0
  71. ocmo_cli/commands/timeline.py +135 -0
  72. ocmo_cli/commands/version_cmd.py +102 -0
  73. ocmo_cli/commands/whoami.py +12 -0
  74. ocmo_cli/main.py +241 -0
  75. ocmo_cli/output_formats.yaml +295 -0
  76. ocmo_cli-0.8.19.dist-info/METADATA +169 -0
  77. ocmo_cli-0.8.19.dist-info/RECORD +81 -0
  78. ocmo_cli-0.8.19.dist-info/WHEEL +4 -0
  79. ocmo_cli-0.8.19.dist-info/entry_points.txt +2 -0
  80. ocmo_cli-0.8.19.dist-info/licenses/LICENSE +201 -0
  81. ocmo_cli-0.8.19.dist-info/licenses/NOTICE +16 -0
ocmo_cli/_client.py ADDED
@@ -0,0 +1,351 @@
1
+ """Client factory — builds an OcmoClient from CLI context.
2
+
3
+ All SDK imports are deferred so that --help and startup remain fast.
4
+ Supports all OCMO_* environment variables documented in sdk/ocmo/config.py.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from ocmo import NamespaceView, OcmoClient
15
+ from ocmo.config import OcmoConfig
16
+
17
+ from ._config import CliConfig, Context
18
+
19
+ from ._auth_resolve import (
20
+ apply_resolved_oidc_to_kwargs,
21
+ resolve_oidc_settings,
22
+ )
23
+
24
+
25
+ class OcmoCtx:
26
+ """CLI runtime context threaded through click.Context.obj."""
27
+
28
+ def __init__(
29
+ self,
30
+ *,
31
+ namespace: str | None,
32
+ output: str | None,
33
+ dry_run: bool,
34
+ verbose: bool,
35
+ quiet: bool,
36
+ yes: bool,
37
+ skip_version_check: bool,
38
+ no_color: bool,
39
+ ) -> None:
40
+ self.namespace = namespace
41
+ self.output = output
42
+ self.dry_run = dry_run
43
+ self.verbose = verbose
44
+ self.quiet = quiet
45
+ self.yes = yes
46
+ self.skip_version_check = skip_version_check
47
+ self.no_color = no_color
48
+ self._client: OcmoClient | None = None
49
+
50
+ def client(self) -> OcmoClient:
51
+ """Return a cached OcmoClient built from the CLI context and env."""
52
+ if self._client is None:
53
+ self._client = _build_client(
54
+ skip_version_check=self.skip_version_check,
55
+ verbose=self.verbose,
56
+ )
57
+ return self._client
58
+
59
+ def ns(self, namespace: str | None = None) -> NamespaceView:
60
+ """Return a NamespaceView for the effective namespace."""
61
+ ns = namespace or self.namespace or _resolve_namespace()
62
+ if not ns:
63
+ _abort_missing_namespace()
64
+ return self.client().ns(ns)
65
+
66
+ def require_namespace(self, namespace: str | None = None) -> str:
67
+ ns = namespace or self.namespace or _resolve_namespace()
68
+ if not ns:
69
+ _abort_missing_namespace()
70
+ assert ns is not None
71
+ return ns
72
+
73
+ def namespace_view(self, namespace: str | None = None) -> NamespaceView:
74
+ """Return a NamespaceView for the effective namespace."""
75
+ return self.ns(self.require_namespace(namespace))
76
+
77
+
78
+ def _resolve_namespace() -> str | None:
79
+ # 1. Environment variable (SDK layer handles this too, but check here for CLI error message)
80
+ env = os.environ.get("OCMO_NAMESPACE")
81
+ if env:
82
+ return env
83
+ # 2. Config file context
84
+ try:
85
+ from ._config import load_config # deferred
86
+
87
+ cfg = load_config()
88
+ return cfg.namespace() or None
89
+ except Exception:
90
+ return None
91
+
92
+
93
+ def _fetch_oidc_from_server(server: str | None) -> dict[str, str]:
94
+ """Return public OIDC hints from the API ``/api/version`` endpoint."""
95
+ if not server:
96
+ return {}
97
+ try:
98
+ import httpx # deferred
99
+
100
+ url = server.rstrip("/") + "/api/version"
101
+ resp = httpx.get(url, timeout=5.0)
102
+ resp.raise_for_status()
103
+ oidc = (resp.json().get("auth") or {}).get("oidc") or {}
104
+ return {k: str(v) for k, v in oidc.items() if v is not None}
105
+ except Exception:
106
+ return {}
107
+
108
+
109
+ def _fetch_oidc_scopes_from_server(server: str | None) -> str | None:
110
+ """Return OIDC scopes advertised by the API (unauthenticated /version)."""
111
+ scopes = _fetch_oidc_from_server(server).get("scopes")
112
+ return scopes if scopes else None
113
+
114
+
115
+ def _build_client(*, skip_version_check: bool, verbose: bool) -> OcmoClient:
116
+ """Build an OcmoClient with all config options from env + CLI config file."""
117
+ from ocmo import OcmoClient # deferred
118
+
119
+ if verbose:
120
+ import logging
121
+
122
+ logging.basicConfig(level=logging.DEBUG)
123
+
124
+ return OcmoClient(config=build_ocmo_config(skip_version_check=skip_version_check))
125
+
126
+
127
+ def _uses_token_auth(cfg: CliConfig | None = None, ctx: Context | None = None) -> bool:
128
+ """True when auth should use OCMO_TOKEN / token file (resolver or bearer)."""
129
+ if os.environ.get("OCMO_AUTH_MODE") in ("resolver-token", "bearer"):
130
+ return True
131
+ if os.environ.get("OCMO_TOKEN") or os.environ.get("OCMO_TOKEN_FILE"):
132
+ return True
133
+ try:
134
+ if cfg is None:
135
+ cfg = _load_cli_config()
136
+ if ctx is None:
137
+ ctx_name = _resolve_context_name(cfg, None)
138
+ ctx = cfg.contexts.get(ctx_name) if ctx_name else cfg.active_context()
139
+ if ctx is None:
140
+ return False
141
+ if ctx.token_file:
142
+ return True
143
+ if not ctx.auth:
144
+ return False
145
+ entry = cfg.auths.get(ctx.auth)
146
+ return bool(entry and (entry.token or entry.token_file))
147
+ except Exception:
148
+ return False
149
+
150
+
151
+ def build_ocmo_config(
152
+ *,
153
+ skip_version_check: bool = False,
154
+ context_name: str | None = None,
155
+ validate_auth: bool = True,
156
+ ) -> OcmoConfig:
157
+ """Build OcmoConfig from environment variables and the CLI config file."""
158
+ from ocmo.config import OcmoConfig # deferred
159
+
160
+ cfg = _load_cli_config()
161
+ ctx_name = _resolve_context_name(cfg, context_name)
162
+ ctx = cfg.contexts.get(ctx_name) if ctx_name else None
163
+
164
+ server = os.environ.get("OCMO_SERVER") or (ctx.server if ctx else None) or cfg.server()
165
+ if server:
166
+ os.environ.setdefault("OCMO_SERVER", server)
167
+
168
+ file_auth = _auth_kwargs_from_context(cfg, ctx)
169
+ token_auth = _uses_token_auth(cfg, ctx)
170
+ auth_profile = ctx.auth if ctx and ctx.auth else None
171
+
172
+ if token_auth:
173
+ file_auth = {k: v for k, v in file_auth.items() if k in ("token", "token_file")}
174
+ else:
175
+ oidc_hints = _fetch_oidc_from_server(server) if server else {}
176
+ entry = cfg.auths.get(ctx.auth) if ctx and ctx.auth else None
177
+ if entry and entry.mode == "oidc" and not (entry.token or entry.token_file):
178
+ resolved = resolve_oidc_settings(
179
+ server=server,
180
+ entry=entry,
181
+ fetch_server_oidc=_fetch_oidc_from_server,
182
+ )
183
+ apply_resolved_oidc_to_kwargs(file_auth, resolved)
184
+ if not os.environ.get("OCMO_OIDC_SCOPE") and file_auth.get("oidc_scope") is None:
185
+ scopes = oidc_hints.get("scopes")
186
+ if scopes:
187
+ file_auth["oidc_scope"] = scopes
188
+
189
+ auth_kwargs = {k: v for k, v in file_auth.items() if v is not None}
190
+ sdk_cfg = OcmoConfig.from_env(
191
+ skip_version_check=skip_version_check if skip_version_check else None,
192
+ oidc_cache_profile=auth_profile,
193
+ oidc_issuer=auth_kwargs.get("oidc_issuer"),
194
+ client_id=auth_kwargs.get("client_id"),
195
+ client_secret=auth_kwargs.get("client_secret"),
196
+ client_secret_file=auth_kwargs.get("client_secret_file"),
197
+ token=auth_kwargs.get("token"),
198
+ token_file=auth_kwargs.get("token_file"),
199
+ oidc_scope=auth_kwargs.get("oidc_scope"),
200
+ )
201
+ if validate_auth:
202
+ _validate_cli_auth(cfg, ctx, sdk_cfg)
203
+ return sdk_cfg
204
+
205
+
206
+ def _load_cli_config() -> CliConfig:
207
+ from ._config import load_config
208
+
209
+ return load_config()
210
+
211
+
212
+ def _resolve_context_name(cfg: CliConfig, explicit: str | None = None) -> str | None:
213
+ return explicit or os.environ.get("OCMO_CONTEXT") or cfg.current_context or None
214
+
215
+
216
+ def _validate_cli_auth(cfg: CliConfig, ctx: Context | None, sdk_cfg: OcmoConfig) -> None:
217
+ """Require explicit auth configuration before API calls."""
218
+ from ocmo.errors import OcmoConfigError # deferred
219
+
220
+ if sdk_cfg.auth_mode in ("resolver-token", "bearer"):
221
+ return
222
+ if sdk_cfg.token:
223
+ return
224
+ if ctx and ctx.token_file:
225
+ return
226
+
227
+ env_oidc = bool(os.environ.get("OCMO_CLIENT_ID") and os.environ.get("OCMO_OIDC_ISSUER"))
228
+ if env_oidc:
229
+ return
230
+
231
+ auth_name = ctx.auth if ctx else None
232
+ if not auth_name:
233
+ raise OcmoConfigError(_missing_auth_message(cfg, ctx, reason="no_auth_reference"))
234
+
235
+ entry = cfg.auths.get(auth_name)
236
+ if entry is None:
237
+ raise OcmoConfigError(_missing_auth_message(cfg, ctx, reason="missing_auth_entry"))
238
+
239
+ if entry.token or entry.token_file:
240
+ return
241
+
242
+ if entry.mode != "oidc":
243
+ raise OcmoConfigError(f"auths.{auth_name} uses unsupported mode {entry.mode!r}; expected 'oidc'.")
244
+
245
+ if not sdk_cfg.client_id or not sdk_cfg.oidc_issuer:
246
+ raise OcmoConfigError(_missing_auth_message(cfg, ctx, reason="incomplete_auth_entry"))
247
+
248
+
249
+ def _missing_auth_message(cfg: CliConfig, ctx: Context | None, *, reason: str) -> str:
250
+ from ._config import _config_path
251
+
252
+ config_path = _config_path()
253
+ ctx_name = ctx and _resolve_context_name(cfg, None)
254
+ auth_name = ctx.auth if ctx else None
255
+ lines = [
256
+ "Authentication is not configured for the active context.",
257
+ "",
258
+ "Define an auths: entry in the config file and reference it from the context:",
259
+ " auths:",
260
+ f" {auth_name or 'my-oidc'}:",
261
+ " mode: oidc",
262
+ " # issuer / client_id optional — env, /api/version, or:",
263
+ " # issuer: https://sso.example.com",
264
+ " # client_id: ocmo-cli",
265
+ " contexts:",
266
+ f" {ctx_name or 'default'}:",
267
+ f" auth: {auth_name or 'my-oidc'}",
268
+ "",
269
+ "Or set environment variables:",
270
+ " OCMO_OIDC_ISSUER, OCMO_CLIENT_ID",
271
+ " OCMO_CLIENT_SECRET (when required)",
272
+ "",
273
+ f"Config file: {config_path}",
274
+ ]
275
+ if reason == "missing_auth_entry" and auth_name:
276
+ lines.insert(2, f"Context {ctx_name!r} references auth {auth_name!r}, but auths.{auth_name} is missing.")
277
+ elif reason == "no_auth_reference" and ctx_name:
278
+ lines.insert(2, f"Context {ctx_name!r} has no auth: field.")
279
+ elif reason == "incomplete_auth_entry" and auth_name:
280
+ lines.insert(
281
+ 2,
282
+ f"Could not resolve OIDC issuer and client_id for auths.{auth_name} "
283
+ "(set them in the config, OCMO_OIDC_ISSUER/OCMO_CLIENT_ID, or ensure "
284
+ "the context server exposes them via /api/version).",
285
+ )
286
+ return "\n".join(lines)
287
+
288
+
289
+ def _auth_kwargs_from_context(cfg: CliConfig, ctx: Context | None) -> dict[str, str | None]:
290
+ """Merge auth settings from a specific context when env is unset."""
291
+ if not ctx:
292
+ return {}
293
+
294
+ kwargs: dict[str, str | None] = {}
295
+
296
+ if ctx.token_file and not os.environ.get("OCMO_TOKEN") and not os.environ.get("OCMO_TOKEN_FILE"):
297
+ kwargs["token_file"] = ctx.token_file
298
+
299
+ auth_name = ctx.auth
300
+ if not auth_name:
301
+ return kwargs
302
+
303
+ entry = cfg.auths.get(auth_name)
304
+ if not entry:
305
+ return kwargs
306
+
307
+ def _set(env_var: str, config_key: str, value: str | None) -> None:
308
+ if value and not os.environ.get(env_var):
309
+ kwargs[config_key] = value
310
+
311
+ _set("OCMO_OIDC_ISSUER", "oidc_issuer", entry.issuer or None)
312
+ _set("OCMO_CLIENT_ID", "client_id", entry.client_id or None)
313
+ _set("OCMO_CLIENT_SECRET", "client_secret", entry.client_secret or None)
314
+ _set("OCMO_CLIENT_SECRET_FILE", "client_secret_file", entry.client_secret_file or None)
315
+ _set("OCMO_TOKEN", "token", entry.token or None)
316
+ _set("OCMO_TOKEN_FILE", "token_file", entry.token_file or None)
317
+
318
+ return kwargs
319
+
320
+
321
+ def _auth_kwargs_from_config() -> dict[str, str | None]:
322
+ """Merge auth settings from the active CLI context when env is unset."""
323
+ try:
324
+ cfg = _load_cli_config()
325
+ except Exception:
326
+ return {}
327
+ ctx_name = _resolve_context_name(cfg, None)
328
+ ctx = cfg.contexts.get(ctx_name) if ctx_name else cfg.active_context()
329
+ return _auth_kwargs_from_context(cfg, ctx)
330
+
331
+
332
+ def _server_from_config() -> str | None:
333
+ """Read server URL from the config file context."""
334
+ try:
335
+ from ._config import load_config
336
+
337
+ cfg = load_config()
338
+ return cfg.server() or None
339
+ except Exception:
340
+ return None
341
+
342
+
343
+ def _abort_missing_namespace() -> None:
344
+ print(
345
+ "Error: namespace is required. Specify it via:\n"
346
+ " -n / --namespace flag\n"
347
+ " OCMO_NAMESPACE environment variable\n"
348
+ " current context in the config file (ocmo config set namespace <ns>)",
349
+ file=sys.stderr,
350
+ )
351
+ raise SystemExit(2)
@@ -0,0 +1,35 @@
1
+ """Factory for thin client-scoped commands (whoami, api-health, etc.)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import Any
7
+
8
+ import click
9
+
10
+ from ._client import OcmoCtx
11
+ from ._errors import sdk_command
12
+ from ._options import output_option
13
+ from ._output import as_dict
14
+ from ._output_manifest import emit_command_output
15
+
16
+
17
+ def client_command(
18
+ name: str,
19
+ *,
20
+ help: str,
21
+ manifest_key: str,
22
+ method: str,
23
+ ) -> click.Command:
24
+ """Build a command that calls a client method and emits manifest output."""
25
+
26
+ @click.command(name, help=help)
27
+ @output_option(manifest_key)
28
+ @click.pass_obj
29
+ @sdk_command
30
+ def cmd(ctx: OcmoCtx, output_fmt: str | None) -> None:
31
+ fetch: Callable[[], Any] = getattr(ctx.client(), method)
32
+ data = as_dict(fetch())
33
+ emit_command_output(manifest_key, data, output_fmt, ctx_fmt=ctx.output)
34
+
35
+ return cmd
@@ -0,0 +1,29 @@
1
+ """Startup-safe short help for hand-written CLI commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ HAND_WRITTEN_SHORT_HELP: dict[str, tuple[str, str, str]] = {
6
+ "auth": ("auth", "auth_group", "Authenticate with the OCMO server."),
7
+ "config": ("config_cmd", "config_group", "Manage local CLI configuration (server, contexts, auth)."),
8
+ "describe": ("describe", "describe_cmd", "Read or set the Markdown description of a tree item."),
9
+ "resolve": ("resolve", "resolve_group", "Resolve a config or folder to its final value(s)."),
10
+ "export": ("export", "export_cmd", "Export a subtree to disk, preserving tree structure."),
11
+ "import": ("import_", "import_cmd", "Import a directory tree into OCMO."),
12
+ "apply": ("apply", "apply_cmd", "Create or update a tree item from a local file."),
13
+ "edit": ("edit", "edit_group", "Open a tree item in $EDITOR and update it on save."),
14
+ "ls": ("ls", "ls_cmd", "List the direct children of a tree path, or the namespace root when ADDRESS is omitted."),
15
+ "tree": ("ls", "tree_cmd", "Render the subtree under a path in tree format."),
16
+ "diff": ("diff", "diff_cmd", "Show a unified diff between two versions or two items."),
17
+ "schema": ("schema", "schema_cmd", "Render the JSON Schema for a resource type."),
18
+ "version": ("version_cmd", "version_cmd", "Print CLI, SDK, and server versions with a compatibility verdict."),
19
+ "whoami": ("whoami", "whoami_cmd", "Show the identity of the current authenticated principal."),
20
+ "can-i": ("can_i", "can_i_cmd", "Check whether the current principal may perform one or more operations."),
21
+ "api-health": (
22
+ "api_health",
23
+ "api_health_cmd",
24
+ "Check API dependency health (database, cache, and related backends).",
25
+ ),
26
+ "completion": ("completion", "completion_cmd", "Emit a shell completion script."),
27
+ "timeline": ("timeline", "timeline_group", "Item-scoped audit timelines."),
28
+ "resolve-series": ("resolve_series", "resolve_series_group", "Resolve statistics over time."),
29
+ }
@@ -0,0 +1,65 @@
1
+ # AUTO-GENERATED by scripts/generate_commands.py — do not edit.
2
+ # Run: uv run python scripts/generate_commands.py --write
3
+
4
+ from __future__ import annotations
5
+
6
+ from typing import Any
7
+
8
+ OPERATIONS: dict[str, dict[str, Any]] = {
9
+ "health": {"hand_written": True},
10
+ "version": {"hand_written": True},
11
+ "whoami": {"hand_written": True},
12
+ "can_i": {"hand_written": True},
13
+ "list_namespaces": {"action": "get", "resource": "namespace"},
14
+ "show_namespace": {"action": "get", "resource": "namespace"},
15
+ "create_namespace": {"action": "create", "resource": "namespace"},
16
+ "update_namespace": {"action": "update", "resource": "namespace"},
17
+ "delete_namespace": {"action": "delete", "resource": "namespace", "confirm": "destructive"},
18
+ "navigate_root": {"hand_written": True},
19
+ "navigate_path": {"hand_written": True},
20
+ "search_root": {"action": "search", "resource": "tree"},
21
+ "search_path": {"action": "search", "resource": "tree"},
22
+ "list_item_versions": {"action": "get", "resource": "version"},
23
+ "get_item": {"action": "get", "resource": "item"},
24
+ "diff_item": {"hand_written": True},
25
+ "delete_item": {"action": "delete", "resource": "item", "confirm": "destructive"},
26
+ "move_item": {"hand_written": True, "action": "move", "resource": "item"},
27
+ "copy_item": {"hand_written": True, "action": "copy", "resource": "item"},
28
+ "set_tag": {"action": "tag", "resource": "item"},
29
+ "describe_item": {"hand_written": True},
30
+ "get_config_metadata_schema": {"hand_written": True},
31
+ "get_config_data_schema": {"hand_written": True},
32
+ "create_config": {"action": "create", "resource": "config"},
33
+ "update_config": {"action": "update", "resource": "config"},
34
+ "create_template": {"action": "create", "resource": "template"},
35
+ "update_template": {"action": "update", "resource": "template"},
36
+ "create_secret": {"action": "create", "resource": "secret", "confirm": "sensitive"},
37
+ "update_secret": {"action": "update", "resource": "secret", "confirm": "sensitive"},
38
+ "get_resolver_configuration_schema": {"hand_written": True},
39
+ "create_resolver": {"action": "create", "resource": "resolver"},
40
+ "update_resolver": {"action": "update", "resource": "resolver"},
41
+ "rotate_resolver_token": {"action": "rotate", "resource": "token", "confirm": "destructive"},
42
+ "list_cast_formats": {"action": "get", "resource": "cast"},
43
+ "download_resolved_artifact": {"skip": "Internal SDK operation; surfaced via `ocmo resolve`."},
44
+ "resolve_config": {"hand_written": True},
45
+ "resolve_parameters": {"hand_written": True, "action": "resolve", "resource": "parameters"},
46
+ "resolve_draft_config": {"hand_written": True, "action": "resolve", "resource": "draft"},
47
+ "list_global_permissions": {"action": "get", "resource": "globalpermission"},
48
+ "create_global_permission": {"action": "create", "resource": "globalpermission"},
49
+ "get_global_permission": {"action": "get", "resource": "globalpermission"},
50
+ "update_global_permission": {"action": "update", "resource": "globalpermission"},
51
+ "delete_global_permission": {"action": "delete", "resource": "globalpermission", "confirm": "destructive"},
52
+ "move_global_permission": {"hand_written": True, "action": "move", "resource": "globalpermission"},
53
+ "list_global_audit": {"action": "get", "resource": "audit"},
54
+ "get_global_audit_event": {"action": "get", "resource": "audit"},
55
+ "list_namespace_audit": {"action": "get", "resource": "audit"},
56
+ "get_namespace_audit_event": {"action": "get", "resource": "audit"},
57
+ "namespace_audit_timeline": {"hand_written": True},
58
+ "namespace_audit_resolve_series": {"hand_written": True},
59
+ "list_locks": {"action": "get", "resource": "lock"},
60
+ "get_lock": {"action": "get", "resource": "lock"},
61
+ "create_lock": {"action": "create", "resource": "lock"},
62
+ "replace_lock": {"action": "update", "resource": "lock"},
63
+ "delete_lock": {"action": "delete", "resource": "lock", "confirm": "destructive"},
64
+ "propagate_config": {"action": "propagate", "resource": "config"},
65
+ }
ocmo_cli/_config.py ADDED
@@ -0,0 +1,183 @@
1
+ """Local CLI configuration file — kubeconfig-shaped YAML.
2
+
3
+ Precedence (highest first):
4
+ command flag > OCMO_* env var > current context in config file > built-in default.
5
+
6
+ File lives at $OCMO_CONFIG or $XDG_CONFIG_HOME/ocmo/config.yaml (default).
7
+ Created 0600 in a 0700 directory. Refusing to read world-readable files that
8
+ contain inline credentials.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import stat
15
+ import sys
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+
21
+ def _config_path() -> Path:
22
+ env = os.environ.get("OCMO_CONFIG")
23
+ if env:
24
+ return Path(env)
25
+ xdg = os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")
26
+ return Path(xdg) / "ocmo" / "config.yaml"
27
+
28
+
29
+ def format_config_path_note() -> str:
30
+ """Human-readable config file path for help text and command output."""
31
+ path = _config_path()
32
+ note = "" if path.exists() else " (not created yet)"
33
+ return f"Config file: {path}{note}"
34
+
35
+
36
+ @dataclass
37
+ class AuthEntry:
38
+ mode: str = "oidc"
39
+ issuer: str = ""
40
+ client_id: str = ""
41
+ client_secret: str = ""
42
+ client_secret_file: str = ""
43
+ token: str = ""
44
+ token_file: str = ""
45
+
46
+ def has_inline_secret(self) -> bool:
47
+ return bool(self.client_secret or self.token)
48
+
49
+
50
+ @dataclass
51
+ class Context:
52
+ server: str = ""
53
+ namespace: str = ""
54
+ auth: str = ""
55
+ token_file: str = ""
56
+
57
+
58
+ @dataclass
59
+ class CliConfig:
60
+ current_context: str = ""
61
+ contexts: dict[str, Context] = field(default_factory=dict)
62
+ auths: dict[str, AuthEntry] = field(default_factory=dict)
63
+
64
+ def active_context(self) -> Context | None:
65
+ name = os.environ.get("OCMO_CONTEXT") or self.current_context
66
+ return self.contexts.get(name)
67
+
68
+ def server(self) -> str:
69
+ env = os.environ.get("OCMO_SERVER")
70
+ if env:
71
+ return env
72
+ ctx = self.active_context()
73
+ return ctx.server if ctx else ""
74
+
75
+ def namespace(self) -> str:
76
+ env = os.environ.get("OCMO_NAMESPACE")
77
+ if env:
78
+ return env
79
+ ctx = self.active_context()
80
+ return ctx.namespace if ctx else ""
81
+
82
+
83
+ def load_config() -> CliConfig:
84
+ """Load the config file if it exists; return an empty config otherwise."""
85
+ import yaml # deferred
86
+
87
+ path = _config_path()
88
+ if not path.exists():
89
+ return CliConfig()
90
+
91
+ _check_permissions(path)
92
+
93
+ with path.open() as f:
94
+ raw: dict[str, Any] = yaml.safe_load(f) or {}
95
+
96
+ cfg = CliConfig(current_context=raw.get("current-context", ""))
97
+
98
+ for name, ctx_raw in (raw.get("contexts") or {}).items():
99
+ cfg.contexts[name] = Context(
100
+ server=ctx_raw.get("server", ""),
101
+ namespace=ctx_raw.get("namespace", ""),
102
+ auth=ctx_raw.get("auth", ""),
103
+ token_file=ctx_raw.get("token-file", ""),
104
+ )
105
+
106
+ for name, auth_raw in (raw.get("auths") or {}).items():
107
+ entry = AuthEntry(
108
+ mode=auth_raw.get("mode", "oidc"),
109
+ issuer=auth_raw.get("issuer", ""),
110
+ client_id=auth_raw.get("client_id", ""),
111
+ client_secret=auth_raw.get("client_secret", ""),
112
+ client_secret_file=auth_raw.get("client_secret_file", ""),
113
+ token=auth_raw.get("token", ""),
114
+ token_file=auth_raw.get("token_file", ""),
115
+ )
116
+ if entry.has_inline_secret():
117
+ print(
118
+ f"Warning: auth '{name}' contains an inline credential. "
119
+ "Consider using the *_file variant, a keyring, or an environment variable.",
120
+ file=sys.stderr,
121
+ )
122
+ cfg.auths[name] = entry
123
+
124
+ return cfg
125
+
126
+
127
+ def save_config(cfg: CliConfig) -> None:
128
+ """Write config to disk with 0600/0700 permissions."""
129
+ import yaml # deferred
130
+
131
+ path = _config_path()
132
+ path.parent.mkdir(parents=True, exist_ok=True)
133
+ os.chmod(path.parent, 0o700)
134
+
135
+ raw: dict[str, Any] = {}
136
+ if cfg.current_context:
137
+ raw["current-context"] = cfg.current_context
138
+ if cfg.contexts:
139
+ raw["contexts"] = {name: _ctx_to_dict(ctx) for name, ctx in cfg.contexts.items()}
140
+ if cfg.auths:
141
+ raw["auths"] = {name: _auth_to_dict(auth) for name, auth in cfg.auths.items()}
142
+
143
+ with path.open("w") as f:
144
+ yaml.safe_dump(raw, f, default_flow_style=False)
145
+ os.chmod(path, 0o600)
146
+
147
+
148
+ def _ctx_to_dict(ctx: Context) -> dict[str, Any]:
149
+ d: dict[str, Any] = {}
150
+ if ctx.server:
151
+ d["server"] = ctx.server
152
+ if ctx.namespace:
153
+ d["namespace"] = ctx.namespace
154
+ if ctx.auth:
155
+ d["auth"] = ctx.auth
156
+ if ctx.token_file:
157
+ d["token-file"] = ctx.token_file
158
+ return d
159
+
160
+
161
+ def _auth_to_dict(auth: AuthEntry) -> dict[str, Any]:
162
+ d: dict[str, Any] = {"mode": auth.mode}
163
+ for k in ("issuer", "client_id", "client_secret", "client_secret_file", "token", "token_file"):
164
+ v = getattr(auth, k)
165
+ if v:
166
+ d[k] = v
167
+ return d
168
+
169
+
170
+ def _check_permissions(path: Path) -> None:
171
+ """Refuse to read a world-readable config that contains credentials."""
172
+ try:
173
+ mode = path.stat().st_mode
174
+ except OSError:
175
+ return
176
+ if mode & stat.S_IROTH:
177
+ # World-readable: read but warn unless no credentials present
178
+ print(
179
+ f"Warning: config file {path} is world-readable. "
180
+ "This is a security risk if it contains credentials. "
181
+ "Run: chmod 600 " + str(path),
182
+ file=sys.stderr,
183
+ )