sanka-cli 0.2.3__tar.gz → 0.2.5__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.5}/PKG-INFO +32 -1
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/README.md +30 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/pyproject.toml +2 -1
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/cli/__init__.py +44 -23
- sanka_cli-0.2.5/src/sanka/cli/_compact.py +50 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/extensions/lifecycle.py +1 -2
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/__init__.py +1 -1
- sanka_cli-0.2.5/src/sanka_cli/certificates.py +98 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/client.py +28 -1
- sanka_cli-0.2.5/src/sanka_cli/commands/cloud.py +550 -0
- sanka_cli-0.2.5/src/sanka_cli/commands/cloud_fleet.py +262 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/main.py +2 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/runtime.py +8 -1
- sanka_cli-0.2.5/src/sanka_cli/skills/sanka-cli/SKILL.md +48 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_cli.py +1 -1
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_cli_developer_experience.py +66 -1
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_extension_lifecycle.py +1 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_mcp.py +2 -2
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_sanka_facade.py +1 -1
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/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.5}/.gitignore +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/LICENSES/AGPL-3.0-only.txt +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/LICENSES/Apache-2.0.txt +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/NOTICE +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/__init__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/_client.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/cli/_output.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/cli/_research.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/cli/py.typed +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/connector/__init__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/connector/py.typed +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/__about__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/__init__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/concurrency.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/connector_client.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/connector_host.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/engine.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/execution/__init__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/execution/continuous.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/execution/errors.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/execution/local.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/execution/model.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/execution/report_codec.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/execution/routes.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/execution/scope.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/execution/state.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/execution/validate.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/extensions/__init__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/extensions/discovery.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/extensions/model.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/extensions/runner.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/extensions/store.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/hashing.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/mapping/__init__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/mapping/errors.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/mapping/mapping_candidates.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/mapping/model.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/mapping/owner_mapping.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/mapping/pending_relationships.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/mapping/record_mapping.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/planner.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/private_sqlite.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/py.typed +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/registry.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/safe_local_io.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/spec.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka/runtime/state.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/__main__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/bundle.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/commands/__init__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/commands/ai.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/commands/auth.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/commands/cloud_migrate.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/commands/code.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/commands/migrate.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/commands/profiles.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/commands/resources.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/commands/skill.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/commands/workflows.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/config.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/mcp/__init__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/mcp/client.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/mcp/render.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/mcp/server.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/output.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_cli/state.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_connector/__about__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_connector/__init__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_connector/credentials.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_connector/errors.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_connector/protocols.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_connector/provisioning.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_connector/py.typed +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_connector/records.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_connector/registration.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/src/sanka_connector/schema.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/conftest.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/fixtures/extension_drf_project/api/__init__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/fixtures/extension_drf_project/api/serializers.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/fixtures/extension_drf_project/api/views.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/fixtures/extension_drf_project/config/__init__.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/fixtures/extension_drf_project/config/settings.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/fixtures/extension_drf_project/config/urls.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/fixtures/extension_drf_project/manage.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/fixtures/extension_marketplace/drf-to-fastapi.json +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/fixtures/extension_marketplace/marketplace.json +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_code_bundle.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_code_commands.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_concurrency.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_connector_client.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_connector_host.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_credential_provider.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_engine_e2e.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_engine_execution_adoption.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_engine_pg_to_clickhouse_e2e.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_engine_postgres_e2e.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_execution_continuous.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_execution_exact_scope.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_execution_local_store.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_execution_report_codec.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_execution_routes.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_execution_scope.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_execution_seam.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_execution_validate.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_extension_discovery.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_extension_runner.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_extension_store.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_extension_wheel_acceptance.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_hashing.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_hosted_cli.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_mapping_candidates.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_mapping_owner_mapping.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_mapping_pending_relationships.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_mapping_record_mapping.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_planner.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_registry.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_safe_local_io.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/tests/test_spec.py +0 -0
- {sanka_cli-0.2.3 → sanka_cli-0.2.5}/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.5
|
|
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
|
|
@@ -16,6 +16,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
16
16
|
Classifier: Typing :: Typed
|
|
17
17
|
Requires-Python: >=3.12
|
|
18
18
|
Requires-Dist: click<9,>=8.1
|
|
19
|
+
Requires-Dist: cryptography<52,>=43
|
|
19
20
|
Requires-Dist: httpx<1,>=0.27
|
|
20
21
|
Requires-Dist: keyring<26,>=25
|
|
21
22
|
Requires-Dist: packaging<27,>=24
|
|
@@ -107,6 +108,17 @@ workflows, AI, Custom Code, and explicitly cloud-selected migrations retain
|
|
|
107
108
|
their existing authentication requirements. Add `--json` to local lifecycle
|
|
108
109
|
commands for the stable `sanka-cli/v1` response envelope.
|
|
109
110
|
|
|
111
|
+
## Hosted repository runs
|
|
112
|
+
|
|
113
|
+
`sanka cloud` uploads reviewed repository ZIPs, starts runs with an explicit
|
|
114
|
+
credit cap, and reads status, receipts, and retained artifacts. When enabled by
|
|
115
|
+
the operator, `cloud repair` makes one bounded patch attempt and `cloud certify`
|
|
116
|
+
compares selected HTTP cases in a fresh worker. `cloud certificate-verify`
|
|
117
|
+
verifies signed evidence online or with an explicitly trusted offline key ring.
|
|
118
|
+
Every hosted command pins its workspace; paid operations require confirmation.
|
|
119
|
+
See [Hosted repository runs](../../docs/cloud-runs.md) for inputs, pricing,
|
|
120
|
+
verification scope, and revocation limits.
|
|
121
|
+
|
|
110
122
|
## Extension trust
|
|
111
123
|
|
|
112
124
|
The manager verifies a trusted marketplace snapshot, manifest schema,
|
|
@@ -149,3 +161,22 @@ The distribution license expression is
|
|
|
149
161
|
`Apache-2.0 AND AGPL-3.0-only`. Both license texts and the historical hosted
|
|
150
162
|
CLI notice ship in the wheel and source archive. File-level SPDX identifiers
|
|
151
163
|
are authoritative.
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
### Compact lifecycle output
|
|
167
|
+
|
|
168
|
+
Use `--compact-dsl` instead of `--json` on `scan`, `plan`, `apply`, `test`,
|
|
169
|
+
`verify`, and extension management commands when an agent needs a short result.
|
|
170
|
+
The flags are mutually exclusive; `--json` retains its existing contract.
|
|
171
|
+
Compact output starts with `sanka-compact/v1 <command> <outcome> <migration_state>`
|
|
172
|
+
then emits one `key=JSON-value` per line. Strings use JSON escaping, including
|
|
173
|
+
newlines and quotes. Failures, warnings, plan hashes, and limitations are retained.
|
|
174
|
+
Legacy aliases are not repeated. With full artifacts available, generated file
|
|
175
|
+
contents and route source code are replaced by explicit omission counts and file
|
|
176
|
+
names. Read the returned artifact for those details; a compact summary is not a
|
|
177
|
+
replacement for the full report. `data.plan_hash` becomes the `plan_hash` line;
|
|
178
|
+
use that core hash for apply, not a nested extension hash.
|
|
179
|
+
|
|
180
|
+
Keep the lifecycle: scan → plan → apply → test → verify. An applied scaffold with
|
|
181
|
+
manual gaps is not complete. Test establishes generated scope; scenario verification
|
|
182
|
+
checks HTTP and database parity within the supplied fixtures and cases.
|
|
@@ -77,6 +77,17 @@ workflows, AI, Custom Code, and explicitly cloud-selected migrations retain
|
|
|
77
77
|
their existing authentication requirements. Add `--json` to local lifecycle
|
|
78
78
|
commands for the stable `sanka-cli/v1` response envelope.
|
|
79
79
|
|
|
80
|
+
## Hosted repository runs
|
|
81
|
+
|
|
82
|
+
`sanka cloud` uploads reviewed repository ZIPs, starts runs with an explicit
|
|
83
|
+
credit cap, and reads status, receipts, and retained artifacts. When enabled by
|
|
84
|
+
the operator, `cloud repair` makes one bounded patch attempt and `cloud certify`
|
|
85
|
+
compares selected HTTP cases in a fresh worker. `cloud certificate-verify`
|
|
86
|
+
verifies signed evidence online or with an explicitly trusted offline key ring.
|
|
87
|
+
Every hosted command pins its workspace; paid operations require confirmation.
|
|
88
|
+
See [Hosted repository runs](../../docs/cloud-runs.md) for inputs, pricing,
|
|
89
|
+
verification scope, and revocation limits.
|
|
90
|
+
|
|
80
91
|
## Extension trust
|
|
81
92
|
|
|
82
93
|
The manager verifies a trusted marketplace snapshot, manifest schema,
|
|
@@ -119,3 +130,22 @@ The distribution license expression is
|
|
|
119
130
|
`Apache-2.0 AND AGPL-3.0-only`. Both license texts and the historical hosted
|
|
120
131
|
CLI notice ship in the wheel and source archive. File-level SPDX identifiers
|
|
121
132
|
are authoritative.
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
### Compact lifecycle output
|
|
136
|
+
|
|
137
|
+
Use `--compact-dsl` instead of `--json` on `scan`, `plan`, `apply`, `test`,
|
|
138
|
+
`verify`, and extension management commands when an agent needs a short result.
|
|
139
|
+
The flags are mutually exclusive; `--json` retains its existing contract.
|
|
140
|
+
Compact output starts with `sanka-compact/v1 <command> <outcome> <migration_state>`
|
|
141
|
+
then emits one `key=JSON-value` per line. Strings use JSON escaping, including
|
|
142
|
+
newlines and quotes. Failures, warnings, plan hashes, and limitations are retained.
|
|
143
|
+
Legacy aliases are not repeated. With full artifacts available, generated file
|
|
144
|
+
contents and route source code are replaced by explicit omission counts and file
|
|
145
|
+
names. Read the returned artifact for those details; a compact summary is not a
|
|
146
|
+
replacement for the full report. `data.plan_hash` becomes the `plan_hash` line;
|
|
147
|
+
use that core hash for apply, not a nested extension hash.
|
|
148
|
+
|
|
149
|
+
Keep the lifecycle: scan → plan → apply → test → verify. An applied scaffold with
|
|
150
|
+
manual gaps is not complete. Test establishes generated scope; scenario verification
|
|
151
|
+
checks HTTP and database parity within the supplied fixtures and cases.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "sanka-cli"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.5"
|
|
4
4
|
description = "Sanka command-line interface and migration runtime"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -16,6 +16,7 @@ classifiers = [
|
|
|
16
16
|
]
|
|
17
17
|
dependencies = [
|
|
18
18
|
"click>=8.1,<9",
|
|
19
|
+
"cryptography>=43,<52",
|
|
19
20
|
"httpx>=0.27,<1",
|
|
20
21
|
"keyring>=25,<26",
|
|
21
22
|
"packaging>=24,<27",
|
|
@@ -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,
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Public certificate input and verification helpers; no hosted execution code."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from urllib.parse import unquote, urlsplit
|
|
12
|
+
|
|
13
|
+
from cryptography.exceptions import InvalidSignature
|
|
14
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def canonical_bytes(value: Any) -> bytes:
|
|
18
|
+
return json.dumps(
|
|
19
|
+
value, sort_keys=True, separators=(",", ":"), ensure_ascii=True, allow_nan=False
|
|
20
|
+
).encode("utf-8")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def read_json(path: Path, maximum: int = 192 * 1024) -> Any:
|
|
24
|
+
with path.open("rb") as stream:
|
|
25
|
+
data = stream.read(maximum + 1)
|
|
26
|
+
if len(data) > maximum:
|
|
27
|
+
raise ValueError("JSON file exceeds its size limit")
|
|
28
|
+
return json.loads(data)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_scenarios(path: Path) -> list[dict[str, Any]]:
|
|
32
|
+
cases = read_json(path, 48 * 1024)
|
|
33
|
+
if not isinstance(cases, list) or not 1 <= len(cases) <= 50:
|
|
34
|
+
raise ValueError("Cases must be a JSON array containing 1 to 50 requests")
|
|
35
|
+
seen, normalized = set(), []
|
|
36
|
+
for case in cases:
|
|
37
|
+
if not isinstance(case, dict) or set(case) - {"id", "method", "path", "json_body"}:
|
|
38
|
+
raise ValueError("Each case accepts only id, method, path and json_body")
|
|
39
|
+
identifier, method, value = case.get("id"), case.get("method"), case.get("path")
|
|
40
|
+
if (
|
|
41
|
+
not isinstance(identifier, str)
|
|
42
|
+
or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}", identifier)
|
|
43
|
+
or identifier in seen
|
|
44
|
+
or method not in {"GET", "POST", "PUT", "PATCH", "DELETE"}
|
|
45
|
+
or not isinstance(value, str)
|
|
46
|
+
or not 1 <= len(value) <= 1000
|
|
47
|
+
):
|
|
48
|
+
raise ValueError("Each case needs a unique ID, supported method and local HTTP path")
|
|
49
|
+
seen.add(identifier)
|
|
50
|
+
parsed, decoded = urlsplit(value), unquote(value)
|
|
51
|
+
if (
|
|
52
|
+
not value.startswith("/")
|
|
53
|
+
or value.startswith("//")
|
|
54
|
+
or parsed.scheme
|
|
55
|
+
or parsed.netloc
|
|
56
|
+
or parsed.fragment
|
|
57
|
+
or "\\" in decoded
|
|
58
|
+
or any(ord(char) < 32 or ord(char) > 126 for char in decoded)
|
|
59
|
+
or ".." in decoded.split("?", 1)[0].split("/")
|
|
60
|
+
):
|
|
61
|
+
raise ValueError("Case paths must be local ASCII HTTP paths without traversal")
|
|
62
|
+
body = case.get("json_body")
|
|
63
|
+
if len(canonical_bytes({"body": body})) > 8192 or (method == "GET" and body is not None):
|
|
64
|
+
raise ValueError("Case bodies are limited to 8 KiB; GET cannot have a body")
|
|
65
|
+
normalized.append({"id": identifier, "method": method, "path": value, "json_body": body})
|
|
66
|
+
if len(canonical_bytes(normalized)) > 47 * 1024:
|
|
67
|
+
raise ValueError("The normalized certificate scope exceeds its size limit")
|
|
68
|
+
return normalized
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def verify_certificate(document: dict[str, Any], key_document: dict[str, Any]) -> dict[str, Any]:
|
|
72
|
+
"""Verify using keys supplied by the caller's chosen trusted source only."""
|
|
73
|
+
signed = document.get("certificate", document)
|
|
74
|
+
key_document = key_document.get("data", key_document)
|
|
75
|
+
try:
|
|
76
|
+
payload = signed["payload"]
|
|
77
|
+
if (
|
|
78
|
+
signed["algorithm"] != "Ed25519"
|
|
79
|
+
or signed["encoding"] != "sorted-ascii-json-v1"
|
|
80
|
+
or payload["schema_version"] != "developer-certificate-v1"
|
|
81
|
+
or payload["issuer"] != "https://api-v2.sanka.com"
|
|
82
|
+
or not isinstance(payload, dict)
|
|
83
|
+
):
|
|
84
|
+
raise ValueError("Unsupported certificate issuer or signature format")
|
|
85
|
+
matches = [row for row in key_document["keys"] if row["key_id"] == signed["key_id"]]
|
|
86
|
+
if len(matches) != 1 or matches[0].get("algorithm") != "Ed25519":
|
|
87
|
+
raise ValueError("The signing key is not in the selected trusted key ring")
|
|
88
|
+
key = Ed25519PublicKey.from_public_bytes(
|
|
89
|
+
base64.b64decode(matches[0]["public_key_base64"], validate=True)
|
|
90
|
+
)
|
|
91
|
+
key.verify(
|
|
92
|
+
base64.b64decode(signed["signature_base64"], validate=True), canonical_bytes(payload)
|
|
93
|
+
)
|
|
94
|
+
except InvalidSignature as error:
|
|
95
|
+
raise ValueError("Certificate signature is invalid") from error
|
|
96
|
+
except (KeyError, TypeError, AttributeError) as error:
|
|
97
|
+
raise ValueError("Certificate or trusted key ring has an invalid format") from error
|
|
98
|
+
return payload
|
|
@@ -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:
|