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
@@ -0,0 +1,200 @@
1
+ """ocmo edit — open an item in $EDITOR and update on save."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ import subprocess
8
+ import tempfile
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ import click
12
+
13
+ from .._address import parse_address_or_exit
14
+ from .._click_groups import ResourceAliasGroup
15
+ from .._client import OcmoCtx
16
+ from .._errors import sdk_command
17
+ from .._exit import USAGE_ERROR
18
+ from .._item_output import (
19
+ deleted_version_edit_error,
20
+ emit_item_result,
21
+ item_body,
22
+ item_output_format,
23
+ item_version_is_deleted,
24
+ node_type_of,
25
+ )
26
+ from .._options import namespace_option, output_option, tree_version_option
27
+ from .._output import err, status
28
+ from .._resource_aliases import RESOURCE_ALIASES
29
+
30
+ if TYPE_CHECKING:
31
+ from ocmo import NamespaceView
32
+
33
+ _EDIT_RESOURCES = ("config", "template", "secret", "resolver")
34
+
35
+ _EDIT_GROUP_HELP = """\
36
+ Open a tree item in $EDITOR and update it on save.
37
+
38
+ Fetches the current document body, opens it in $EDITOR (or $VISUAL, defaulting to vi),
39
+ then submits an update when the file changes."""
40
+
41
+
42
+ @click.group("edit", cls=ResourceAliasGroup, help=_EDIT_GROUP_HELP)
43
+ def edit_group() -> None:
44
+ """Edit tree document items interactively."""
45
+
46
+
47
+ def _build_edit_command(resource: str) -> click.Command:
48
+ output_key = f"edit {resource}"
49
+ resource_help = (
50
+ f"Edit a {resource} item. ADDRESS is the tree path, optionally with " "@version or @tag (e.g. app/web@stable)."
51
+ )
52
+
53
+ @click.command(resource, help=resource_help)
54
+ @click.argument("address")
55
+ @namespace_option()
56
+ @output_option(output_key)
57
+ @tree_version_option()
58
+ @click.pass_obj
59
+ @sdk_command
60
+ def edit_resource_cmd(
61
+ ctx: OcmoCtx,
62
+ address: str,
63
+ namespace: str | None,
64
+ output_fmt: str | None,
65
+ version_flag: str | None,
66
+ *,
67
+ _resource: str = resource,
68
+ ) -> None:
69
+ _run_edit(
70
+ ctx=ctx,
71
+ resource=_resource,
72
+ address=address,
73
+ namespace=namespace,
74
+ output_fmt=output_fmt,
75
+ version_flag=version_flag,
76
+ )
77
+
78
+ return edit_resource_cmd
79
+
80
+
81
+ for _resource in _EDIT_RESOURCES:
82
+ edit_group.add_resource_command(
83
+ _build_edit_command(_resource),
84
+ canonical=_resource,
85
+ aliases=RESOURCE_ALIASES.get(_resource, []),
86
+ )
87
+
88
+
89
+ def _run_edit(
90
+ *,
91
+ ctx: OcmoCtx,
92
+ resource: str,
93
+ address: str,
94
+ namespace: str | None,
95
+ output_fmt: str | None,
96
+ version_flag: str | None,
97
+ ) -> None:
98
+ path, version = parse_address_or_exit(address, version_flag=version_flag)
99
+ ns = ctx.require_namespace(namespace)
100
+ view = ctx.namespace_view(namespace)
101
+
102
+ if ctx.dry_run:
103
+ from .._dry_run import emit_dry_run_plan, format_edit_dry_run # deferred
104
+
105
+ emit_dry_run_plan(
106
+ format_edit_dry_run(
107
+ resource=resource,
108
+ path=path,
109
+ namespace=ns,
110
+ )
111
+ )
112
+ return
113
+
114
+ get_kwargs: dict[str, Any] = {}
115
+ if version is not None:
116
+ get_kwargs["version"] = version
117
+ if resource == "secret":
118
+ get_kwargs["reveal"] = True
119
+
120
+ item = view.get_item(path=path, **get_kwargs)
121
+ actual_type = node_type_of(item)
122
+ if actual_type and actual_type != resource:
123
+ err(f"Item {path!r} is a {actual_type}, not a {resource}.")
124
+ raise SystemExit(USAGE_ERROR)
125
+
126
+ if item_version_is_deleted(item):
127
+ err(deleted_version_edit_error(path, item))
128
+ raise SystemExit(USAGE_ERROR)
129
+
130
+ content = item_body(item, resource=resource)
131
+ editor = os.environ.get("EDITOR") or os.environ.get("VISUAL") or "vi"
132
+
133
+ if resource == "secret":
134
+ new_content = _edit_in_secure_temp(content, editor)
135
+ else:
136
+ new_content = _edit_in_temp_file(content, editor, suffix=".yaml")
137
+
138
+ if new_content == content:
139
+ print("No changes.")
140
+ return
141
+
142
+ result = _update_item(view, path, new_content, resource)
143
+ status(f"Updated {resource} {path!r}.")
144
+
145
+ fmt = item_output_format(
146
+ output_fmt,
147
+ ctx.output,
148
+ command_key=f"edit {resource}",
149
+ )
150
+ emit_item_result(result, fmt, no_color=ctx.no_color, resource=resource)
151
+
152
+
153
+ def _edit_in_temp_file(content: str, editor: str, *, suffix: str) -> str:
154
+ with tempfile.NamedTemporaryFile(mode="w", suffix=suffix, delete=False) as handle:
155
+ handle.write(content)
156
+ tmp_path = handle.name
157
+
158
+ try:
159
+ _run_editor(editor, tmp_path)
160
+ with open(tmp_path, encoding="utf-8") as handle:
161
+ return handle.read()
162
+ finally:
163
+ os.unlink(tmp_path)
164
+
165
+
166
+ def _edit_in_secure_temp(content: str, editor: str) -> str:
167
+ """Edit a secret in a secure temp directory with 0700/0600 permissions."""
168
+ tmp_dir = tempfile.mkdtemp()
169
+ os.chmod(tmp_dir, 0o700)
170
+ tmp_path = os.path.join(tmp_dir, "secret")
171
+
172
+ try:
173
+ with open(tmp_path, "w", encoding="utf-8") as handle:
174
+ os.chmod(tmp_path, 0o600)
175
+ handle.write(content)
176
+
177
+ _run_editor(editor, tmp_path)
178
+
179
+ with open(tmp_path, encoding="utf-8") as handle:
180
+ return handle.read()
181
+ finally:
182
+ if os.path.exists(tmp_path):
183
+ size = os.path.getsize(tmp_path)
184
+ with open(tmp_path, "wb") as handle:
185
+ handle.write(b"\x00" * size)
186
+ os.unlink(tmp_path)
187
+ os.rmdir(tmp_dir)
188
+
189
+
190
+ def _run_editor(editor: str, path: str) -> None:
191
+ args = shlex.split(editor) + [path]
192
+ result = subprocess.run(args)
193
+ if result.returncode != 0:
194
+ err(f"Editor exited with code {result.returncode}.")
195
+ raise SystemExit(1)
196
+
197
+
198
+ def _update_item(view: NamespaceView, path: str, content: str, resource: str) -> Any:
199
+ method = getattr(view, f"update_{resource}")
200
+ return method(path=path, content=content)
@@ -0,0 +1,242 @@
1
+ """ocmo export — write a namespace subtree to disk.
2
+
3
+ One file per item at its tree path. Secrets excluded unless --reveal-secrets.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ import click
14
+
15
+ from .._address import parse_address_or_exit
16
+ from .._client import OcmoCtx
17
+ from .._errors import sdk_command
18
+ from .._export_metadata import (
19
+ export_metadata_rows,
20
+ metadata_file_prefix,
21
+ write_export_xattrs,
22
+ )
23
+ from .._item_output import item_body
24
+ from .._options import yes_option
25
+ from .._output import as_dict, err, warn
26
+
27
+
28
+ @click.command("export")
29
+ @click.argument("address")
30
+ @click.option("--to", "dest_dir", required=True, metavar="DIR", help="Target directory for exported files.")
31
+ @click.option("-n", "--namespace", default=None)
32
+ @click.option("--version", "-V", "version_flag", default=None, help="Version / tag to export (default: latest).")
33
+ @click.option(
34
+ "--reveal-secrets", is_flag=True, default=False, help="Include secrets with decrypted values. Requires --yes."
35
+ )
36
+ @click.option("--overwrite", is_flag=True, default=False, help="Overwrite existing files.")
37
+ @click.option(
38
+ "--fail-on-missing", is_flag=True, default=False, help="Exit non-zero if any item lacks the requested version/tag."
39
+ )
40
+ @click.option(
41
+ "--metadata", is_flag=True, default=False, help="Embed item metadata in exported files and xattrs (user.ocmo.*)."
42
+ )
43
+ @click.option("--dry-run", is_flag=True, default=False)
44
+ @yes_option()
45
+ @click.pass_obj
46
+ @sdk_command
47
+ def export_cmd(
48
+ ctx: OcmoCtx,
49
+ address: str,
50
+ dest_dir: str,
51
+ namespace: str | None,
52
+ version_flag: str | None,
53
+ reveal_secrets: bool,
54
+ overwrite: bool,
55
+ fail_on_missing: bool,
56
+ metadata: bool,
57
+ dry_run: bool,
58
+ yes: bool,
59
+ ) -> None:
60
+ """Export a subtree to disk, preserving tree structure.
61
+
62
+ \b
63
+ Examples:
64
+ ocmo -n prod export app/ --to ./backup/
65
+ ocmo -n prod export app/ --to ./backup/ --version stable
66
+ ocmo -n prod export app/ --to ./backup/ --reveal-secrets --yes
67
+ """
68
+ path, version = parse_address_or_exit(address, version_flag=version_flag)
69
+
70
+ if reveal_secrets and not (yes or ctx.yes):
71
+ if sys.stdin.isatty():
72
+ warn("--reveal-secrets will export decrypted secret values to disk.")
73
+ from .._mutating import run_mutating
74
+
75
+ run_mutating(
76
+ ctx,
77
+ dry_run=False,
78
+ yes=False,
79
+ plan_lines=None,
80
+ confirm_message="Continue with revealed secrets?",
81
+ action=lambda: None,
82
+ abort_exit_code=1,
83
+ abort_via_err=True,
84
+ )
85
+ else:
86
+ err("--reveal-secrets requires --yes in non-interactive mode.")
87
+ raise SystemExit(1)
88
+
89
+ ns = ctx.require_namespace(namespace)
90
+ dest = Path(dest_dir)
91
+
92
+ if not (dry_run or ctx.dry_run):
93
+ _check_dest_permissions(dest, reveal_secrets)
94
+
95
+ view = ctx.namespace_view(namespace)
96
+ result = view.navigate_path(path=path.rstrip("/"))
97
+
98
+ data = as_dict(result)
99
+ children = data.get("children") or []
100
+
101
+ items_to_export = _collect_items(children, include_secrets=reveal_secrets)
102
+
103
+ if not items_to_export:
104
+ warn(f"No items found under {path!r}.")
105
+ return
106
+
107
+ if dry_run or ctx.dry_run:
108
+ from .._dry_run import emit_dry_run_plan, format_export_dry_run # deferred
109
+
110
+ base = path.rstrip("/")
111
+ for item in items_to_export:
112
+ item_path = item.get("path") or item.get("name", "")
113
+ rel = _relative_export_path(item_path, base)
114
+ dest_file = _safe_join(dest, rel)
115
+ emit_dry_run_plan(
116
+ format_export_dry_run(
117
+ item_path=item_path,
118
+ dest_file=str(dest_file),
119
+ namespace=ns,
120
+ )
121
+ )
122
+ return
123
+
124
+ skipped = failed = exported = 0
125
+ base = path.rstrip("/")
126
+
127
+ for item_meta in items_to_export:
128
+ item_path = item_meta.get("path") or item_meta.get("name", "")
129
+ node_type = item_meta.get("node_type") or item_meta.get("type", "config")
130
+ item_version = version or "latest"
131
+
132
+ rel = _relative_export_path(item_path, base)
133
+ dest_file = _safe_join(dest, rel)
134
+
135
+ if dest_file.exists() and not overwrite:
136
+ warn(f"Skipping {rel!r}: file exists (use --overwrite).")
137
+ skipped += 1
138
+ continue
139
+
140
+ try:
141
+ get_kwargs: dict[str, Any] = {"version": item_version}
142
+ if node_type == "secret" and reveal_secrets:
143
+ get_kwargs["reveal"] = True
144
+
145
+ raw_item = view.get_item(path=item_path, **get_kwargs)
146
+ content = item_body(raw_item)
147
+ meta_rows = export_metadata_rows(raw_item, namespace=ns) if metadata else []
148
+ if metadata:
149
+ content = metadata_file_prefix(meta_rows, node_type=node_type) + content
150
+
151
+ if not content and fail_on_missing:
152
+ err(f"Item {item_path!r} has no content at version {item_version!r}.")
153
+ raise SystemExit(1)
154
+
155
+ dest_file.parent.mkdir(parents=True, exist_ok=True)
156
+ mode = 0o600 if node_type == "secret" else 0o644
157
+ _atomic_write_text(dest_file, content, mode=mode)
158
+ if metadata:
159
+ write_export_xattrs(dest_file, meta_rows)
160
+
161
+ if sys.stderr.isatty():
162
+ err(f"Exported: {rel}")
163
+ exported += 1
164
+
165
+ except SystemExit:
166
+ raise
167
+ except Exception as exc:
168
+ if fail_on_missing and "not found" in str(exc).lower():
169
+ err(f"Item {item_path!r} not found at version {item_version!r}: {exc}")
170
+ raise SystemExit(1) from exc
171
+ warn(f"Failed to export {item_path!r}: {exc}")
172
+ failed += 1
173
+
174
+ print(f"Exported {exported} items, skipped {skipped}, failed {failed}.", file=sys.stderr)
175
+ if failed:
176
+ raise SystemExit(1)
177
+
178
+
179
+ def _relative_export_path(item_path: str, base: str) -> str:
180
+ base = base.rstrip("/")
181
+ if not base:
182
+ return item_path
183
+ if item_path == base:
184
+ return ""
185
+ prefix = f"{base}/"
186
+ if item_path.startswith(prefix):
187
+ return item_path[len(prefix) :]
188
+ return item_path
189
+
190
+
191
+ def _collect_items(children: list[Any], *, include_secrets: bool) -> list[dict[str, Any]]:
192
+ """Flatten tree children, optionally excluding secrets."""
193
+ result: list[dict[str, Any]] = []
194
+ for item in children:
195
+ node = item if isinstance(item, dict) else vars(item)
196
+ node_type = node.get("node_type") or node.get("type", "")
197
+ if node_type == "folder":
198
+ result.extend(_collect_items(node.get("children") or [], include_secrets=include_secrets))
199
+ elif node_type == "secret" and not include_secrets:
200
+ continue
201
+ else:
202
+ result.append(node)
203
+ return result
204
+
205
+
206
+ def _check_dest_permissions(dest: Path, reveal_secrets: bool) -> None:
207
+ if not dest.exists():
208
+ return
209
+ if not reveal_secrets:
210
+ return
211
+ # Refuse to write revealed secrets into group/world-readable directories
212
+ mode = dest.stat().st_mode
213
+ import stat
214
+
215
+ if mode & (stat.S_IRGRP | stat.S_IROTH):
216
+ err(
217
+ f"Refusing to write revealed secrets into {dest}: "
218
+ "directory is group- or world-readable. "
219
+ "Run: chmod 700 " + str(dest)
220
+ )
221
+ raise SystemExit(1)
222
+
223
+
224
+ def _safe_join(base: Path, name: str) -> Path:
225
+ if os.path.isabs(name):
226
+ raise ValueError(f"Absolute path in server response: {name!r}")
227
+ resolved = (base / name).resolve()
228
+ if not str(resolved).startswith(str(base.resolve())):
229
+ raise ValueError(f"Path traversal: {name!r} escapes {base}")
230
+ return base / name
231
+
232
+
233
+ def _atomic_write_text(path: Path, content: str, *, mode: int = 0o644) -> None:
234
+ tmp = Path(str(path) + ".tmp")
235
+ try:
236
+ with tmp.open("w") as f:
237
+ f.write(content)
238
+ os.chmod(tmp, mode)
239
+ tmp.replace(path)
240
+ except Exception:
241
+ tmp.unlink(missing_ok=True)
242
+ raise