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
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
"""ocmo resolve — resolve a config or folder to its final value.
|
|
2
|
+
|
|
3
|
+
Key behaviours:
|
|
4
|
+
- Lazy download: artifacts only fetched when about to be written/printed.
|
|
5
|
+
- Default output (raw): metadata on stderr, content on stdout per item.
|
|
6
|
+
- --trace-only performs zero artifact downloads.
|
|
7
|
+
- Writes are atomic per file.
|
|
8
|
+
- Resolver hooks are opt-in (OCMO_EXEC_HOOKS or --exec-hooks).
|
|
9
|
+
- --print-hooks exits without resolving (for inspection before enabling hooks).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import shlex
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
import tempfile
|
|
21
|
+
from collections.abc import Callable, Iterable
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
24
|
+
|
|
25
|
+
import click
|
|
26
|
+
|
|
27
|
+
from .._address import parse_address_or_exit
|
|
28
|
+
from .._click_groups import DefaultCommandGroup
|
|
29
|
+
from .._client import OcmoCtx
|
|
30
|
+
from .._errors import sdk_command
|
|
31
|
+
from .._exit import FAILURE, HOOK_FAILURE, USAGE_ERROR, VALIDATION_ERROR
|
|
32
|
+
from .._options import namespace_option
|
|
33
|
+
from .._output import err, warn
|
|
34
|
+
from .._resolve_options import resolve_options
|
|
35
|
+
from .._resolve_output import (
|
|
36
|
+
emit_resolve_item_metadata,
|
|
37
|
+
emit_resolve_results,
|
|
38
|
+
emit_write_outcome,
|
|
39
|
+
emit_write_outcome_jsonpath,
|
|
40
|
+
emit_write_report,
|
|
41
|
+
resolve_output_format,
|
|
42
|
+
)
|
|
43
|
+
from .._resolve_write import (
|
|
44
|
+
ResolveWriteOutcome,
|
|
45
|
+
atomic_write,
|
|
46
|
+
resolve_item_dest,
|
|
47
|
+
save_resolve_item,
|
|
48
|
+
write_resolve_items,
|
|
49
|
+
)
|
|
50
|
+
from .._typing import ResolvedArtifact
|
|
51
|
+
|
|
52
|
+
if TYPE_CHECKING:
|
|
53
|
+
from ocmo.resolve import ResolveResult
|
|
54
|
+
|
|
55
|
+
_EXEC_HOOKS_ENV = "OCMO_EXEC_HOOKS"
|
|
56
|
+
_HOOK_TIMEOUT_DEFAULT = 60
|
|
57
|
+
_CREDENTIAL_VARS = frozenset(
|
|
58
|
+
{
|
|
59
|
+
"OCMO_TOKEN",
|
|
60
|
+
"OCMO_CLIENT_SECRET",
|
|
61
|
+
"OCMO_CLIENT_SECRET_FILE",
|
|
62
|
+
"OCMO_PASSWORD",
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_RESOLVE_HELP = """\
|
|
68
|
+
Resolve a config or folder to its final value(s).
|
|
69
|
+
|
|
70
|
+
Default output prints resolve metadata on stderr (``# name: …``) and each
|
|
71
|
+
artifact on stdout. Use ``-o json`` or ``-o yaml`` for the full response with
|
|
72
|
+
``data`` instead of download URLs.
|
|
73
|
+
|
|
74
|
+
\b
|
|
75
|
+
ADDRESS format: <tree-path>[@<version>]. A trailing slash marks a folder.
|
|
76
|
+
|
|
77
|
+
\b
|
|
78
|
+
Examples:
|
|
79
|
+
ocmo -n prod resolve app/web
|
|
80
|
+
ocmo -n prod resolve app/ --output-dir ./out/
|
|
81
|
+
ocmo -n prod resolve app/ -o name
|
|
82
|
+
ocmo -n prod resolve app/web --cast json --param replicas=5
|
|
83
|
+
ocmo -n prod resolve app/web -O ./app.json
|
|
84
|
+
ocmo -n prod resolve app/ --trace-only -o json
|
|
85
|
+
ocmo -n prod resolve app/web --property database.host
|
|
86
|
+
ocmo -n prod resolve app/web@stable
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@click.group("resolve", cls=DefaultCommandGroup, default_command="_default", help=_RESOLVE_HELP)
|
|
91
|
+
@click.pass_context
|
|
92
|
+
def resolve_group(ctx: click.Context) -> None:
|
|
93
|
+
"""Resolve configs or inspect resolve metadata."""
|
|
94
|
+
if ctx.invoked_subcommand is None:
|
|
95
|
+
click.echo(ctx.get_help())
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@resolve_group.command("_default", hidden=True)
|
|
99
|
+
@click.argument("address")
|
|
100
|
+
@namespace_option()
|
|
101
|
+
@resolve_options()
|
|
102
|
+
@click.pass_obj
|
|
103
|
+
@sdk_command
|
|
104
|
+
def resolve_cmd(
|
|
105
|
+
ctx: OcmoCtx,
|
|
106
|
+
address: str,
|
|
107
|
+
namespace: str | None,
|
|
108
|
+
output_fmt: str | None,
|
|
109
|
+
cast: str | None,
|
|
110
|
+
params: tuple[str, ...],
|
|
111
|
+
cast_options: tuple[str, ...],
|
|
112
|
+
output_file: str | None,
|
|
113
|
+
output_dir: str | None,
|
|
114
|
+
rewrite: bool,
|
|
115
|
+
skip_existing: bool,
|
|
116
|
+
trace_only: bool,
|
|
117
|
+
prop_path: str | None,
|
|
118
|
+
version_flag: str | None,
|
|
119
|
+
exec_hooks: bool,
|
|
120
|
+
hook_timeout: int,
|
|
121
|
+
trust_hooks_sha: str | None,
|
|
122
|
+
print_hooks: bool,
|
|
123
|
+
) -> None:
|
|
124
|
+
"""Resolve a config or folder to its final value.
|
|
125
|
+
|
|
126
|
+
\b
|
|
127
|
+
Examples:
|
|
128
|
+
ocmo -n prod resolve app/web
|
|
129
|
+
ocmo -n prod resolve app/web --cast json --param replicas=5
|
|
130
|
+
ocmo -n prod resolve app/web -O ./app.json
|
|
131
|
+
ocmo -n prod resolve app/ --output-dir ./out/
|
|
132
|
+
ocmo -n prod resolve app/ --trace-only -o json
|
|
133
|
+
ocmo -n prod resolve app/web --property database.host
|
|
134
|
+
"""
|
|
135
|
+
path, version = parse_address_or_exit(address, version_flag=version_flag)
|
|
136
|
+
|
|
137
|
+
ns = ctx.require_namespace(namespace)
|
|
138
|
+
view = ctx.namespace_view(namespace)
|
|
139
|
+
|
|
140
|
+
# Parse --param and --cast-option
|
|
141
|
+
params_dict = _parse_kv_args(params)
|
|
142
|
+
cast_options_dict = _parse_kv_args(cast_options)
|
|
143
|
+
|
|
144
|
+
# Build resolve kwargs
|
|
145
|
+
resolve_kwargs: dict[str, Any] = {}
|
|
146
|
+
if cast:
|
|
147
|
+
resolve_kwargs["cast"] = cast
|
|
148
|
+
if version:
|
|
149
|
+
resolve_kwargs["version"] = version
|
|
150
|
+
if trace_only:
|
|
151
|
+
resolve_kwargs["trace_only"] = True
|
|
152
|
+
if params_dict:
|
|
153
|
+
resolve_kwargs["params"] = params_dict
|
|
154
|
+
if cast_options_dict:
|
|
155
|
+
resolve_kwargs["cast_options"] = cast_options_dict
|
|
156
|
+
|
|
157
|
+
if ctx.dry_run:
|
|
158
|
+
from .._dry_run import emit_dry_run_plan, format_resolve_dry_run # deferred
|
|
159
|
+
|
|
160
|
+
emit_dry_run_plan(
|
|
161
|
+
format_resolve_dry_run(
|
|
162
|
+
path=path,
|
|
163
|
+
namespace=ns,
|
|
164
|
+
cast=cast,
|
|
165
|
+
parameters=params_dict or None,
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
if print_hooks:
|
|
171
|
+
resolve_kwargs["trace_only"] = True
|
|
172
|
+
|
|
173
|
+
result = view.resolve(path, **resolve_kwargs)
|
|
174
|
+
|
|
175
|
+
# --print-hooks: show hooks and exit
|
|
176
|
+
if print_hooks:
|
|
177
|
+
_print_hooks(result)
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
run_resolve_pipeline(
|
|
181
|
+
ctx,
|
|
182
|
+
result,
|
|
183
|
+
path=path,
|
|
184
|
+
ns=ns,
|
|
185
|
+
output_fmt=output_fmt,
|
|
186
|
+
output_file=output_file,
|
|
187
|
+
output_dir=output_dir,
|
|
188
|
+
rewrite=rewrite,
|
|
189
|
+
skip_existing=skip_existing,
|
|
190
|
+
trace_only=trace_only,
|
|
191
|
+
prop_path=prop_path,
|
|
192
|
+
exec_hooks=exec_hooks,
|
|
193
|
+
hook_timeout=hook_timeout,
|
|
194
|
+
trust_hooks_sha=trust_hooks_sha,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def run_resolve_pipeline(
|
|
199
|
+
ctx: OcmoCtx,
|
|
200
|
+
result: ResolveResult,
|
|
201
|
+
*,
|
|
202
|
+
path: str,
|
|
203
|
+
ns: str,
|
|
204
|
+
output_fmt: str | None,
|
|
205
|
+
output_file: str | None,
|
|
206
|
+
output_dir: str | None,
|
|
207
|
+
rewrite: bool,
|
|
208
|
+
skip_existing: bool,
|
|
209
|
+
trace_only: bool,
|
|
210
|
+
prop_path: str | None,
|
|
211
|
+
exec_hooks: bool,
|
|
212
|
+
hook_timeout: int,
|
|
213
|
+
trust_hooks_sha: str | None,
|
|
214
|
+
) -> None:
|
|
215
|
+
"""Shared post-resolve output: trace-only, hooks, filesystem writes, or stdout."""
|
|
216
|
+
hooks_mode = _resolve_hooks_mode(exec_hooks)
|
|
217
|
+
|
|
218
|
+
items = list(cast(Iterable[ResolvedArtifact], result)) # evaluates the lazy iterator (metadata only)
|
|
219
|
+
|
|
220
|
+
if not items:
|
|
221
|
+
warn("Resolve returned no items.")
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
output_fmt = resolve_output_format(output_fmt, ctx.output)
|
|
225
|
+
|
|
226
|
+
if rewrite and skip_existing:
|
|
227
|
+
err("Cannot use --rewrite and --skip-existing together.")
|
|
228
|
+
raise SystemExit(USAGE_ERROR)
|
|
229
|
+
|
|
230
|
+
# --trace-only: print metadata, no downloads
|
|
231
|
+
if trace_only:
|
|
232
|
+
if output_fmt == "raw":
|
|
233
|
+
output_fmt = "yaml"
|
|
234
|
+
emit_resolve_results(items, output_fmt, no_color=ctx.no_color, include_data=False)
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
# --property: stdout-only when not writing to filesystem
|
|
238
|
+
if prop_path and not output_dir and not output_file:
|
|
239
|
+
if len(items) != 1:
|
|
240
|
+
err(f"--property requires exactly one resolved item; got {len(items)}.")
|
|
241
|
+
raise SystemExit(USAGE_ERROR)
|
|
242
|
+
_print_property(items[0], prop_path)
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
# Execute hooks when requested
|
|
246
|
+
if hooks_mode != "never":
|
|
247
|
+
if output_file and len(items) > 1:
|
|
248
|
+
err("--output-file requires exactly one resolved item.")
|
|
249
|
+
raise SystemExit(USAGE_ERROR)
|
|
250
|
+
_run_with_hooks(
|
|
251
|
+
result,
|
|
252
|
+
items,
|
|
253
|
+
path,
|
|
254
|
+
ns,
|
|
255
|
+
output_dir,
|
|
256
|
+
output_file,
|
|
257
|
+
hooks_mode=hooks_mode,
|
|
258
|
+
hook_timeout=hook_timeout,
|
|
259
|
+
trust_hooks_sha=trust_hooks_sha,
|
|
260
|
+
yes=ctx.yes,
|
|
261
|
+
rewrite=rewrite,
|
|
262
|
+
skip_existing=skip_existing,
|
|
263
|
+
output_fmt=output_fmt,
|
|
264
|
+
no_color=ctx.no_color,
|
|
265
|
+
prop_path=prop_path,
|
|
266
|
+
)
|
|
267
|
+
return
|
|
268
|
+
|
|
269
|
+
# Write to filesystem
|
|
270
|
+
if output_dir or output_file:
|
|
271
|
+
if output_file and len(items) > 1:
|
|
272
|
+
err("--output-file requires exactly one resolved item.")
|
|
273
|
+
raise SystemExit(USAGE_ERROR)
|
|
274
|
+
_save_and_report(
|
|
275
|
+
items,
|
|
276
|
+
output_file=output_file,
|
|
277
|
+
output_dir=output_dir,
|
|
278
|
+
rewrite=rewrite,
|
|
279
|
+
skip_existing=skip_existing,
|
|
280
|
+
output_fmt=output_fmt,
|
|
281
|
+
no_color=ctx.no_color,
|
|
282
|
+
prop_path=prop_path,
|
|
283
|
+
)
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
emit_resolve_results(items, output_fmt, no_color=ctx.no_color)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
# Helpers
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _parse_kv_args(args: tuple[str, ...]) -> dict[str, str]:
|
|
295
|
+
result: dict[str, str] = {}
|
|
296
|
+
for arg in args:
|
|
297
|
+
if "=" not in arg:
|
|
298
|
+
err(f"Invalid key=value argument: {arg!r}")
|
|
299
|
+
raise SystemExit(USAGE_ERROR)
|
|
300
|
+
k, v = arg.split("=", 1)
|
|
301
|
+
result[k] = v
|
|
302
|
+
return result
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _resolve_hooks_mode(exec_hooks_flag: bool) -> str:
|
|
306
|
+
if exec_hooks_flag:
|
|
307
|
+
return "always"
|
|
308
|
+
env = os.environ.get(_EXEC_HOOKS_ENV, "never").lower()
|
|
309
|
+
return env if env in ("never", "prompt", "always") else "never"
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _print_hooks(result: ResolveResult) -> None:
|
|
313
|
+
hooks = _extract_hooks(result)
|
|
314
|
+
if hooks is None:
|
|
315
|
+
err("No hooks configured.")
|
|
316
|
+
return
|
|
317
|
+
for field in ("validate", "validate_all", "post_resolve", "post_resolve_all"):
|
|
318
|
+
val = getattr(hooks, field, None)
|
|
319
|
+
if val:
|
|
320
|
+
err(f"{field}: {val}")
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _get_property_value(item: ResolvedArtifact, prop_path: str) -> Any:
|
|
324
|
+
from ocmo.errors import PropertyNotFoundError, UnstructuredFormatError
|
|
325
|
+
|
|
326
|
+
try:
|
|
327
|
+
return item.get(prop_path)
|
|
328
|
+
except UnstructuredFormatError as e:
|
|
329
|
+
err(str(e))
|
|
330
|
+
raise SystemExit(VALIDATION_ERROR)
|
|
331
|
+
except PropertyNotFoundError as e:
|
|
332
|
+
err(str(e))
|
|
333
|
+
raise SystemExit(VALIDATION_ERROR)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _property_value_bytes(value: Any) -> bytes:
|
|
337
|
+
if isinstance(value, dict | list):
|
|
338
|
+
text = json.dumps(value, default=str)
|
|
339
|
+
else:
|
|
340
|
+
text = str(value)
|
|
341
|
+
return f"{text}\n".encode()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _item_write_bytes(item: ResolvedArtifact, prop_path: str | None) -> tuple[bytes, bool]:
|
|
345
|
+
if not prop_path:
|
|
346
|
+
return item.bytes, False
|
|
347
|
+
return _property_value_bytes(_get_property_value(item, prop_path)), True
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _item_data_fn(prop_path: str | None) -> Callable[[ResolvedArtifact], tuple[bytes, bool]] | None:
|
|
351
|
+
if not prop_path:
|
|
352
|
+
return None
|
|
353
|
+
return lambda item: _item_write_bytes(item, prop_path)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _print_property(item: ResolvedArtifact, prop_path: str) -> None:
|
|
357
|
+
value = _get_property_value(item, prop_path)
|
|
358
|
+
if isinstance(value, dict | list):
|
|
359
|
+
print(json.dumps(value, default=str))
|
|
360
|
+
else:
|
|
361
|
+
print(value)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _write_report_format(output_fmt: str) -> str:
|
|
365
|
+
return output_fmt if output_fmt in ("json", "yaml") else "raw"
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _is_interleaved_save_format(output_fmt: str) -> bool:
|
|
369
|
+
return output_fmt in ("raw", "name") or output_fmt.startswith("jsonpath=")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _save_and_report(
|
|
373
|
+
items: list[ResolvedArtifact],
|
|
374
|
+
*,
|
|
375
|
+
output_file: str | None,
|
|
376
|
+
output_dir: str | None,
|
|
377
|
+
rewrite: bool,
|
|
378
|
+
skip_existing: bool,
|
|
379
|
+
output_fmt: str,
|
|
380
|
+
no_color: bool,
|
|
381
|
+
prop_path: str | None = None,
|
|
382
|
+
) -> list[ResolveWriteOutcome]:
|
|
383
|
+
output_dir_value = output_dir or (None if output_file else ".")
|
|
384
|
+
item_data = _item_data_fn(prop_path)
|
|
385
|
+
|
|
386
|
+
if _is_interleaved_save_format(output_fmt):
|
|
387
|
+
if output_file and len(items) != 1:
|
|
388
|
+
err("--output-file requires exactly one resolved item.")
|
|
389
|
+
raise SystemExit(USAGE_ERROR)
|
|
390
|
+
if output_dir_value:
|
|
391
|
+
Path(output_dir_value).mkdir(parents=True, exist_ok=True)
|
|
392
|
+
|
|
393
|
+
outcomes: list[ResolveWriteOutcome] = []
|
|
394
|
+
for index, item in enumerate(items):
|
|
395
|
+
if output_fmt == "raw" and index > 0:
|
|
396
|
+
print(file=sys.stderr)
|
|
397
|
+
if output_fmt == "raw":
|
|
398
|
+
emit_resolve_item_metadata(item, no_color=no_color)
|
|
399
|
+
elif output_fmt == "name":
|
|
400
|
+
name = getattr(item, "name", None)
|
|
401
|
+
if name:
|
|
402
|
+
print(name)
|
|
403
|
+
try:
|
|
404
|
+
dest = resolve_item_dest(
|
|
405
|
+
item,
|
|
406
|
+
output_file=output_file,
|
|
407
|
+
output_dir=output_dir_value or ".",
|
|
408
|
+
)
|
|
409
|
+
except ValueError as exc:
|
|
410
|
+
err(str(exc))
|
|
411
|
+
raise SystemExit(USAGE_ERROR) from exc
|
|
412
|
+
data, skip_checksum = _item_write_bytes(item, prop_path)
|
|
413
|
+
outcome = save_resolve_item(
|
|
414
|
+
item,
|
|
415
|
+
dest,
|
|
416
|
+
rewrite=rewrite,
|
|
417
|
+
skip_existing=skip_existing,
|
|
418
|
+
data=data,
|
|
419
|
+
skip_checksum=skip_checksum,
|
|
420
|
+
)
|
|
421
|
+
outcomes.append(outcome)
|
|
422
|
+
if output_fmt == "raw" or output_fmt == "name":
|
|
423
|
+
emit_write_outcome(outcome, "raw", no_color=no_color)
|
|
424
|
+
elif output_fmt.startswith("jsonpath="):
|
|
425
|
+
emit_write_outcome_jsonpath(outcome, output_fmt[9:])
|
|
426
|
+
|
|
427
|
+
if any(outcome.result == "failed" for outcome in outcomes):
|
|
428
|
+
raise SystemExit(FAILURE)
|
|
429
|
+
return outcomes
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
outcomes = write_resolve_items(
|
|
433
|
+
items,
|
|
434
|
+
output_file=output_file,
|
|
435
|
+
output_dir=output_dir_value,
|
|
436
|
+
rewrite=rewrite,
|
|
437
|
+
skip_existing=skip_existing,
|
|
438
|
+
item_data=item_data,
|
|
439
|
+
)
|
|
440
|
+
except ValueError as exc:
|
|
441
|
+
err(str(exc))
|
|
442
|
+
raise SystemExit(USAGE_ERROR) from exc
|
|
443
|
+
|
|
444
|
+
emit_write_report(outcomes, _write_report_format(output_fmt), no_color=no_color)
|
|
445
|
+
|
|
446
|
+
if any(outcome.result == "failed" for outcome in outcomes):
|
|
447
|
+
raise SystemExit(FAILURE)
|
|
448
|
+
|
|
449
|
+
return outcomes
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _run_with_hooks(
|
|
453
|
+
result: ResolveResult,
|
|
454
|
+
items: list[ResolvedArtifact],
|
|
455
|
+
path: str,
|
|
456
|
+
ns: str,
|
|
457
|
+
output_dir: str | None,
|
|
458
|
+
output_file: str | None,
|
|
459
|
+
*,
|
|
460
|
+
hooks_mode: str,
|
|
461
|
+
hook_timeout: int,
|
|
462
|
+
trust_hooks_sha: str | None,
|
|
463
|
+
yes: bool,
|
|
464
|
+
rewrite: bool,
|
|
465
|
+
skip_existing: bool,
|
|
466
|
+
output_fmt: str,
|
|
467
|
+
no_color: bool,
|
|
468
|
+
prop_path: str | None = None,
|
|
469
|
+
) -> None:
|
|
470
|
+
"""Full pipeline: stage → validate → place → post_resolve, per §13."""
|
|
471
|
+
hooks = _extract_hooks(result)
|
|
472
|
+
if hooks is None:
|
|
473
|
+
if output_dir or output_file:
|
|
474
|
+
_save_and_report(
|
|
475
|
+
items,
|
|
476
|
+
output_file=output_file,
|
|
477
|
+
output_dir=output_dir,
|
|
478
|
+
rewrite=rewrite,
|
|
479
|
+
skip_existing=skip_existing,
|
|
480
|
+
output_fmt=output_fmt,
|
|
481
|
+
no_color=no_color,
|
|
482
|
+
prop_path=prop_path,
|
|
483
|
+
)
|
|
484
|
+
return
|
|
485
|
+
|
|
486
|
+
hook_block_sha = _sha256_hooks(hooks)
|
|
487
|
+
|
|
488
|
+
# Trust pinning
|
|
489
|
+
if hooks_mode == "always" and trust_hooks_sha and trust_hooks_sha != hook_block_sha:
|
|
490
|
+
err(
|
|
491
|
+
f"Hook configuration has changed.\n"
|
|
492
|
+
f" Expected SHA-256: {trust_hooks_sha}\n"
|
|
493
|
+
f" Current SHA-256: {hook_block_sha}\n"
|
|
494
|
+
"Re-run with --trust-hooks to accept the new configuration."
|
|
495
|
+
)
|
|
496
|
+
raise SystemExit(HOOK_FAILURE)
|
|
497
|
+
|
|
498
|
+
# Consent
|
|
499
|
+
if hooks_mode == "prompt":
|
|
500
|
+
if not sys.stdin.isatty():
|
|
501
|
+
err("Hook execution requires TTY for 'prompt' mode. Use --exec-hooks or OCMO_EXEC_HOOKS=always.")
|
|
502
|
+
raise SystemExit(HOOK_FAILURE)
|
|
503
|
+
print("The following hooks will be executed:", file=sys.stderr)
|
|
504
|
+
_print_hooks(result)
|
|
505
|
+
answer = input("Execute? [y/N] ").strip().lower()
|
|
506
|
+
if answer not in ("y", "yes"):
|
|
507
|
+
err("Hook execution declined.")
|
|
508
|
+
raise SystemExit(HOOK_FAILURE)
|
|
509
|
+
|
|
510
|
+
# Stage into temp directory
|
|
511
|
+
with tempfile.TemporaryDirectory() as staging_str:
|
|
512
|
+
staging = Path(staging_str)
|
|
513
|
+
staged: list[Path] = []
|
|
514
|
+
for item in items:
|
|
515
|
+
name = getattr(item, "name", "output")
|
|
516
|
+
dest = staging / name
|
|
517
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
518
|
+
data, _skip_checksum = _item_write_bytes(item, prop_path)
|
|
519
|
+
atomic_write(dest, data)
|
|
520
|
+
staged.append(dest)
|
|
521
|
+
|
|
522
|
+
env = _scrub_env(ns=ns, path=path, items=staged)
|
|
523
|
+
|
|
524
|
+
# Validation phase
|
|
525
|
+
_run_hook_phase(
|
|
526
|
+
hooks=hooks,
|
|
527
|
+
phase="validate",
|
|
528
|
+
staged=staged,
|
|
529
|
+
env=env,
|
|
530
|
+
timeout=hook_timeout,
|
|
531
|
+
quiet=False,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
# Place files with checksum-aware idempotency
|
|
535
|
+
outcomes = _save_and_report(
|
|
536
|
+
items,
|
|
537
|
+
output_file=output_file,
|
|
538
|
+
output_dir=output_dir,
|
|
539
|
+
rewrite=rewrite,
|
|
540
|
+
skip_existing=skip_existing,
|
|
541
|
+
output_fmt=output_fmt,
|
|
542
|
+
no_color=no_color,
|
|
543
|
+
prop_path=prop_path,
|
|
544
|
+
)
|
|
545
|
+
placed = [outcome.path for outcome in outcomes if outcome.result != "failed"]
|
|
546
|
+
|
|
547
|
+
# Post-resolve phase
|
|
548
|
+
env_placed = _scrub_env(ns=ns, path=path, items=placed)
|
|
549
|
+
_run_hook_phase(
|
|
550
|
+
hooks=hooks,
|
|
551
|
+
phase="post_resolve",
|
|
552
|
+
staged=placed,
|
|
553
|
+
env=env_placed,
|
|
554
|
+
timeout=hook_timeout,
|
|
555
|
+
quiet=False,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _extract_hooks(result: ResolveResult) -> Any | None:
|
|
560
|
+
resolver = getattr(result, "resolver", None)
|
|
561
|
+
if resolver is None:
|
|
562
|
+
return None
|
|
563
|
+
hooks = getattr(resolver, "hooks", None)
|
|
564
|
+
if hooks is None:
|
|
565
|
+
return None
|
|
566
|
+
# Check if any hook is set
|
|
567
|
+
for field in ("validate", "validate_all", "post_resolve", "post_resolve_all"):
|
|
568
|
+
if getattr(hooks, field, None):
|
|
569
|
+
return hooks
|
|
570
|
+
return None
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def _sha256_hooks(hooks: Any) -> str:
|
|
574
|
+
parts = []
|
|
575
|
+
for field in ("validate", "validate_all", "post_resolve", "post_resolve_all"):
|
|
576
|
+
v = getattr(hooks, field, None) or ""
|
|
577
|
+
parts.append(f"{field}={v}")
|
|
578
|
+
return hashlib.sha256("|".join(parts).encode()).hexdigest()
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _scrub_env(ns: str, path: str, items: list[Path]) -> dict[str, str]:
|
|
582
|
+
env = {k: v for k, v in os.environ.items() if k not in _CREDENTIAL_VARS}
|
|
583
|
+
env["OCMO_HOOK_NAMESPACE"] = ns
|
|
584
|
+
env["OCMO_HOOK_PATH"] = path
|
|
585
|
+
env["OCMO_HOOK_ITEM"] = str(items[0]) if items else ""
|
|
586
|
+
env["OCMO_HOOK_FILES"] = " ".join(shlex.quote(str(p)) for p in items)
|
|
587
|
+
return env
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _run_hook_phase(
|
|
591
|
+
*,
|
|
592
|
+
hooks: Any,
|
|
593
|
+
phase: str,
|
|
594
|
+
staged: list[Path],
|
|
595
|
+
env: dict[str, str],
|
|
596
|
+
timeout: int,
|
|
597
|
+
quiet: bool,
|
|
598
|
+
) -> None:
|
|
599
|
+
"""Run the validate or post_resolve hook phase — exactly one of per/all."""
|
|
600
|
+
per_hook = getattr(hooks, phase, None)
|
|
601
|
+
all_hook = getattr(hooks, f"{phase}_all", None)
|
|
602
|
+
|
|
603
|
+
if all_hook:
|
|
604
|
+
all_paths = " ".join(shlex.quote(str(p)) for p in staged)
|
|
605
|
+
cmd = all_hook.replace("{!conf}", all_paths) if "{!conf}" in all_hook else f"{all_hook} {all_paths}"
|
|
606
|
+
if not quiet:
|
|
607
|
+
err(f"[hook:{phase}_all] {cmd}")
|
|
608
|
+
_exec_hook(cmd, env=env, timeout=timeout, cwd=str(staged[0].parent) if staged else ".")
|
|
609
|
+
elif per_hook:
|
|
610
|
+
for item_path in staged:
|
|
611
|
+
quoted = shlex.quote(str(item_path))
|
|
612
|
+
cmd = per_hook.replace("{!conf}", quoted) if "{!conf}" in per_hook else f"{per_hook} {quoted}"
|
|
613
|
+
if not quiet:
|
|
614
|
+
err(f"[hook:{phase}] {cmd}")
|
|
615
|
+
_exec_hook(cmd, env=env, timeout=timeout, cwd=str(item_path.parent))
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _exec_hook(cmd: str, *, env: dict[str, str], timeout: int, cwd: str) -> None:
|
|
619
|
+
try:
|
|
620
|
+
proc = subprocess.Popen(
|
|
621
|
+
cmd,
|
|
622
|
+
shell=True,
|
|
623
|
+
env=env,
|
|
624
|
+
cwd=cwd,
|
|
625
|
+
stdout=subprocess.PIPE,
|
|
626
|
+
stderr=subprocess.STDOUT,
|
|
627
|
+
text=True,
|
|
628
|
+
)
|
|
629
|
+
try:
|
|
630
|
+
out, _ = proc.communicate(timeout=timeout)
|
|
631
|
+
except subprocess.TimeoutExpired:
|
|
632
|
+
proc.kill()
|
|
633
|
+
proc.communicate()
|
|
634
|
+
err(f"Hook timed out after {timeout}s: {cmd!r}")
|
|
635
|
+
raise SystemExit(HOOK_FAILURE)
|
|
636
|
+
|
|
637
|
+
prefix = "[hook] "
|
|
638
|
+
for line in out.splitlines():
|
|
639
|
+
err(f"{prefix}{line}")
|
|
640
|
+
|
|
641
|
+
if proc.returncode != 0:
|
|
642
|
+
err(f"Hook failed (exit {proc.returncode}): {cmd!r}")
|
|
643
|
+
raise SystemExit(HOOK_FAILURE)
|
|
644
|
+
|
|
645
|
+
except SystemExit:
|
|
646
|
+
raise
|
|
647
|
+
except Exception as exc:
|
|
648
|
+
err(f"Hook execution error: {exc}")
|
|
649
|
+
raise SystemExit(HOOK_FAILURE)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
from .resolve_parameters import resolve_parameters_cmd # noqa: E402
|
|
653
|
+
|
|
654
|
+
resolve_group.add_command(resolve_parameters_cmd)
|
|
655
|
+
|
|
656
|
+
from .resolve_draft import resolve_draft_cmd # noqa: E402
|
|
657
|
+
|
|
658
|
+
resolve_group.add_command(resolve_draft_cmd)
|