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,108 @@
|
|
|
1
|
+
"""Hand-written ``ocmo move globalpermission`` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from .._address import parse_simple_address_or_exit
|
|
8
|
+
from .._client import OcmoCtx
|
|
9
|
+
from .._dry_run import format_generated_dry_run
|
|
10
|
+
from .._errors import sdk_command
|
|
11
|
+
from .._globalpermission_output import emit_get_globalpermission_output
|
|
12
|
+
from .._mutating import run_mutating
|
|
13
|
+
from .._options import dry_run_option, yes_option
|
|
14
|
+
|
|
15
|
+
_MOVE_GP_HELP = """\
|
|
16
|
+
Reorder a global permission rule.
|
|
17
|
+
|
|
18
|
+
\b
|
|
19
|
+
Examples:
|
|
20
|
+
ocmo move globalpermission dev-read --position 1.5
|
|
21
|
+
ocmo move globalpermission dev-read --position 2 --yes
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _move_gp_confirm_message(rule_id: str, position: float) -> str:
|
|
26
|
+
return f"Move global permission rule {rule_id!r} to position {position}. Continue?"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _run_move_globalpermission(
|
|
30
|
+
ctx: OcmoCtx,
|
|
31
|
+
*,
|
|
32
|
+
address: str,
|
|
33
|
+
position: float,
|
|
34
|
+
dry_run: bool,
|
|
35
|
+
yes: bool,
|
|
36
|
+
) -> None:
|
|
37
|
+
rule_id, _version = parse_simple_address_or_exit(address)
|
|
38
|
+
sdk_kwargs = {"position": position}
|
|
39
|
+
|
|
40
|
+
def _action() -> None:
|
|
41
|
+
client = ctx.client()
|
|
42
|
+
client.move_global_permission(rule_id, position=position)
|
|
43
|
+
result = client.get_global_permission(rule_id)
|
|
44
|
+
emit_get_globalpermission_output(result, ctx_fmt=ctx.output)
|
|
45
|
+
|
|
46
|
+
run_mutating(
|
|
47
|
+
ctx,
|
|
48
|
+
dry_run=dry_run,
|
|
49
|
+
yes=yes,
|
|
50
|
+
plan_lines=format_generated_dry_run(
|
|
51
|
+
op_id="move_global_permission",
|
|
52
|
+
action="move",
|
|
53
|
+
resource="globalpermission",
|
|
54
|
+
path=rule_id,
|
|
55
|
+
version=None,
|
|
56
|
+
namespace=None,
|
|
57
|
+
args=[],
|
|
58
|
+
kwargs=sdk_kwargs,
|
|
59
|
+
client_scope=True,
|
|
60
|
+
),
|
|
61
|
+
confirm_message=_move_gp_confirm_message(rule_id, position),
|
|
62
|
+
action=_action,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@click.command("globalpermission", help=_MOVE_GP_HELP)
|
|
67
|
+
@click.argument("address")
|
|
68
|
+
@click.option(
|
|
69
|
+
"--position",
|
|
70
|
+
required=True,
|
|
71
|
+
type=float,
|
|
72
|
+
help="New sort position for the rule (fractional values allowed).",
|
|
73
|
+
)
|
|
74
|
+
@yes_option()
|
|
75
|
+
@dry_run_option()
|
|
76
|
+
@click.pass_obj
|
|
77
|
+
@sdk_command
|
|
78
|
+
def move_globalpermission_cmd(
|
|
79
|
+
ctx: OcmoCtx,
|
|
80
|
+
address: str,
|
|
81
|
+
position: float,
|
|
82
|
+
dry_run: bool,
|
|
83
|
+
yes: bool,
|
|
84
|
+
) -> None:
|
|
85
|
+
_run_move_globalpermission(
|
|
86
|
+
ctx,
|
|
87
|
+
address=address,
|
|
88
|
+
position=position,
|
|
89
|
+
dry_run=dry_run,
|
|
90
|
+
yes=yes,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def register_gp_move_command(root: click.Group) -> None:
|
|
95
|
+
"""Register hand-written ``move globalpermission`` on the generated move group."""
|
|
96
|
+
from .._click_groups import attach_resource_command, ensure_resource_alias_group
|
|
97
|
+
|
|
98
|
+
move_group = ensure_resource_alias_group(
|
|
99
|
+
root,
|
|
100
|
+
"move",
|
|
101
|
+
help="Move items within the tree.",
|
|
102
|
+
)
|
|
103
|
+
attach_resource_command(
|
|
104
|
+
move_group,
|
|
105
|
+
move_globalpermission_cmd,
|
|
106
|
+
canonical="globalpermission",
|
|
107
|
+
aliases=["gp", "globalpermissions"],
|
|
108
|
+
)
|
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
"""ocmo import — import a directory tree into OCMO.
|
|
2
|
+
|
|
3
|
+
Classification:
|
|
4
|
+
parseable YAML/JSON object → Config
|
|
5
|
+
everything else → Template
|
|
6
|
+
|
|
7
|
+
With ``--from-metadata``, each file's export metadata (xattrs or comment headers)
|
|
8
|
+
selects the target namespace path and item type.
|
|
9
|
+
|
|
10
|
+
Slug rule: NFC-normalise, replace forbidden chars with '-', trim '-', preserve case.
|
|
11
|
+
The .ocmoignore file (gitignore syntax) and --exclude control what is walked.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import fnmatch
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import TYPE_CHECKING, Any
|
|
22
|
+
|
|
23
|
+
import click
|
|
24
|
+
|
|
25
|
+
from .._address import slug
|
|
26
|
+
from .._client import OcmoCtx
|
|
27
|
+
from .._errors import sdk_command
|
|
28
|
+
from .._exit import USAGE_ERROR, VERIFY_FAILURE
|
|
29
|
+
from .._import_metadata import import_file_text, read_file_metadata
|
|
30
|
+
from .._import_plan import (
|
|
31
|
+
annotate_plan_conflicts,
|
|
32
|
+
build_metadata_entry,
|
|
33
|
+
load_type_map,
|
|
34
|
+
parse_type_override,
|
|
35
|
+
resolve_import_type,
|
|
36
|
+
try_parse_config_document,
|
|
37
|
+
)
|
|
38
|
+
from .._options import dry_run_option, yes_option
|
|
39
|
+
from .._output import as_dict, confirm, emit_table, err, warn
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from ocmo import NamespaceView
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@click.command("import")
|
|
46
|
+
@click.argument("source_dir")
|
|
47
|
+
@click.option("--to", "target_path", default=None, metavar="PATH", help="Target tree path prefix to import into.")
|
|
48
|
+
@click.option("-n", "--namespace", default=None)
|
|
49
|
+
@click.option(
|
|
50
|
+
"--from-metadata",
|
|
51
|
+
is_flag=True,
|
|
52
|
+
default=False,
|
|
53
|
+
help="Import items using export metadata (namespace/path) from files.",
|
|
54
|
+
)
|
|
55
|
+
@dry_run_option()
|
|
56
|
+
@click.option("--verify", is_flag=True, default=False, help="After import, resolve and compare against source.")
|
|
57
|
+
@click.option(
|
|
58
|
+
"--update",
|
|
59
|
+
is_flag=True,
|
|
60
|
+
default=False,
|
|
61
|
+
help="Update existing items (create a new version). Otherwise, conflicts fail.",
|
|
62
|
+
)
|
|
63
|
+
@click.option(
|
|
64
|
+
"--exclude", "excludes", multiple=True, metavar="GLOB", help="Glob patterns to exclude (may be repeated)."
|
|
65
|
+
)
|
|
66
|
+
@click.option(
|
|
67
|
+
"--type-override",
|
|
68
|
+
"type_override_specs",
|
|
69
|
+
multiple=True,
|
|
70
|
+
metavar="GLOB=TYPE",
|
|
71
|
+
help="Force item type for matching paths (config|template|secret|resolver).",
|
|
72
|
+
)
|
|
73
|
+
@click.option(
|
|
74
|
+
"--type-map",
|
|
75
|
+
"type_map_path",
|
|
76
|
+
default=None,
|
|
77
|
+
type=click.Path(exists=True),
|
|
78
|
+
help="YAML file mapping glob patterns to item types.",
|
|
79
|
+
)
|
|
80
|
+
@click.option(
|
|
81
|
+
"--on-conflict", type=click.Choice(["fail", "suffix"]), default="fail", help="How to handle slug collisions."
|
|
82
|
+
)
|
|
83
|
+
@click.option(
|
|
84
|
+
"--merge-metadata", is_flag=True, default=False, help="Allow merging into files that already have the metadata key."
|
|
85
|
+
)
|
|
86
|
+
@click.option("--follow-symlinks", is_flag=True, default=False)
|
|
87
|
+
@yes_option()
|
|
88
|
+
@click.pass_obj
|
|
89
|
+
@sdk_command
|
|
90
|
+
def import_cmd(
|
|
91
|
+
ctx: OcmoCtx,
|
|
92
|
+
source_dir: str,
|
|
93
|
+
target_path: str | None,
|
|
94
|
+
namespace: str | None,
|
|
95
|
+
from_metadata: bool,
|
|
96
|
+
dry_run: bool,
|
|
97
|
+
verify: bool,
|
|
98
|
+
update: bool,
|
|
99
|
+
excludes: tuple[str, ...],
|
|
100
|
+
type_override_specs: tuple[str, ...],
|
|
101
|
+
type_map_path: str | None,
|
|
102
|
+
on_conflict: str,
|
|
103
|
+
merge_metadata: bool,
|
|
104
|
+
follow_symlinks: bool,
|
|
105
|
+
yes: bool,
|
|
106
|
+
) -> None:
|
|
107
|
+
"""Import a directory tree into OCMO.
|
|
108
|
+
|
|
109
|
+
\b
|
|
110
|
+
Examples:
|
|
111
|
+
ocmo -n prod import ./configs/ --to app/ --dry-run
|
|
112
|
+
ocmo -n prod import ./configs/ --to app/
|
|
113
|
+
ocmo -n prod import ./backup/ --from-metadata --update
|
|
114
|
+
"""
|
|
115
|
+
source = Path(source_dir).resolve()
|
|
116
|
+
if not source.is_dir():
|
|
117
|
+
err(f"Source directory not found: {source}")
|
|
118
|
+
raise SystemExit(1)
|
|
119
|
+
|
|
120
|
+
if not from_metadata and not target_path:
|
|
121
|
+
err("--to is required unless --from-metadata is set.")
|
|
122
|
+
raise SystemExit(USAGE_ERROR)
|
|
123
|
+
|
|
124
|
+
ns = ctx.require_namespace(namespace)
|
|
125
|
+
view = ctx.namespace_view(namespace)
|
|
126
|
+
metadata_key = _get_metadata_key(ctx)
|
|
127
|
+
ignore_patterns = _load_ocmoignore(source) + list(excludes)
|
|
128
|
+
type_map, type_overrides = _load_type_rules(type_map_path, type_override_specs)
|
|
129
|
+
|
|
130
|
+
plan = _build_plan(
|
|
131
|
+
source=source,
|
|
132
|
+
target_base=target_path or "",
|
|
133
|
+
ignore_patterns=ignore_patterns,
|
|
134
|
+
from_metadata=from_metadata,
|
|
135
|
+
follow_symlinks=follow_symlinks,
|
|
136
|
+
metadata_key=metadata_key,
|
|
137
|
+
merge_metadata=merge_metadata,
|
|
138
|
+
on_conflict=on_conflict,
|
|
139
|
+
namespace=ns,
|
|
140
|
+
type_map=type_map,
|
|
141
|
+
type_overrides=type_overrides,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if not plan:
|
|
145
|
+
warn("No files to import.")
|
|
146
|
+
return
|
|
147
|
+
|
|
148
|
+
conflicts = annotate_plan_conflicts(
|
|
149
|
+
plan,
|
|
150
|
+
namespace=ns,
|
|
151
|
+
update=update,
|
|
152
|
+
view=view,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
if dry_run or ctx.dry_run:
|
|
156
|
+
from .._dry_run import emit_dry_run_plan, format_import_dry_run_header # deferred
|
|
157
|
+
|
|
158
|
+
emit_dry_run_plan(format_import_dry_run_header(len(plan)))
|
|
159
|
+
emit_table(
|
|
160
|
+
[
|
|
161
|
+
{
|
|
162
|
+
"source": str(entry["source_rel"]),
|
|
163
|
+
"type": entry["kind"],
|
|
164
|
+
"path": entry["tree_path"],
|
|
165
|
+
"action": entry.get("action", "create"),
|
|
166
|
+
"status": entry.get("status", "ok"),
|
|
167
|
+
}
|
|
168
|
+
for entry in plan
|
|
169
|
+
],
|
|
170
|
+
columns=["source", "type", "path", "action", "status"],
|
|
171
|
+
)
|
|
172
|
+
if conflicts:
|
|
173
|
+
err("Import blocked by conflicts:")
|
|
174
|
+
for message in conflicts:
|
|
175
|
+
err(f" {message}")
|
|
176
|
+
raise SystemExit(USAGE_ERROR)
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
created = updated = skipped = failed = 0
|
|
180
|
+
|
|
181
|
+
for entry in plan:
|
|
182
|
+
if entry.get("status") == "conflict":
|
|
183
|
+
warn(f"Skipping {entry['source_rel']}: {entry.get('conflict')}")
|
|
184
|
+
skipped += 1
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
kind = entry["kind"]
|
|
188
|
+
tree_path = entry["tree_path"]
|
|
189
|
+
content: str = entry["content"]
|
|
190
|
+
|
|
191
|
+
if not (yes or ctx.yes) and kind == "secret":
|
|
192
|
+
if not confirm(f"Import secret {tree_path!r}?", yes=False):
|
|
193
|
+
skipped += 1
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
if kind == "config":
|
|
198
|
+
_import_item(view, "config", tree_path, content, update=update)
|
|
199
|
+
elif kind == "template":
|
|
200
|
+
_import_item(view, "template", tree_path, content, update=update)
|
|
201
|
+
elif kind == "secret":
|
|
202
|
+
_import_item(view, "secret", tree_path, content, update=update)
|
|
203
|
+
elif kind == "resolver":
|
|
204
|
+
_import_item(view, "resolver", tree_path, content, update=update)
|
|
205
|
+
else:
|
|
206
|
+
warn(f"Skipping unsupported type {kind!r} at {tree_path!r}.")
|
|
207
|
+
skipped += 1
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
created += 1
|
|
211
|
+
except Exception as exc:
|
|
212
|
+
msg = str(exc)
|
|
213
|
+
if "already exists" in msg.lower() or "conflict" in msg.lower():
|
|
214
|
+
if update:
|
|
215
|
+
updated += 1
|
|
216
|
+
else:
|
|
217
|
+
warn(f"Conflict at {tree_path!r}: {exc} (use --update to overwrite)")
|
|
218
|
+
skipped += 1
|
|
219
|
+
else:
|
|
220
|
+
warn(f"Failed {tree_path!r}: {exc}")
|
|
221
|
+
failed += 1
|
|
222
|
+
|
|
223
|
+
print(
|
|
224
|
+
f"Import complete: created={created}, updated={updated}, " f"skipped={skipped}, failed={failed}.",
|
|
225
|
+
file=sys.stderr,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
if verify and not failed:
|
|
229
|
+
_run_verify(
|
|
230
|
+
view,
|
|
231
|
+
source,
|
|
232
|
+
target_path or "",
|
|
233
|
+
plan,
|
|
234
|
+
metadata_key=metadata_key,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
# Plan building
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _build_plan(
|
|
244
|
+
*,
|
|
245
|
+
source: Path,
|
|
246
|
+
target_base: str,
|
|
247
|
+
ignore_patterns: list[str],
|
|
248
|
+
from_metadata: bool,
|
|
249
|
+
follow_symlinks: bool,
|
|
250
|
+
metadata_key: str,
|
|
251
|
+
merge_metadata: bool,
|
|
252
|
+
on_conflict: str,
|
|
253
|
+
namespace: str,
|
|
254
|
+
type_map: list[tuple[str, str]],
|
|
255
|
+
type_overrides: list[tuple[str, str]],
|
|
256
|
+
) -> list[dict[str, Any]]:
|
|
257
|
+
plan: list[dict[str, Any]] = []
|
|
258
|
+
slug_set: set[str] = set()
|
|
259
|
+
|
|
260
|
+
for file_path in _walk(source, ignore_patterns=ignore_patterns, follow_symlinks=follow_symlinks):
|
|
261
|
+
rel = file_path.relative_to(source)
|
|
262
|
+
rel_str = str(rel)
|
|
263
|
+
content_bytes = file_path.read_bytes()
|
|
264
|
+
|
|
265
|
+
if from_metadata:
|
|
266
|
+
if not read_file_metadata(file_path).get("path"):
|
|
267
|
+
continue
|
|
268
|
+
entry = build_metadata_entry(
|
|
269
|
+
file_path=file_path,
|
|
270
|
+
rel=rel,
|
|
271
|
+
rel_str=rel_str,
|
|
272
|
+
target_prefix=target_base,
|
|
273
|
+
content_bytes=content_bytes,
|
|
274
|
+
type_map=type_map,
|
|
275
|
+
type_overrides=type_overrides,
|
|
276
|
+
)
|
|
277
|
+
if entry is None:
|
|
278
|
+
continue
|
|
279
|
+
plan.append(entry)
|
|
280
|
+
continue
|
|
281
|
+
|
|
282
|
+
kind = resolve_import_type(
|
|
283
|
+
rel_str,
|
|
284
|
+
type_map=type_map,
|
|
285
|
+
type_overrides=type_overrides,
|
|
286
|
+
metadata_type=None,
|
|
287
|
+
content_bytes=content_bytes,
|
|
288
|
+
)
|
|
289
|
+
tree_path = _build_tree_path(rel, target_base)
|
|
290
|
+
|
|
291
|
+
slug_key = tree_path.lower()
|
|
292
|
+
if slug_key in slug_set:
|
|
293
|
+
if on_conflict == "fail":
|
|
294
|
+
err(f"Slug collision: {rel_str!r} → {tree_path!r} already mapped.")
|
|
295
|
+
raise SystemExit(USAGE_ERROR)
|
|
296
|
+
tree_path = _add_suffix(tree_path)
|
|
297
|
+
slug_set.add(slug_key)
|
|
298
|
+
|
|
299
|
+
if kind == "config":
|
|
300
|
+
content, conflict = _prepare_config_content(
|
|
301
|
+
content_bytes=content_bytes,
|
|
302
|
+
rel_str=rel_str,
|
|
303
|
+
tree_path=tree_path,
|
|
304
|
+
metadata_key=metadata_key,
|
|
305
|
+
merge_metadata=merge_metadata,
|
|
306
|
+
)
|
|
307
|
+
entry = {
|
|
308
|
+
"kind": "config",
|
|
309
|
+
"source_rel": rel,
|
|
310
|
+
"source_abs": file_path,
|
|
311
|
+
"tree_path": tree_path,
|
|
312
|
+
"content": content,
|
|
313
|
+
}
|
|
314
|
+
if conflict:
|
|
315
|
+
entry["conflict_reason"] = conflict
|
|
316
|
+
plan.append(entry)
|
|
317
|
+
elif kind in ("secret", "resolver"):
|
|
318
|
+
plan.append(
|
|
319
|
+
{
|
|
320
|
+
"kind": kind,
|
|
321
|
+
"source_rel": rel,
|
|
322
|
+
"source_abs": file_path,
|
|
323
|
+
"tree_path": tree_path,
|
|
324
|
+
"content": import_file_text(content_bytes),
|
|
325
|
+
}
|
|
326
|
+
)
|
|
327
|
+
elif kind == "template":
|
|
328
|
+
plan.append(
|
|
329
|
+
{
|
|
330
|
+
"kind": "template",
|
|
331
|
+
"source_rel": rel,
|
|
332
|
+
"source_abs": file_path,
|
|
333
|
+
"tree_path": tree_path,
|
|
334
|
+
"content": import_file_text(content_bytes),
|
|
335
|
+
}
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
return plan
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _load_type_rules(
|
|
342
|
+
type_map_path: str | None,
|
|
343
|
+
type_override_specs: tuple[str, ...],
|
|
344
|
+
) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]:
|
|
345
|
+
type_map: list[tuple[str, str]] = []
|
|
346
|
+
if type_map_path:
|
|
347
|
+
try:
|
|
348
|
+
type_map = load_type_map(Path(type_map_path))
|
|
349
|
+
except ValueError as exc:
|
|
350
|
+
err(str(exc))
|
|
351
|
+
raise SystemExit(USAGE_ERROR)
|
|
352
|
+
|
|
353
|
+
type_overrides: list[tuple[str, str]] = []
|
|
354
|
+
for spec in type_override_specs:
|
|
355
|
+
try:
|
|
356
|
+
type_overrides.append(parse_type_override(spec))
|
|
357
|
+
except ValueError as exc:
|
|
358
|
+
err(str(exc))
|
|
359
|
+
raise SystemExit(USAGE_ERROR)
|
|
360
|
+
|
|
361
|
+
return type_map, type_overrides
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _build_tree_path(rel: Path, target_base: str) -> str:
|
|
365
|
+
base = target_base.rstrip("/")
|
|
366
|
+
parts = [slug(p) for p in rel.parts]
|
|
367
|
+
return base + "/" + "/".join(parts)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _prepare_config_content(
|
|
371
|
+
*,
|
|
372
|
+
content_bytes: bytes,
|
|
373
|
+
rel_str: str,
|
|
374
|
+
tree_path: str,
|
|
375
|
+
metadata_key: str,
|
|
376
|
+
merge_metadata: bool,
|
|
377
|
+
) -> tuple[str, str | None]:
|
|
378
|
+
import yaml # deferred
|
|
379
|
+
|
|
380
|
+
text = import_file_text(content_bytes)
|
|
381
|
+
doc, is_json = try_parse_config_document(text.encode("utf-8"))
|
|
382
|
+
if doc is None:
|
|
383
|
+
return text, None
|
|
384
|
+
|
|
385
|
+
if metadata_key in doc and not merge_metadata:
|
|
386
|
+
return "", (f"file already contains key {metadata_key!r} " "(use --merge-metadata to override)")
|
|
387
|
+
|
|
388
|
+
meta: dict[str, Any] = {}
|
|
389
|
+
slugged = tree_path.split("/")[-1]
|
|
390
|
+
if slugged != rel_str:
|
|
391
|
+
meta["name"] = rel_str
|
|
392
|
+
if is_json:
|
|
393
|
+
meta["cast"] = {"format": "json"}
|
|
394
|
+
|
|
395
|
+
if meta:
|
|
396
|
+
existing = doc.get(metadata_key, {})
|
|
397
|
+
if not isinstance(existing, dict):
|
|
398
|
+
existing = {}
|
|
399
|
+
doc[metadata_key] = {**existing, **meta}
|
|
400
|
+
|
|
401
|
+
if is_json:
|
|
402
|
+
return json.dumps(doc, indent=2), None
|
|
403
|
+
return yaml.safe_dump(doc, default_flow_style=False), None
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _add_suffix(path: str) -> str:
|
|
407
|
+
parts = path.rsplit("/", 1)
|
|
408
|
+
return parts[0] + "/" + parts[1] + "_1" if len(parts) == 2 else path + "_1"
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
# ---------------------------------------------------------------------------
|
|
412
|
+
# Import helpers
|
|
413
|
+
# ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _get_metadata_key(ctx: OcmoCtx) -> str:
|
|
417
|
+
try:
|
|
418
|
+
info = ctx.client().version() # type: ignore[no-untyped-call]
|
|
419
|
+
data = as_dict(info)
|
|
420
|
+
return data.get("config_metadata_key") or "_ocmo"
|
|
421
|
+
except Exception:
|
|
422
|
+
return "_ocmo"
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _import_item(
|
|
426
|
+
view: NamespaceView,
|
|
427
|
+
kind: str,
|
|
428
|
+
path: str,
|
|
429
|
+
content: str,
|
|
430
|
+
*,
|
|
431
|
+
update: bool,
|
|
432
|
+
) -> None:
|
|
433
|
+
methods = {
|
|
434
|
+
"config": (view.update_config, view.create_config),
|
|
435
|
+
"template": (view.update_template, view.create_template),
|
|
436
|
+
"secret": (view.update_secret, view.create_secret),
|
|
437
|
+
"resolver": (view.update_resolver, view.create_resolver),
|
|
438
|
+
}
|
|
439
|
+
update_fn, create_fn = methods[kind]
|
|
440
|
+
try:
|
|
441
|
+
update_fn(path=path, content=content)
|
|
442
|
+
except Exception:
|
|
443
|
+
create_fn(path=path, content=content)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# ---------------------------------------------------------------------------
|
|
447
|
+
# Filesystem walk
|
|
448
|
+
# ---------------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _walk(
|
|
452
|
+
root: Path,
|
|
453
|
+
*,
|
|
454
|
+
ignore_patterns: list[str],
|
|
455
|
+
follow_symlinks: bool,
|
|
456
|
+
) -> list[Path]:
|
|
457
|
+
results: list[Path] = []
|
|
458
|
+
for dirpath, dirnames, filenames in os.walk(root, followlinks=follow_symlinks):
|
|
459
|
+
dp = Path(dirpath)
|
|
460
|
+
rel_dir = dp.relative_to(root)
|
|
461
|
+
|
|
462
|
+
dirnames[:] = [d for d in dirnames if d != ".git"]
|
|
463
|
+
|
|
464
|
+
for filename in sorted(filenames):
|
|
465
|
+
rel_file = rel_dir / filename
|
|
466
|
+
rel_str = str(rel_file)
|
|
467
|
+
|
|
468
|
+
full = dp / filename
|
|
469
|
+
if full.is_symlink() and not follow_symlinks:
|
|
470
|
+
warn(f"Skipping symlink: {rel_str}")
|
|
471
|
+
continue
|
|
472
|
+
|
|
473
|
+
if _is_ignored(rel_str, ignore_patterns):
|
|
474
|
+
continue
|
|
475
|
+
|
|
476
|
+
results.append(full)
|
|
477
|
+
|
|
478
|
+
return results
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _is_ignored(rel_str: str, patterns: list[str]) -> bool:
|
|
482
|
+
for pat in patterns:
|
|
483
|
+
if fnmatch.fnmatch(rel_str, pat) or fnmatch.fnmatch(Path(rel_str).name, pat):
|
|
484
|
+
return True
|
|
485
|
+
return False
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _load_ocmoignore(root: Path) -> list[str]:
|
|
489
|
+
ignore_file = root / ".ocmoignore"
|
|
490
|
+
if not ignore_file.exists():
|
|
491
|
+
return []
|
|
492
|
+
patterns: list[str] = []
|
|
493
|
+
for line in ignore_file.read_text().splitlines():
|
|
494
|
+
line = line.strip()
|
|
495
|
+
if line and not line.startswith("#"):
|
|
496
|
+
patterns.append(line)
|
|
497
|
+
return patterns
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
# ---------------------------------------------------------------------------
|
|
501
|
+
# Verification (§12.3)
|
|
502
|
+
# ---------------------------------------------------------------------------
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _run_verify(
|
|
506
|
+
view: NamespaceView,
|
|
507
|
+
source: Path,
|
|
508
|
+
target_path: str,
|
|
509
|
+
plan: list[dict[str, Any]],
|
|
510
|
+
*,
|
|
511
|
+
metadata_key: str,
|
|
512
|
+
) -> None:
|
|
513
|
+
"""Resolve the imported folder and compare against the source directory."""
|
|
514
|
+
import tempfile
|
|
515
|
+
|
|
516
|
+
from .._resolve_write import write_resolve_items
|
|
517
|
+
|
|
518
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
519
|
+
tmp = Path(tmp_dir)
|
|
520
|
+
|
|
521
|
+
try:
|
|
522
|
+
result = view.resolve(target_path.rstrip("/") + "/")
|
|
523
|
+
items = list(result)
|
|
524
|
+
outcomes = write_resolve_items(
|
|
525
|
+
items, # type: ignore[arg-type] # SDK ResolvedItem satisfies ResolvedArtifact
|
|
526
|
+
output_dir=str(tmp),
|
|
527
|
+
rewrite=True,
|
|
528
|
+
)
|
|
529
|
+
except Exception as exc:
|
|
530
|
+
err(f"Verify: resolve failed: {exc}")
|
|
531
|
+
raise SystemExit(VERIFY_FAILURE)
|
|
532
|
+
|
|
533
|
+
write_failures = [o for o in outcomes if o.result == "failed"]
|
|
534
|
+
if write_failures:
|
|
535
|
+
err("Verify: failed to write resolved output:")
|
|
536
|
+
for outcome in write_failures:
|
|
537
|
+
detail = outcome.detail or "write failed"
|
|
538
|
+
err(f" {outcome.path}: {detail}")
|
|
539
|
+
raise SystemExit(VERIFY_FAILURE)
|
|
540
|
+
|
|
541
|
+
mismatches: list[str] = []
|
|
542
|
+
for entry in plan:
|
|
543
|
+
if entry["kind"] != "config":
|
|
544
|
+
continue
|
|
545
|
+
rel = _expected_verify_rel(entry, target_path)
|
|
546
|
+
src_text = import_file_text(entry["source_abs"].read_bytes())
|
|
547
|
+
resolved_file = tmp / rel
|
|
548
|
+
if not resolved_file.exists():
|
|
549
|
+
mismatches.append(f"MISSING: {rel}")
|
|
550
|
+
continue
|
|
551
|
+
try:
|
|
552
|
+
src_doc, _ = try_parse_config_document(src_text.encode("utf-8"))
|
|
553
|
+
res_doc, _ = try_parse_config_document(
|
|
554
|
+
resolved_file.read_bytes(),
|
|
555
|
+
)
|
|
556
|
+
if src_doc is None or res_doc is None:
|
|
557
|
+
if resolved_file.read_text(encoding="utf-8") != src_text:
|
|
558
|
+
mismatches.append(f"BYTE MISMATCH: {rel}")
|
|
559
|
+
continue
|
|
560
|
+
src_body = _config_document_body(src_doc, metadata_key)
|
|
561
|
+
res_body = _config_document_body(res_doc, metadata_key)
|
|
562
|
+
if src_body != res_body:
|
|
563
|
+
mismatches.append(f"SEMANTIC MISMATCH: {rel}")
|
|
564
|
+
except Exception as e:
|
|
565
|
+
mismatches.append(f"PARSE ERROR: {rel}: {e}")
|
|
566
|
+
|
|
567
|
+
if mismatches:
|
|
568
|
+
err("Verification failed:")
|
|
569
|
+
for m in mismatches:
|
|
570
|
+
err(f" {m}")
|
|
571
|
+
raise SystemExit(VERIFY_FAILURE)
|
|
572
|
+
else:
|
|
573
|
+
print("Verification passed.", file=sys.stderr)
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _expected_verify_rel(entry: dict[str, Any], target_path: str) -> str:
|
|
577
|
+
"""Path of a resolved artifact relative to the verify output directory."""
|
|
578
|
+
tree_path = str(entry["tree_path"]).strip("/")
|
|
579
|
+
base = target_path.strip("/")
|
|
580
|
+
if base and (tree_path == base or tree_path.startswith(f"{base}/")):
|
|
581
|
+
if tree_path == base:
|
|
582
|
+
return Path(tree_path).name
|
|
583
|
+
return str(tree_path[len(base) + 1 :])
|
|
584
|
+
return str(entry["source_rel"])
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _config_document_body(
|
|
588
|
+
doc: dict[str, Any] | None,
|
|
589
|
+
metadata_key: str,
|
|
590
|
+
) -> dict[str, Any] | None:
|
|
591
|
+
if doc is None:
|
|
592
|
+
return None
|
|
593
|
+
body = dict(doc)
|
|
594
|
+
body.pop(metadata_key, None)
|
|
595
|
+
return body
|