sanka-cli 0.2.3__tar.gz → 0.2.4__tar.gz
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.
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/PKG-INFO +20 -1
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/README.md +19 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/pyproject.toml +1 -1
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/cli/__init__.py +44 -23
- sanka_cli-0.2.4/src/sanka/cli/_compact.py +50 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/extensions/lifecycle.py +1 -2
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/__init__.py +1 -1
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/client.py +28 -1
- sanka_cli-0.2.4/src/sanka_cli/commands/cloud.py +240 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/main.py +2 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/runtime.py +8 -1
- sanka_cli-0.2.4/src/sanka_cli/skills/sanka-cli/SKILL.md +48 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_cli.py +1 -1
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_cli_developer_experience.py +66 -1
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_extension_lifecycle.py +1 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_mcp.py +2 -2
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_sanka_facade.py +1 -1
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_skill_commands.py +7 -1
- sanka_cli-0.2.3/src/sanka_cli/skills/sanka-cli/SKILL.md +0 -62
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/.gitignore +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/LICENSES/AGPL-3.0-only.txt +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/LICENSES/Apache-2.0.txt +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/NOTICE +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/__init__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/_client.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/cli/_output.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/cli/_research.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/cli/py.typed +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/connector/__init__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/connector/py.typed +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/__about__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/__init__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/concurrency.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/connector_client.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/connector_host.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/engine.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/execution/__init__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/execution/continuous.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/execution/errors.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/execution/local.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/execution/model.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/execution/report_codec.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/execution/routes.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/execution/scope.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/execution/state.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/execution/validate.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/extensions/__init__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/extensions/discovery.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/extensions/model.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/extensions/runner.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/extensions/store.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/hashing.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/mapping/__init__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/mapping/errors.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/mapping/mapping_candidates.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/mapping/model.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/mapping/owner_mapping.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/mapping/pending_relationships.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/mapping/record_mapping.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/planner.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/private_sqlite.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/py.typed +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/registry.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/safe_local_io.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/spec.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka/runtime/state.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/__main__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/bundle.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/commands/__init__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/commands/ai.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/commands/auth.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/commands/cloud_migrate.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/commands/code.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/commands/migrate.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/commands/profiles.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/commands/resources.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/commands/skill.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/commands/workflows.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/config.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/mcp/__init__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/mcp/client.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/mcp/render.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/mcp/server.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/output.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_cli/state.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_connector/__about__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_connector/__init__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_connector/credentials.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_connector/errors.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_connector/protocols.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_connector/provisioning.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_connector/py.typed +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_connector/records.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_connector/registration.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/src/sanka_connector/schema.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/conftest.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/fixtures/extension_drf_project/api/__init__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/fixtures/extension_drf_project/api/serializers.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/fixtures/extension_drf_project/api/views.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/fixtures/extension_drf_project/config/__init__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/fixtures/extension_drf_project/config/settings.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/fixtures/extension_drf_project/config/urls.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/fixtures/extension_drf_project/manage.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/fixtures/extension_marketplace/drf-to-fastapi.json +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/fixtures/extension_marketplace/marketplace.json +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_code_bundle.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_code_commands.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_concurrency.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_connector_client.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_connector_host.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_credential_provider.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_engine_e2e.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_engine_execution_adoption.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_engine_pg_to_clickhouse_e2e.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_engine_postgres_e2e.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_execution_continuous.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_execution_exact_scope.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_execution_local_store.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_execution_report_codec.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_execution_routes.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_execution_scope.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_execution_seam.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_execution_validate.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_extension_discovery.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_extension_runner.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_extension_store.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_extension_wheel_acceptance.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_hashing.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_hosted_cli.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_mapping_candidates.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_mapping_owner_mapping.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_mapping_pending_relationships.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_mapping_record_mapping.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_planner.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_registry.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_safe_local_io.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_spec.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.4}/tests/test_state_store.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: sanka-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Sanka command-line interface and migration runtime
|
|
5
5
|
Project-URL: Repository, https://github.com/sankaHQ/sanka
|
|
6
6
|
Project-URL: Issues, https://github.com/sankaHQ/sanka/issues
|
|
@@ -149,3 +149,22 @@ The distribution license expression is
|
|
|
149
149
|
`Apache-2.0 AND AGPL-3.0-only`. Both license texts and the historical hosted
|
|
150
150
|
CLI notice ship in the wheel and source archive. File-level SPDX identifiers
|
|
151
151
|
are authoritative.
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
### Compact lifecycle output
|
|
155
|
+
|
|
156
|
+
Use `--compact-dsl` instead of `--json` on `scan`, `plan`, `apply`, `test`,
|
|
157
|
+
`verify`, and extension management commands when an agent needs a short result.
|
|
158
|
+
The flags are mutually exclusive; `--json` retains its existing contract.
|
|
159
|
+
Compact output starts with `sanka-compact/v1 <command> <outcome> <migration_state>`
|
|
160
|
+
then emits one `key=JSON-value` per line. Strings use JSON escaping, including
|
|
161
|
+
newlines and quotes. Failures, warnings, plan hashes, and limitations are retained.
|
|
162
|
+
Legacy aliases are not repeated. With full artifacts available, generated file
|
|
163
|
+
contents and route source code are replaced by explicit omission counts and file
|
|
164
|
+
names. Read the returned artifact for those details; a compact summary is not a
|
|
165
|
+
replacement for the full report. `data.plan_hash` becomes the `plan_hash` line;
|
|
166
|
+
use that core hash for apply, not a nested extension hash.
|
|
167
|
+
|
|
168
|
+
Keep the lifecycle: scan → plan → apply → test → verify. An applied scaffold with
|
|
169
|
+
manual gaps is not complete. Test establishes generated scope; scenario verification
|
|
170
|
+
checks HTTP and database parity within the supplied fixtures and cases.
|
|
@@ -119,3 +119,22 @@ The distribution license expression is
|
|
|
119
119
|
`Apache-2.0 AND AGPL-3.0-only`. Both license texts and the historical hosted
|
|
120
120
|
CLI notice ship in the wheel and source archive. File-level SPDX identifiers
|
|
121
121
|
are authoritative.
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
### Compact lifecycle output
|
|
125
|
+
|
|
126
|
+
Use `--compact-dsl` instead of `--json` on `scan`, `plan`, `apply`, `test`,
|
|
127
|
+
`verify`, and extension management commands when an agent needs a short result.
|
|
128
|
+
The flags are mutually exclusive; `--json` retains its existing contract.
|
|
129
|
+
Compact output starts with `sanka-compact/v1 <command> <outcome> <migration_state>`
|
|
130
|
+
then emits one `key=JSON-value` per line. Strings use JSON escaping, including
|
|
131
|
+
newlines and quotes. Failures, warnings, plan hashes, and limitations are retained.
|
|
132
|
+
Legacy aliases are not repeated. With full artifacts available, generated file
|
|
133
|
+
contents and route source code are replaced by explicit omission counts and file
|
|
134
|
+
names. Read the returned artifact for those details; a compact summary is not a
|
|
135
|
+
replacement for the full report. `data.plan_hash` becomes the `plan_hash` line;
|
|
136
|
+
use that core hash for apply, not a nested extension hash.
|
|
137
|
+
|
|
138
|
+
Keep the lifecycle: scan → plan → apply → test → verify. An applied scaffold with
|
|
139
|
+
manual gaps is not complete. Test establishes generated scope; scenario verification
|
|
140
|
+
checks HTTP and database parity within the supplied fixtures and cases.
|
|
@@ -85,17 +85,21 @@ class _CliArgumentParser(argparse.ArgumentParser):
|
|
|
85
85
|
|
|
86
86
|
def main(argv: list[str] | None = None) -> int:
|
|
87
87
|
arguments = list(sys.argv[1:] if argv is None else argv)
|
|
88
|
-
json_errors = "--json" in arguments
|
|
88
|
+
json_errors = "--json" in arguments or "--compact-dsl" in arguments
|
|
89
89
|
parser = _build_parser(json_errors=json_errors)
|
|
90
90
|
try:
|
|
91
91
|
args = parser.parse_args(arguments)
|
|
92
92
|
except CliUsageError as error:
|
|
93
93
|
command = arguments[0] if arguments and arguments[0] in SDK_COMMANDS else "sanka"
|
|
94
94
|
return _print_cli_error(
|
|
95
|
-
argparse.Namespace(
|
|
95
|
+
argparse.Namespace(
|
|
96
|
+
command=command, json=json_errors, compact_dsl="--compact-dsl" in arguments
|
|
97
|
+
),
|
|
96
98
|
error,
|
|
97
99
|
exit_code=2,
|
|
98
100
|
)
|
|
101
|
+
if getattr(args, "compact_dsl", False):
|
|
102
|
+
args.json = True
|
|
99
103
|
if args.command is None:
|
|
100
104
|
parser.print_help()
|
|
101
105
|
return 0
|
|
@@ -173,12 +177,17 @@ def _json_result(
|
|
|
173
177
|
return envelope
|
|
174
178
|
|
|
175
179
|
|
|
176
|
-
def
|
|
177
|
-
|
|
180
|
+
def _print_result(args: argparse.Namespace, payload: dict[str, Any]) -> None:
|
|
181
|
+
if getattr(args, "compact_dsl", False):
|
|
182
|
+
from sanka.cli._compact import render_compact
|
|
183
|
+
|
|
184
|
+
print(render_compact(payload))
|
|
185
|
+
else:
|
|
186
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True))
|
|
178
187
|
|
|
179
188
|
|
|
180
189
|
def _print_cli_error(args: argparse.Namespace, error: Exception, *, exit_code: int) -> int:
|
|
181
|
-
if getattr(args, "json", False):
|
|
190
|
+
if getattr(args, "json", False) or getattr(args, "compact_dsl", False):
|
|
182
191
|
code = getattr(error, "code", None)
|
|
183
192
|
details = getattr(error, "details", None)
|
|
184
193
|
structured_error: dict[str, Any] = {
|
|
@@ -187,13 +196,14 @@ def _print_cli_error(args: argparse.Namespace, error: Exception, *, exit_code: i
|
|
|
187
196
|
}
|
|
188
197
|
if isinstance(details, dict) and details:
|
|
189
198
|
structured_error["details"] = details
|
|
190
|
-
|
|
199
|
+
_print_result(
|
|
200
|
+
args,
|
|
191
201
|
_json_result(
|
|
192
202
|
str(getattr(args, "command", "sanka") or "sanka"),
|
|
193
203
|
{"error": structured_error},
|
|
194
204
|
outcome="error",
|
|
195
205
|
migration_state="not_started" if exit_code == 2 else "failed",
|
|
196
|
-
)
|
|
206
|
+
),
|
|
197
207
|
)
|
|
198
208
|
else:
|
|
199
209
|
_terminal(args).failure(str(error))
|
|
@@ -221,9 +231,15 @@ def _build_parser(*, json_errors: bool = False) -> argparse.ArgumentParser:
|
|
|
221
231
|
|
|
222
232
|
def presentation(sub: argparse.ArgumentParser, *, json_option: bool = True) -> None:
|
|
223
233
|
if json_option:
|
|
224
|
-
sub.
|
|
234
|
+
formats = sub.add_mutually_exclusive_group()
|
|
235
|
+
formats.add_argument(
|
|
225
236
|
"--json", action="store_true", help="print one sanka-cli/v1 JSON document"
|
|
226
237
|
)
|
|
238
|
+
formats.add_argument(
|
|
239
|
+
"--compact-dsl",
|
|
240
|
+
action="store_true",
|
|
241
|
+
help="print a compact result; full generated code stays in artifacts",
|
|
242
|
+
)
|
|
227
243
|
sub.add_argument("--no-color", action="store_true", help="disable ANSI color")
|
|
228
244
|
detail = sub.add_mutually_exclusive_group()
|
|
229
245
|
detail.add_argument("--quiet", action="store_true", help="print only outcome and errors")
|
|
@@ -414,7 +430,7 @@ def _build_parser(*, json_errors: bool = False) -> argparse.ArgumentParser:
|
|
|
414
430
|
description=(
|
|
415
431
|
"Check hashes, generated files, route coverage, and configured read-only HTTP "
|
|
416
432
|
"comparisons. With --scenarios, replay a scenario file against the source "
|
|
417
|
-
"application and a candidate FastAPI app and diff status, body, declared "
|
|
433
|
+
"application and a candidate FastAPI or Flask app and diff status, body, declared "
|
|
418
434
|
"headers, and database state; that mode needs no plan or generated manifest."
|
|
419
435
|
),
|
|
420
436
|
epilog=(
|
|
@@ -436,6 +452,7 @@ def _build_parser(*, json_errors: bool = False) -> argparse.ArgumentParser:
|
|
|
436
452
|
help="application repository root",
|
|
437
453
|
)
|
|
438
454
|
verify.add_argument("--to", help="select an application plan")
|
|
455
|
+
verify.add_argument("--settings", help="source Django settings module for scenario replay")
|
|
439
456
|
verify.add_argument("--artifact-dir", default=DEFAULT_ARTIFACT_DIR)
|
|
440
457
|
verify.add_argument("--output", default=None, help="generated FastAPI output directory")
|
|
441
458
|
verify.add_argument(
|
|
@@ -667,8 +684,8 @@ def _extension_result(
|
|
|
667
684
|
records: list[dict[str, Any]],
|
|
668
685
|
) -> int:
|
|
669
686
|
data = {"operation": operation, "records": records}
|
|
670
|
-
if args.json:
|
|
671
|
-
|
|
687
|
+
if args.json or getattr(args, "compact_dsl", False):
|
|
688
|
+
_print_result(args, _json_result("extension", data, migration_state="not_started"))
|
|
672
689
|
return 0
|
|
673
690
|
|
|
674
691
|
terminal = _terminal(args)
|
|
@@ -895,8 +912,9 @@ def _print_application_result(
|
|
|
895
912
|
*,
|
|
896
913
|
migration_state: str,
|
|
897
914
|
) -> int:
|
|
898
|
-
if args.json:
|
|
899
|
-
|
|
915
|
+
if args.json or getattr(args, "compact_dsl", False):
|
|
916
|
+
_print_result(
|
|
917
|
+
args,
|
|
900
918
|
_json_result(
|
|
901
919
|
command,
|
|
902
920
|
result.data,
|
|
@@ -904,7 +922,7 @@ def _print_application_result(
|
|
|
904
922
|
artifacts=list(result.artifacts),
|
|
905
923
|
limitations=list(result.limitations),
|
|
906
924
|
next_actions=list(result.next_actions),
|
|
907
|
-
)
|
|
925
|
+
),
|
|
908
926
|
)
|
|
909
927
|
else:
|
|
910
928
|
terminal = _terminal(args)
|
|
@@ -926,8 +944,9 @@ async def _cmd_plan(args: argparse.Namespace) -> int:
|
|
|
926
944
|
engine = _engine(args.state)
|
|
927
945
|
run_id = engine.create(spec)
|
|
928
946
|
migration_plan = await engine.plan(run_id)
|
|
929
|
-
if args.json:
|
|
930
|
-
|
|
947
|
+
if args.json or getattr(args, "compact_dsl", False):
|
|
948
|
+
_print_result(
|
|
949
|
+
args,
|
|
931
950
|
_json_result(
|
|
932
951
|
"plan",
|
|
933
952
|
{
|
|
@@ -953,7 +972,7 @@ async def _cmd_plan(args: argparse.Namespace) -> int:
|
|
|
953
972
|
]
|
|
954
973
|
)
|
|
955
974
|
],
|
|
956
|
-
)
|
|
975
|
+
),
|
|
957
976
|
)
|
|
958
977
|
else:
|
|
959
978
|
_print_plan(run_id, migration_plan)
|
|
@@ -995,8 +1014,9 @@ async def _cmd_apply(args: argparse.Namespace) -> int:
|
|
|
995
1014
|
if run.plan_json is None:
|
|
996
1015
|
raise ExecutionError("no plan for this spec yet; run `sanka plan` first")
|
|
997
1016
|
await engine.apply(run_id, plan_hash=args.plan_hash)
|
|
998
|
-
if args.json:
|
|
999
|
-
|
|
1017
|
+
if args.json or getattr(args, "compact_dsl", False):
|
|
1018
|
+
_print_result(
|
|
1019
|
+
args,
|
|
1000
1020
|
_json_result(
|
|
1001
1021
|
"apply",
|
|
1002
1022
|
{
|
|
@@ -1017,7 +1037,7 @@ async def _cmd_apply(args: argparse.Namespace) -> int:
|
|
|
1017
1037
|
]
|
|
1018
1038
|
)
|
|
1019
1039
|
],
|
|
1020
|
-
)
|
|
1040
|
+
),
|
|
1021
1041
|
)
|
|
1022
1042
|
else:
|
|
1023
1043
|
print(f"run {run_id}: applied")
|
|
@@ -1053,8 +1073,9 @@ async def _cmd_verify(args: argparse.Namespace) -> int:
|
|
|
1053
1073
|
engine = _engine(args.state)
|
|
1054
1074
|
run_id = engine.create(spec)
|
|
1055
1075
|
verify_report = await engine.verify(run_id)
|
|
1056
|
-
if args.json:
|
|
1057
|
-
|
|
1076
|
+
if args.json or getattr(args, "compact_dsl", False):
|
|
1077
|
+
_print_result(
|
|
1078
|
+
args,
|
|
1058
1079
|
_json_result(
|
|
1059
1080
|
"verify",
|
|
1060
1081
|
{
|
|
@@ -1077,7 +1098,7 @@ async def _cmd_verify(args: argparse.Namespace) -> int:
|
|
|
1077
1098
|
"verified_within_scope" if verify_report.ok else "verification_failed"
|
|
1078
1099
|
),
|
|
1079
1100
|
artifacts=[str(Path(args.state).resolve())],
|
|
1080
|
-
)
|
|
1101
|
+
),
|
|
1081
1102
|
)
|
|
1082
1103
|
else:
|
|
1083
1104
|
_print_verify(verify_report)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
"""Model-facing presentation of the existing result, with JSON-escaped values."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _summary(value: Any, *, path: tuple[str, ...] = (), artifacts: bool = False) -> Any:
|
|
11
|
+
if isinstance(value, dict):
|
|
12
|
+
if path in {("files",), ("extension", "files")} and artifacts:
|
|
13
|
+
return {"names": sorted(value), "omitted_file_contents": len(value)}
|
|
14
|
+
result = {}
|
|
15
|
+
for name, item in value.items():
|
|
16
|
+
if name == "extension" and isinstance(item, dict):
|
|
17
|
+
# Core lifecycle responses repeat extension fields; retain differing hashes.
|
|
18
|
+
item = {k: v for k, v in item.items() if k not in value or value[k] != v}
|
|
19
|
+
if not item:
|
|
20
|
+
continue
|
|
21
|
+
if (
|
|
22
|
+
name == "source"
|
|
23
|
+
and {"classification", "method"} <= value.keys()
|
|
24
|
+
and isinstance(item, str)
|
|
25
|
+
and artifacts
|
|
26
|
+
):
|
|
27
|
+
result["omitted_source_characters"] = len(item)
|
|
28
|
+
else:
|
|
29
|
+
result[name] = _summary(item, path=(*path, name), artifacts=artifacts)
|
|
30
|
+
return result
|
|
31
|
+
if isinstance(value, list):
|
|
32
|
+
return [_summary(item, path=(*path, "[]"), artifacts=artifacts) for item in value]
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def render_compact(payload: dict[str, Any]) -> str:
|
|
37
|
+
"""One header, then key=JSON lines. Never truncate failures, warnings or hashes."""
|
|
38
|
+
lines = [" ".join(str(payload[k]) for k in ("command", "outcome", "migration_state"))]
|
|
39
|
+
lines[0] = "sanka-compact/v1 " + lines[0]
|
|
40
|
+
data = _summary(payload["data"], artifacts=bool(payload.get("artifacts")))
|
|
41
|
+
for key, value in data.items():
|
|
42
|
+
# Escape even extension-defined keys so a newline cannot forge another record.
|
|
43
|
+
label = key if key.isidentifier() else json.dumps(key, ensure_ascii=False)
|
|
44
|
+
lines.append(label + "=" + json.dumps(value, ensure_ascii=False, separators=(",", ":")))
|
|
45
|
+
for key in ("artifacts", "limitations", "next_actions"):
|
|
46
|
+
if payload.get(key):
|
|
47
|
+
lines.append(
|
|
48
|
+
key + "=" + json.dumps(payload[key], ensure_ascii=False, separators=(",", ":"))
|
|
49
|
+
)
|
|
50
|
+
return "\n".join(lines)
|
|
@@ -820,7 +820,7 @@ class ApplicationLifecycle:
|
|
|
820
820
|
written, so the source fingerprint legitimately differs from any reviewed plan and
|
|
821
821
|
a plan may not exist yet. The extension lock still has to match its recommendation
|
|
822
822
|
exactly; only the plan, fingerprint, and artifact checks that guard apply and test
|
|
823
|
-
are skipped, because replay writes
|
|
823
|
+
are skipped, because replay only writes verification artifacts in its artifact directory.
|
|
824
824
|
"""
|
|
825
825
|
explicit_env_names = _canonical_environment_names(explicit_env_names)
|
|
826
826
|
fingerprint = fingerprint_repository(self.project_root)
|
|
@@ -839,7 +839,6 @@ class ApplicationLifecycle:
|
|
|
839
839
|
if result.outcome != "success":
|
|
840
840
|
self._raise_failure(result)
|
|
841
841
|
data = dict(result.data)
|
|
842
|
-
data["extension"] = result.data
|
|
843
842
|
return ExtensionResult(
|
|
844
843
|
outcome="success",
|
|
845
844
|
data=data,
|
|
@@ -87,16 +87,43 @@ class SankaApiClient:
|
|
|
87
87
|
path: str,
|
|
88
88
|
*,
|
|
89
89
|
params: dict[str, Any] | None = None,
|
|
90
|
+
headers: dict[str, str] | None = None,
|
|
91
|
+
max_bytes: int | None = None,
|
|
90
92
|
) -> tuple[bytes, dict[str, str]]:
|
|
91
93
|
"""Fetch a non-JSON body (currently only the Custom Code bundle download).
|
|
92
94
|
|
|
93
95
|
Returns headers alongside the payload so the caller can verify the digest the
|
|
94
96
|
server reports without a second request.
|
|
95
97
|
"""
|
|
98
|
+
request_headers = self._headers()
|
|
99
|
+
request_headers.update(headers or {})
|
|
100
|
+
if max_bytes is not None:
|
|
101
|
+
with self.client.stream(
|
|
102
|
+
method.upper(),
|
|
103
|
+
path,
|
|
104
|
+
headers=request_headers,
|
|
105
|
+
params=params,
|
|
106
|
+
) as response:
|
|
107
|
+
content = bytearray()
|
|
108
|
+
for chunk in response.iter_bytes(chunk_size=65536):
|
|
109
|
+
if len(content) + len(chunk) > max_bytes:
|
|
110
|
+
raise APIError(
|
|
111
|
+
status_code=413, message="API artifact exceeds its size limit"
|
|
112
|
+
)
|
|
113
|
+
content.extend(chunk)
|
|
114
|
+
if response.status_code >= 400:
|
|
115
|
+
self._raise_for_response(
|
|
116
|
+
httpx.Response(
|
|
117
|
+
response.status_code,
|
|
118
|
+
content=bytes(content),
|
|
119
|
+
headers=response.headers,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
return bytes(content), dict(response.headers)
|
|
96
123
|
response = self.client.request(
|
|
97
124
|
method.upper(),
|
|
98
125
|
path,
|
|
99
|
-
headers=
|
|
126
|
+
headers=request_headers,
|
|
100
127
|
params=params,
|
|
101
128
|
)
|
|
102
129
|
if response.status_code >= 400:
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Workspace-pinned client for hosted repository runs; no hosted implementation."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import hashlib
|
|
8
|
+
import io
|
|
9
|
+
import re
|
|
10
|
+
import zipfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
import sanka_cli.runtime as runtime
|
|
17
|
+
from sanka_cli.state import CLIState
|
|
18
|
+
|
|
19
|
+
ROOT = "/v2/migrate/cloud-runs"
|
|
20
|
+
SOURCE_BYTES = 8 * 1024 * 1024
|
|
21
|
+
ARTIFACT_BYTES = 16 * 1024 * 1024
|
|
22
|
+
WORKSPACE = click.option("--workspace", required=True, help="Exact workspace code (8 digits).")
|
|
23
|
+
RUN = click.argument("run_id", type=click.UUID)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _headers(workspace: str) -> dict[str, str]:
|
|
27
|
+
if not re.fullmatch(r"\d{8}", workspace):
|
|
28
|
+
raise click.BadParameter("must be an 8-digit workspace code", param_hint="--workspace")
|
|
29
|
+
return {"X-Workspace-Code": workspace}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _digest(value: str) -> str:
|
|
33
|
+
if not re.fullmatch(r"[a-f0-9]{64}", value):
|
|
34
|
+
raise click.BadParameter("must be a lowercase SHA-256 digest", param_hint="--sha256")
|
|
35
|
+
return value
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _data(payload: dict[str, Any]) -> dict[str, Any]:
|
|
39
|
+
data = payload.get("data", payload)
|
|
40
|
+
if not isinstance(data, dict):
|
|
41
|
+
raise click.ClickException("Cloud API returned an invalid response")
|
|
42
|
+
return data
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _read(state: CLIState, workspace: str, path: str, **params: Any) -> dict[str, Any]:
|
|
46
|
+
return runtime.request_json(state, "GET", path, headers=_headers(workspace), params=params)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@click.group()
|
|
50
|
+
def cloud() -> None:
|
|
51
|
+
"""Hosted DRF-to-FastAPI runs, credit receipts, and artifacts (when enabled)."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@cloud.command("upload")
|
|
55
|
+
@WORKSPACE
|
|
56
|
+
@click.argument("archive", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
57
|
+
@click.option("--yes", is_flag=True, help="Confirm uploading this ZIP to the selected workspace.")
|
|
58
|
+
@click.pass_obj
|
|
59
|
+
def upload(state: CLIState, workspace: str, archive: Path, yes: bool) -> None:
|
|
60
|
+
"""Upload an explicit ZIP, returning its source ID and SHA-256; no compute charge."""
|
|
61
|
+
headers = _headers(workspace)
|
|
62
|
+
with archive.open("rb") as stream:
|
|
63
|
+
content = stream.read(SOURCE_BYTES + 1)
|
|
64
|
+
if not content or len(content) > SOURCE_BYTES:
|
|
65
|
+
raise click.ClickException("Source ZIP must contain between 1 byte and 8 MiB")
|
|
66
|
+
try:
|
|
67
|
+
with zipfile.ZipFile(io.BytesIO(content)) as bundle:
|
|
68
|
+
if not bundle.infolist():
|
|
69
|
+
raise ValueError("empty ZIP")
|
|
70
|
+
except (ValueError, zipfile.BadZipFile) as exc:
|
|
71
|
+
raise click.ClickException("Source must be a nonempty ZIP archive") from exc
|
|
72
|
+
sha256 = hashlib.sha256(content).hexdigest()
|
|
73
|
+
if not yes:
|
|
74
|
+
click.confirm(
|
|
75
|
+
f"Upload {archive.name} ({len(content)} bytes, SHA-256 {sha256}) "
|
|
76
|
+
f"to workspace {workspace}? Source retention: 7 days",
|
|
77
|
+
abort=True,
|
|
78
|
+
)
|
|
79
|
+
payload = runtime.request_json(
|
|
80
|
+
state,
|
|
81
|
+
"POST",
|
|
82
|
+
"/v2/migrate/cloud-sources",
|
|
83
|
+
headers=headers,
|
|
84
|
+
json_body={"sha256": sha256, "archive_base64": base64.b64encode(content).decode("ascii")},
|
|
85
|
+
)
|
|
86
|
+
if _data(payload).get("sha256") != sha256:
|
|
87
|
+
raise click.ClickException("Uploaded source digest does not match the selected ZIP")
|
|
88
|
+
runtime.emit_payload(payload, state)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@cloud.command("run")
|
|
92
|
+
@WORKSPACE
|
|
93
|
+
@click.option("--source-id", required=True, type=click.UUID)
|
|
94
|
+
@click.option("--sha256", required=True)
|
|
95
|
+
@click.option("--max-credits", required=True, type=click.IntRange(1, 6000))
|
|
96
|
+
@click.option("--timeout-seconds", default=600, show_default=True, type=click.IntRange(1, 3600))
|
|
97
|
+
@click.option(
|
|
98
|
+
"--settings-module", default=None, help="Django settings module, e.g. config.settings."
|
|
99
|
+
)
|
|
100
|
+
@click.option(
|
|
101
|
+
"--idempotency-key",
|
|
102
|
+
required=True,
|
|
103
|
+
help="Unique intent key (8-200 characters). Reuse with identical inputs after a network error.",
|
|
104
|
+
)
|
|
105
|
+
@click.option("--yes", is_flag=True, help="Confirm the credit hold and paid execution.")
|
|
106
|
+
@click.pass_obj
|
|
107
|
+
def run(
|
|
108
|
+
state: CLIState,
|
|
109
|
+
workspace: str,
|
|
110
|
+
source_id: Any,
|
|
111
|
+
sha256: str,
|
|
112
|
+
max_credits: int,
|
|
113
|
+
timeout_seconds: int,
|
|
114
|
+
settings_module: str | None,
|
|
115
|
+
idempotency_key: str,
|
|
116
|
+
yes: bool,
|
|
117
|
+
) -> None:
|
|
118
|
+
"""Reserve the credit limit and queue one run. Retries keep the same source and key."""
|
|
119
|
+
headers = _headers(workspace)
|
|
120
|
+
_digest(sha256)
|
|
121
|
+
if (
|
|
122
|
+
not 8 <= len(idempotency_key) <= 200
|
|
123
|
+
or not idempotency_key.isascii()
|
|
124
|
+
or any(ord(char) < 33 or ord(char) > 126 for char in idempotency_key)
|
|
125
|
+
):
|
|
126
|
+
raise click.BadParameter(
|
|
127
|
+
"must be 8-200 visible ASCII characters", param_hint="--idempotency-key"
|
|
128
|
+
)
|
|
129
|
+
if settings_module is not None and (
|
|
130
|
+
len(settings_module) > 200
|
|
131
|
+
or not re.fullmatch(r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*", settings_module)
|
|
132
|
+
):
|
|
133
|
+
raise click.BadParameter("must be a Python module name", param_hint="--settings-module")
|
|
134
|
+
if not yes:
|
|
135
|
+
click.confirm(
|
|
136
|
+
f"Run source {source_id} in workspace {workspace}? "
|
|
137
|
+
f"Reserve up to {max_credits} credits; "
|
|
138
|
+
"100 credits per active worker-minute, unused credits released",
|
|
139
|
+
abort=True,
|
|
140
|
+
)
|
|
141
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
142
|
+
payload = runtime.request_json(
|
|
143
|
+
state,
|
|
144
|
+
"POST",
|
|
145
|
+
ROOT,
|
|
146
|
+
headers=headers,
|
|
147
|
+
json_body={
|
|
148
|
+
"source_id": str(source_id),
|
|
149
|
+
"source_sha256": sha256,
|
|
150
|
+
"max_credits": max_credits,
|
|
151
|
+
"timeout_seconds": timeout_seconds,
|
|
152
|
+
"settings_module": settings_module,
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
runtime.emit_payload(payload, state)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@cloud.command("list")
|
|
159
|
+
@WORKSPACE
|
|
160
|
+
@click.option("--cursor", type=click.UUID, default=None)
|
|
161
|
+
@click.pass_obj
|
|
162
|
+
def list_runs(state: CLIState, workspace: str, cursor: Any) -> None:
|
|
163
|
+
params = {"cursor": str(cursor)} if cursor else {}
|
|
164
|
+
runtime.emit_payload(_read(state, workspace, ROOT, limit=20, **params), state)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@cloud.command("status")
|
|
168
|
+
@WORKSPACE
|
|
169
|
+
@RUN
|
|
170
|
+
@click.pass_obj
|
|
171
|
+
def status(state: CLIState, workspace: str, run_id: Any) -> None:
|
|
172
|
+
runtime.emit_payload(_read(state, workspace, f"{ROOT}/{run_id}"), state)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@cloud.command("receipt")
|
|
176
|
+
@WORKSPACE
|
|
177
|
+
@RUN
|
|
178
|
+
@click.pass_obj
|
|
179
|
+
def receipt(state: CLIState, workspace: str, run_id: Any) -> None:
|
|
180
|
+
"""Read the immutable receipt, including held, charged, and released credits."""
|
|
181
|
+
runtime.emit_payload(_read(state, workspace, f"{ROOT}/{run_id}/receipt"), state)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@cloud.command("events")
|
|
185
|
+
@WORKSPACE
|
|
186
|
+
@RUN
|
|
187
|
+
@click.option("--cursor", default=0, type=click.IntRange(min=0))
|
|
188
|
+
@click.pass_obj
|
|
189
|
+
def events(state: CLIState, workspace: str, run_id: Any, cursor: int) -> None:
|
|
190
|
+
runtime.emit_payload(_read(state, workspace, f"{ROOT}/{run_id}/events", cursor=cursor), state)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@cloud.command("cancel")
|
|
194
|
+
@WORKSPACE
|
|
195
|
+
@RUN
|
|
196
|
+
@click.pass_obj
|
|
197
|
+
def cancel(state: CLIState, workspace: str, run_id: Any) -> None:
|
|
198
|
+
"""Cancel this run; completed active compute can still be charged."""
|
|
199
|
+
runtime.emit_payload(
|
|
200
|
+
runtime.request_json(
|
|
201
|
+
state,
|
|
202
|
+
"POST",
|
|
203
|
+
f"{ROOT}/{run_id}/cancel",
|
|
204
|
+
headers=_headers(workspace),
|
|
205
|
+
),
|
|
206
|
+
state,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@cloud.command("download")
|
|
211
|
+
@WORKSPACE
|
|
212
|
+
@RUN
|
|
213
|
+
@click.option("--artifact", type=click.Choice(["output.zip", "logs.txt"]), default="output.zip")
|
|
214
|
+
@click.option("--to", "destination", required=True, type=click.Path(dir_okay=False, path_type=Path))
|
|
215
|
+
@click.pass_obj
|
|
216
|
+
def download(
|
|
217
|
+
state: CLIState, workspace: str, run_id: Any, artifact: str, destination: Path
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Verify a retained artifact's digest and save a new file; existing files are preserved."""
|
|
220
|
+
if destination.exists() or destination.is_symlink():
|
|
221
|
+
raise click.ClickException("Destination already exists; choose a new file")
|
|
222
|
+
manifest = _data(_read(state, workspace, f"{ROOT}/{run_id}/artifacts"))
|
|
223
|
+
entries = manifest.get("artifacts", [])
|
|
224
|
+
metadata = next((item for item in entries if item.get("name") == artifact), None)
|
|
225
|
+
if metadata is None:
|
|
226
|
+
raise click.ClickException("Artifact is not available (it may have expired)")
|
|
227
|
+
content, _ = runtime.request_bytes(
|
|
228
|
+
state,
|
|
229
|
+
"GET",
|
|
230
|
+
f"{ROOT}/{run_id}/artifacts/{artifact}",
|
|
231
|
+
headers=_headers(workspace),
|
|
232
|
+
max_bytes=ARTIFACT_BYTES,
|
|
233
|
+
)
|
|
234
|
+
if len(content) != metadata.get("size_bytes") or hashlib.sha256(
|
|
235
|
+
content
|
|
236
|
+
).hexdigest() != metadata.get("sha256"):
|
|
237
|
+
raise click.ClickException("Artifact size or digest does not match; no file was written")
|
|
238
|
+
with destination.open("xb") as stream:
|
|
239
|
+
stream.write(content)
|
|
240
|
+
runtime.emit_payload({"path": str(destination), "sha256": metadata["sha256"]}, state)
|
|
@@ -6,6 +6,7 @@ import click
|
|
|
6
6
|
from sanka_cli import __version__
|
|
7
7
|
from sanka_cli.commands.ai import ai
|
|
8
8
|
from sanka_cli.commands.auth import auth, auth_login, auth_logout, auth_status
|
|
9
|
+
from sanka_cli.commands.cloud import cloud
|
|
9
10
|
from sanka_cli.commands.code import code
|
|
10
11
|
from sanka_cli.commands.migrate import register_migration_passthroughs
|
|
11
12
|
from sanka_cli.commands.profiles import profiles
|
|
@@ -44,6 +45,7 @@ cli.add_command(profiles)
|
|
|
44
45
|
cli.add_command(workflows)
|
|
45
46
|
cli.add_command(ai)
|
|
46
47
|
cli.add_command(code)
|
|
48
|
+
cli.add_command(cloud)
|
|
47
49
|
cli.add_command(skill)
|
|
48
50
|
|
|
49
51
|
attach_resource_group(cli, "companies", "/v2/public/companies")
|
|
@@ -148,10 +148,17 @@ def request_bytes(
|
|
|
148
148
|
path: str,
|
|
149
149
|
*,
|
|
150
150
|
params: dict[str, Any] | None = None,
|
|
151
|
+
headers: dict[str, str] | None = None,
|
|
152
|
+
max_bytes: int | None = None,
|
|
151
153
|
) -> tuple[bytes, dict[str, str]]:
|
|
152
154
|
client = build_client(state)
|
|
153
155
|
try:
|
|
154
|
-
|
|
156
|
+
options: dict[str, Any] = {}
|
|
157
|
+
if headers is not None:
|
|
158
|
+
options["headers"] = headers
|
|
159
|
+
if max_bytes is not None:
|
|
160
|
+
options["max_bytes"] = max_bytes
|
|
161
|
+
return client.request_bytes(method, path, params=params, **options)
|
|
155
162
|
except APIError as exc:
|
|
156
163
|
handle_api_error(exc)
|
|
157
164
|
raise
|