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.
- LICENSE +201 -0
- NOTICE +16 -0
- ocmo_cli/__init__.py +3 -0
- ocmo_cli/__main__.py +4 -0
- ocmo_cli/_address.py +182 -0
- ocmo_cli/_audit_item.py +33 -0
- ocmo_cli/_auth_resolve.py +66 -0
- ocmo_cli/_body_payload.py +203 -0
- ocmo_cli/_click_groups.py +227 -0
- ocmo_cli/_client.py +351 -0
- ocmo_cli/_client_commands.py +35 -0
- ocmo_cli/_command_registry.py +29 -0
- ocmo_cli/_commands_map.py +65 -0
- ocmo_cli/_config.py +183 -0
- ocmo_cli/_config_content.py +43 -0
- ocmo_cli/_delete_item_output.py +129 -0
- ocmo_cli/_diff_input.py +144 -0
- ocmo_cli/_dry_run.py +292 -0
- ocmo_cli/_errors.py +151 -0
- ocmo_cli/_exit.py +13 -0
- ocmo_cli/_export_metadata.py +47 -0
- ocmo_cli/_generated_registry.py +45 -0
- ocmo_cli/_get_cast_output.py +52 -0
- ocmo_cli/_get_item_list.py +23 -0
- ocmo_cli/_globalpermission_output.py +98 -0
- ocmo_cli/_import_metadata.py +140 -0
- ocmo_cli/_import_plan.py +221 -0
- ocmo_cli/_item_output.py +223 -0
- ocmo_cli/_lazy_mixin.py +29 -0
- ocmo_cli/_ls_wide.py +286 -0
- ocmo_cli/_mutating.py +39 -0
- ocmo_cli/_notice.py +17 -0
- ocmo_cli/_options.py +77 -0
- ocmo_cli/_output.py +424 -0
- ocmo_cli/_output_manifest.py +332 -0
- ocmo_cli/_resolve_draft.py +50 -0
- ocmo_cli/_resolve_options.py +143 -0
- ocmo_cli/_resolve_output.py +228 -0
- ocmo_cli/_resolve_parameters_output.py +72 -0
- ocmo_cli/_resolve_series.py +89 -0
- ocmo_cli/_resolve_series_chart.py +409 -0
- ocmo_cli/_resolve_write.py +185 -0
- ocmo_cli/_resolver_output.py +34 -0
- ocmo_cli/_resource_aliases.py +22 -0
- ocmo_cli/_sdk_dispatch.py +310 -0
- ocmo_cli/_typing.py +27 -0
- ocmo_cli/_version_output.py +39 -0
- ocmo_cli/_whoami_output.py +43 -0
- ocmo_cli/commands/__init__.py +1 -0
- ocmo_cli/commands/api.py +125 -0
- ocmo_cli/commands/api_health.py +12 -0
- ocmo_cli/commands/apply.py +153 -0
- ocmo_cli/commands/auth.py +786 -0
- ocmo_cli/commands/can_i.py +51 -0
- ocmo_cli/commands/completion.py +59 -0
- ocmo_cli/commands/config_cmd.py +235 -0
- ocmo_cli/commands/describe.py +109 -0
- ocmo_cli/commands/diff.py +86 -0
- ocmo_cli/commands/edit.py +200 -0
- ocmo_cli/commands/export.py +242 -0
- ocmo_cli/commands/generated.py +907 -0
- ocmo_cli/commands/gp_move.py +108 -0
- ocmo_cli/commands/import_.py +595 -0
- ocmo_cli/commands/item_relocate.py +187 -0
- ocmo_cli/commands/ls.py +481 -0
- ocmo_cli/commands/resolve.py +658 -0
- ocmo_cli/commands/resolve_draft.py +102 -0
- ocmo_cli/commands/resolve_parameters.py +65 -0
- ocmo_cli/commands/resolve_series.py +109 -0
- ocmo_cli/commands/schema.py +112 -0
- ocmo_cli/commands/timeline.py +135 -0
- ocmo_cli/commands/version_cmd.py +102 -0
- ocmo_cli/commands/whoami.py +12 -0
- ocmo_cli/main.py +241 -0
- ocmo_cli/output_formats.yaml +295 -0
- ocmo_cli-0.8.19.dist-info/METADATA +169 -0
- ocmo_cli-0.8.19.dist-info/RECORD +81 -0
- ocmo_cli-0.8.19.dist-info/WHEEL +4 -0
- ocmo_cli-0.8.19.dist-info/entry_points.txt +2 -0
- ocmo_cli-0.8.19.dist-info/licenses/LICENSE +201 -0
- 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
|
+
)
|