agent-hitch 0.2.16 → 0.2.17
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.
- package/README.md +7 -28
- package/README.zh-CN.md +6 -26
- package/dist/scripts/benchmark-dataset-verification.js +118 -0
- package/dist/scripts/benchmark-dataset-verification.js.map +1 -0
- package/dist/scripts/canary-external-endpoint.js +56 -0
- package/dist/scripts/canary-external-endpoint.js.map +1 -0
- package/dist/scripts/canary-native-training.js +8 -6
- package/dist/scripts/canary-native-training.js.map +1 -1
- package/dist/scripts/canary-remote-training.js +8 -6
- package/dist/scripts/canary-remote-training.js.map +1 -1
- package/dist/scripts/check-architecture.js +1 -1
- package/dist/scripts/check-architecture.js.map +1 -1
- package/dist/src/adapters/providers/codex.js +5 -2
- package/dist/src/adapters/providers/codex.js.map +1 -1
- package/dist/src/adapters/providers/model-call.js +1 -2
- package/dist/src/adapters/providers/model-call.js.map +1 -1
- package/dist/src/adapters/providers/training-tool.js +2 -0
- package/dist/src/adapters/providers/training-tool.js.map +1 -1
- package/dist/src/backends/harbor/artifact-pins.js +42 -0
- package/dist/src/backends/harbor/artifact-pins.js.map +1 -0
- package/dist/src/backends/harbor/backend.js +16 -8
- package/dist/src/backends/harbor/backend.js.map +1 -1
- package/dist/src/backends/harbor/bound-credential-helper.js +24 -0
- package/dist/src/backends/harbor/bound-credential-helper.js.map +1 -0
- package/dist/src/backends/harbor/credential-snapshot.js +71 -0
- package/dist/src/backends/harbor/credential-snapshot.js.map +1 -0
- package/dist/src/backends/harbor/environment-config.js +25 -2
- package/dist/src/backends/harbor/environment-config.js.map +1 -1
- package/dist/src/backends/harbor/environment-files.js +143 -0
- package/dist/src/backends/harbor/environment-files.js.map +1 -0
- package/dist/src/backends/harbor/evaluation-binding.js +160 -0
- package/dist/src/backends/harbor/evaluation-binding.js.map +1 -0
- package/dist/src/backends/harbor/host-credential-helper.js +5 -0
- package/dist/src/backends/harbor/host-credential-helper.js.map +1 -1
- package/dist/src/backends/harbor/index.js +2 -0
- package/dist/src/backends/harbor/index.js.map +1 -1
- package/dist/src/backends/harbor/model-proxy-config.js +3 -18
- package/dist/src/backends/harbor/model-proxy-config.js.map +1 -1
- package/dist/src/backends/harbor/portable-regrade.js +15 -3
- package/dist/src/backends/harbor/portable-regrade.js.map +1 -1
- package/dist/src/backends/harbor/python-runtime.js +71 -0
- package/dist/src/backends/harbor/python-runtime.js.map +1 -0
- package/dist/src/backends/harbor/tools.js +6 -4
- package/dist/src/backends/harbor/tools.js.map +1 -1
- package/dist/src/backends/index.js +3 -0
- package/dist/src/backends/index.js.map +1 -1
- package/dist/src/cli/arguments.js +30 -30
- package/dist/src/cli/arguments.js.map +1 -1
- package/dist/src/cli/commands/capabilities.js +7 -3
- package/dist/src/cli/commands/capabilities.js.map +1 -1
- package/dist/src/cli/commands/daemon.js +0 -17
- package/dist/src/cli/commands/daemon.js.map +1 -1
- package/dist/src/cli/commands/eval-binding.js +32 -0
- package/dist/src/cli/commands/eval-binding.js.map +1 -0
- package/dist/src/cli/commands/eval.js +21 -15
- package/dist/src/cli/commands/eval.js.map +1 -1
- package/dist/src/cli/commands/models.js +32 -45
- package/dist/src/cli/commands/models.js.map +1 -1
- package/dist/src/cli/commands/run.js +0 -35
- package/dist/src/cli/commands/run.js.map +1 -1
- package/dist/src/cli/main.js +0 -4
- package/dist/src/cli/main.js.map +1 -1
- package/dist/src/cli/output.js +5 -16
- package/dist/src/cli/output.js.map +1 -1
- package/dist/src/control-plane/eval-records.js +3 -1
- package/dist/src/control-plane/eval-records.js.map +1 -1
- package/dist/src/control-plane/eval-recovery.js +4 -1
- package/dist/src/control-plane/eval-recovery.js.map +1 -1
- package/dist/src/control-plane/eval-scheduler.js +16 -8
- package/dist/src/control-plane/eval-scheduler.js.map +1 -1
- package/dist/src/control-plane/index.js +0 -2
- package/dist/src/control-plane/index.js.map +1 -1
- package/dist/src/control-plane/model-capture-planning.js +3 -4
- package/dist/src/control-plane/model-capture-planning.js.map +1 -1
- package/dist/src/control-plane/remote-model-routes.js +4 -24
- package/dist/src/control-plane/remote-model-routes.js.map +1 -1
- package/dist/src/control-plane/remote-offer-contract.js +2 -2
- package/dist/src/control-plane/remote-offer-contract.js.map +1 -1
- package/dist/src/control-plane/remote-result-transport.js +7 -8
- package/dist/src/control-plane/remote-result-transport.js.map +1 -1
- package/dist/src/control-plane/remote-work-coordinator.js +2 -2
- package/dist/src/control-plane/remote-work-coordinator.js.map +1 -1
- package/dist/src/control-plane/remote-work-recovery.js +2 -2
- package/dist/src/control-plane/remote-work-recovery.js.map +1 -1
- package/dist/src/control-plane/remote-worker-protocol.js +1 -1
- package/dist/src/control-plane/remote-worker-protocol.js.map +1 -1
- package/dist/src/control-plane/remote-worker-runner.js +21 -6
- package/dist/src/control-plane/remote-worker-runner.js.map +1 -1
- package/dist/src/control-plane/remote-workers.js +2 -2
- package/dist/src/control-plane/remote-workers.js.map +1 -1
- package/dist/src/control-plane/rerun-recovery.js +1 -1
- package/dist/src/control-plane/rerun-recovery.js.map +1 -1
- package/dist/src/control-plane/rerun-scheduler.js +21 -14
- package/dist/src/control-plane/rerun-scheduler.js.map +1 -1
- package/dist/src/control-plane/rerun-source.js +5 -2
- package/dist/src/control-plane/rerun-source.js.map +1 -1
- package/dist/src/control-plane/rerun-submission.js +7 -5
- package/dist/src/control-plane/rerun-submission.js.map +1 -1
- package/dist/src/control-plane/synthetic-result.js +3 -2
- package/dist/src/control-plane/synthetic-result.js.map +1 -1
- package/dist/src/control-plane/worker-model-relay.js +1 -1
- package/dist/src/control-plane/worker-model-relay.js.map +1 -1
- package/dist/src/controller-runtime/hash.js +6 -0
- package/dist/src/controller-runtime/hash.js.map +1 -1
- package/dist/src/daemon/client.js +12 -35
- package/dist/src/daemon/client.js.map +1 -1
- package/dist/src/daemon/evaluation-binding.js +33 -0
- package/dist/src/daemon/evaluation-binding.js.map +1 -0
- package/dist/src/daemon/evaluation-submission.js +27 -0
- package/dist/src/daemon/evaluation-submission.js.map +1 -0
- package/dist/src/daemon/index.js +1 -0
- package/dist/src/daemon/index.js.map +1 -1
- package/dist/src/daemon/json-stream.js +98 -0
- package/dist/src/daemon/json-stream.js.map +1 -0
- package/dist/src/daemon/scheduler.js +1 -4
- package/dist/src/daemon/scheduler.js.map +1 -1
- package/dist/src/daemon/server.js +14 -54
- package/dist/src/daemon/server.js.map +1 -1
- package/dist/src/domain/endpoints.js +2 -0
- package/dist/src/domain/endpoints.js.map +1 -0
- package/dist/src/domain/evaluation-binding.js +5 -0
- package/dist/src/domain/evaluation-binding.js.map +1 -0
- package/dist/src/domain/index.js +2 -0
- package/dist/src/domain/index.js.map +1 -1
- package/dist/src/domain/runs.js.map +1 -1
- package/dist/src/evals/benchmark-adapter-manifest.js +95 -32
- package/dist/src/evals/benchmark-adapter-manifest.js.map +1 -1
- package/dist/src/evals/benchmark-verification.js +106 -0
- package/dist/src/evals/benchmark-verification.js.map +1 -0
- package/dist/src/evals/collect-only-rerun.js +3 -2
- package/dist/src/evals/collect-only-rerun.js.map +1 -1
- package/dist/src/evals/directory.js +9 -1
- package/dist/src/evals/directory.js.map +1 -1
- package/dist/src/evals/endpoint-attempts.js +112 -0
- package/dist/src/evals/endpoint-attempts.js.map +1 -0
- package/dist/src/evals/endpoint-usage.js +8 -0
- package/dist/src/evals/endpoint-usage.js.map +1 -0
- package/dist/src/evals/environment-binding.js +9 -0
- package/dist/src/evals/environment-binding.js.map +1 -0
- package/dist/src/evals/eval-artifact-planning.js +10 -1
- package/dist/src/evals/eval-artifact-planning.js.map +1 -1
- package/dist/src/evals/eval-finalization.js +2 -3
- package/dist/src/evals/eval-finalization.js.map +1 -1
- package/dist/src/evals/eval-logical-plan.js +2 -0
- package/dist/src/evals/eval-logical-plan.js.map +1 -1
- package/dist/src/evals/eval-result-builder.js +4 -0
- package/dist/src/evals/eval-result-builder.js.map +1 -1
- package/dist/src/evals/eval-state.js +22 -0
- package/dist/src/evals/eval-state.js.map +1 -0
- package/dist/src/evals/execution-plan.js +6 -2
- package/dist/src/evals/execution-plan.js.map +1 -1
- package/dist/src/evals/index.js +5 -1
- package/dist/src/evals/index.js.map +1 -1
- package/dist/src/evals/infrastructure-retry.js +2 -0
- package/dist/src/evals/infrastructure-retry.js.map +1 -1
- package/dist/src/evals/model-capture-runtime.js +9 -13
- package/dist/src/evals/model-capture-runtime.js.map +1 -1
- package/dist/src/evals/native-phase-evidence.js +8 -2
- package/dist/src/evals/native-phase-evidence.js.map +1 -1
- package/dist/src/evals/planned-execution.js +7 -2
- package/dist/src/evals/planned-execution.js.map +1 -1
- package/dist/src/evals/planned-progress-publisher.js +18 -5
- package/dist/src/evals/planned-progress-publisher.js.map +1 -1
- package/dist/src/evals/planned-retry-execution.js +1 -0
- package/dist/src/evals/planned-retry-execution.js.map +1 -1
- package/dist/src/evals/preparation-rerun.js +0 -1
- package/dist/src/evals/preparation-rerun.js.map +1 -1
- package/dist/src/evals/progress.js +92 -7
- package/dist/src/evals/progress.js.map +1 -1
- package/dist/src/evals/records.js +12 -2
- package/dist/src/evals/records.js.map +1 -1
- package/dist/src/evals/recovery.js +2 -0
- package/dist/src/evals/recovery.js.map +1 -1
- package/dist/src/evals/remote-rerun-execution.js +1 -3
- package/dist/src/evals/remote-rerun-execution.js.map +1 -1
- package/dist/src/evals/remote-verifier-rerun.js +1 -1
- package/dist/src/evals/remote-verifier-rerun.js.map +1 -1
- package/dist/src/evals/request.js +14 -45
- package/dist/src/evals/request.js.map +1 -1
- package/dist/src/evals/rerun-observation.js +31 -0
- package/dist/src/evals/rerun-observation.js.map +1 -0
- package/dist/src/evals/rerun.js +23 -28
- package/dist/src/evals/rerun.js.map +1 -1
- package/dist/src/evals/service.js +45 -42
- package/dist/src/evals/service.js.map +1 -1
- package/dist/src/evals/shared-runtime.js +125 -0
- package/dist/src/evals/shared-runtime.js.map +1 -0
- package/dist/src/evals/trial-import.js +10 -2
- package/dist/src/evals/trial-import.js.map +1 -1
- package/dist/src/evals/trial-publication-recovery.js +1 -1
- package/dist/src/evals/trial-publication-recovery.js.map +1 -1
- package/dist/src/evals/verifier-artifacts.js +1 -0
- package/dist/src/evals/verifier-artifacts.js.map +1 -1
- package/dist/src/evals/verifier-only-rerun.js +1 -1
- package/dist/src/evals/verifier-only-rerun.js.map +1 -1
- package/dist/src/evals/verifier-rerun-result.js +3 -2
- package/dist/src/evals/verifier-rerun-result.js.map +1 -1
- package/dist/src/evals/verifier-score-artifacts.js +9 -1
- package/dist/src/evals/verifier-score-artifacts.js.map +1 -1
- package/dist/src/foundation/config.js +0 -5
- package/dist/src/foundation/config.js.map +1 -1
- package/dist/src/inference/index.js +3 -22
- package/dist/src/inference/index.js.map +1 -1
- package/dist/src/inference/lock.js +44 -130
- package/dist/src/inference/lock.js.map +1 -1
- package/dist/src/model-access/endpoint-contract.js +127 -0
- package/dist/src/model-access/endpoint-contract.js.map +1 -0
- package/dist/src/model-access/endpoint-credentials.js +54 -0
- package/dist/src/model-access/endpoint-credentials.js.map +1 -0
- package/dist/src/model-access/endpoint-probe-stream.js +66 -0
- package/dist/src/model-access/endpoint-probe-stream.js.map +1 -0
- package/dist/src/model-access/endpoint-verification.js +76 -0
- package/dist/src/model-access/endpoint-verification.js.map +1 -0
- package/dist/src/model-access/endpoints.js +105 -0
- package/dist/src/model-access/endpoints.js.map +1 -0
- package/dist/src/model-access/index.js +5 -1
- package/dist/src/model-access/index.js.map +1 -1
- package/dist/src/model-access/proxy.js +57 -14
- package/dist/src/model-access/proxy.js.map +1 -1
- package/dist/src/model-access/remote-model.js +21 -29
- package/dist/src/model-access/remote-model.js.map +1 -1
- package/dist/src/model-access/usage.js +30 -0
- package/dist/src/model-access/usage.js.map +1 -0
- package/dist/src/runs/endpoint-runtime.js +40 -0
- package/dist/src/runs/endpoint-runtime.js.map +1 -0
- package/dist/src/runs/executor.js +28 -42
- package/dist/src/runs/executor.js.map +1 -1
- package/dist/src/runs/local-inference-environment.js +5 -53
- package/dist/src/runs/local-inference-environment.js.map +1 -1
- package/dist/src/runs/manifest.js +1 -0
- package/dist/src/runs/manifest.js.map +1 -1
- package/dist/src/runs/outcome.js +3 -10
- package/dist/src/runs/outcome.js.map +1 -1
- package/dist/src/runs/queued.js +3 -2
- package/dist/src/runs/queued.js.map +1 -1
- package/dist/src/runs/records.js +3 -0
- package/dist/src/runs/records.js.map +1 -1
- package/dist/src/runs/request.js +10 -38
- package/dist/src/runs/request.js.map +1 -1
- package/dist/src/workers/remote-harbor-work-spec.js +8 -10
- package/dist/src/workers/remote-harbor-work-spec.js.map +1 -1
- package/dist/src/workers/remote-harbor-worker.js +3 -0
- package/dist/src/workers/remote-harbor-worker.js.map +1 -1
- package/docs/evidence/eval-preparation-2026-10-09.json +393 -0
- package/docs/evidence/eval-preparation-2026-10-09.md +178 -0
- package/docs/local-model-inference-spec.zh-CN.md +4 -663
- package/docs/resource-storage.zh-CN.md +116 -0
- package/docs/schemas/endpoint-evaluation-attempt.schema.json +29 -0
- package/docs/schemas/eval-artifact-pin.schema.json +14 -0
- package/docs/schemas/eval-execution-plan.schema.json +486 -105
- package/docs/schemas/eval-progress.schema.json +1 -1
- package/docs/schemas/eval-request.schema.json +149 -18
- package/docs/schemas/eval-rerun-submission.schema.json +8 -1
- package/docs/schemas/eval-result.schema.json +310 -56
- package/docs/schemas/evaluation-binding.schema.json +15 -0
- package/docs/schemas/external-endpoint-binding.schema.json +153 -0
- package/docs/schemas/external-endpoint-config.schema.json +93 -0
- package/docs/schemas/inference-execution-evidence.schema.json +253 -3
- package/docs/schemas/inference-lock-v3.schema.json +6 -0
- package/docs/schemas/inference-lock.schema.json +842 -341
- package/docs/schemas/ordered-eval-control.schema.json +7 -1
- package/docs/schemas/ready-endpoint-binding.schema.json +201 -0
- package/docs/schemas/run-manifest.schema.json +6 -0
- package/docs/schemas/run-request.schema.json +122 -20
- package/examples/external-inference/.env.example +16 -0
- package/examples/external-inference/README.md +203 -0
- package/examples/external-inference/accept.mjs +297 -0
- package/examples/external-inference/compose.gateway.yaml +119 -0
- package/examples/external-inference/compose.single.yaml +64 -0
- package/examples/external-inference/compose.tp2.yaml +15 -0
- package/examples/external-inference/deployment.example.json +58 -0
- package/examples/external-inference/endpoint.chat-completions.example.json +13 -0
- package/examples/external-inference/endpoint.responses.example.json +13 -0
- package/integrations/harbor/hitch_build_inputs.py +213 -0
- package/integrations/harbor/hitch_build_process.py +126 -0
- package/integrations/harbor/hitch_compose_preparation.py +274 -0
- package/integrations/harbor/hitch_harbor_agent.py +25 -39
- package/integrations/harbor/hitch_harbor_environment.py +96 -3
- package/integrations/harbor/hitch_image_cache.py +357 -0
- package/integrations/harbor/hitch_python_runtime.py +303 -0
- package/integrations/harbor/hitch_shared_runtime.py +123 -0
- package/integrations/model-call/cli.js +11 -10
- package/integrations/training-tool/cli.js +5 -3
- package/node_modules/smol-toml/LICENSE +24 -0
- package/node_modules/smol-toml/README.md +418 -0
- package/node_modules/smol-toml/dist/date.d.ts +41 -0
- package/node_modules/smol-toml/dist/date.js +127 -0
- package/node_modules/smol-toml/dist/error.d.ts +38 -0
- package/node_modules/smol-toml/dist/error.js +63 -0
- package/node_modules/smol-toml/dist/extract.js +69 -0
- package/node_modules/smol-toml/dist/index.cjs +734 -0
- package/node_modules/smol-toml/dist/index.d.ts +43 -0
- package/node_modules/smol-toml/dist/index.js +33 -0
- package/node_modules/smol-toml/dist/parse.d.ts +36 -0
- package/node_modules/smol-toml/dist/parse.js +149 -0
- package/node_modules/smol-toml/dist/primitive.js +238 -0
- package/node_modules/smol-toml/dist/stringify.d.ts +31 -0
- package/node_modules/smol-toml/dist/stringify.js +181 -0
- package/node_modules/smol-toml/dist/struct.js +179 -0
- package/node_modules/smol-toml/dist/util.d.ts +38 -0
- package/node_modules/smol-toml/dist/util.js +89 -0
- package/node_modules/smol-toml/package.json +68 -0
- package/package.json +15 -4
- package/dist/src/cli/commands/local.js +0 -130
- package/dist/src/cli/commands/local.js.map +0 -1
- package/dist/src/cli/commands/model-node.js +0 -26
- package/dist/src/cli/commands/model-node.js.map +0 -1
- package/dist/src/control-plane/inference-manager.js +0 -357
- package/dist/src/control-plane/inference-manager.js.map +0 -1
- package/dist/src/control-plane/managed-gateway-receipt.js +0 -26
- package/dist/src/control-plane/managed-gateway-receipt.js.map +0 -1
- package/dist/src/control-plane/managed-service-recovery.js +0 -158
- package/dist/src/control-plane/managed-service-recovery.js.map +0 -1
- package/dist/src/daemon/inference-prepare.js +0 -47
- package/dist/src/daemon/inference-prepare.js.map +0 -1
- package/dist/src/inference/daemon-capacity.js +0 -41
- package/dist/src/inference/daemon-capacity.js.map +0 -1
- package/dist/src/inference/device-reservation.js +0 -58
- package/dist/src/inference/device-reservation.js.map +0 -1
- package/dist/src/inference/doctor.js +0 -95
- package/dist/src/inference/doctor.js.map +0 -1
- package/dist/src/inference/generation-recovery.js +0 -83
- package/dist/src/inference/generation-recovery.js.map +0 -1
- package/dist/src/inference/managed-launcher.js +0 -35
- package/dist/src/inference/managed-launcher.js.map +0 -1
- package/dist/src/inference/materialize.js +0 -66
- package/dist/src/inference/materialize.js.map +0 -1
- package/dist/src/inference/model-location.js +0 -34
- package/dist/src/inference/model-location.js.map +0 -1
- package/dist/src/inference/node-client.js +0 -167
- package/dist/src/inference/node-client.js.map +0 -1
- package/dist/src/inference/node-registry.js +0 -79
- package/dist/src/inference/node-registry.js.map +0 -1
- package/dist/src/inference/observation.js +0 -79
- package/dist/src/inference/observation.js.map +0 -1
- package/dist/src/inference/preflight.js +0 -132
- package/dist/src/inference/preflight.js.map +0 -1
- package/dist/src/inference/process-sglang.js +0 -169
- package/dist/src/inference/process-sglang.js.map +0 -1
- package/dist/src/inference/protocol-probe.js +0 -29
- package/dist/src/inference/protocol-probe.js.map +0 -1
- package/dist/src/inference/remote-model-store.js +0 -24
- package/dist/src/inference/remote-model-store.js.map +0 -1
- package/dist/src/inference/runtime-catalog.js +0 -41
- package/dist/src/inference/runtime-catalog.js.map +0 -1
- package/dist/src/inference/runtime-store.js +0 -93
- package/dist/src/inference/runtime-store.js.map +0 -1
- package/dist/src/inference/service-attachment.js +0 -46
- package/dist/src/inference/service-attachment.js.map +0 -1
- package/dist/src/inference/service-inspection.js +0 -38
- package/dist/src/inference/service-inspection.js.map +0 -1
- package/dist/src/inference/sglang.js +0 -279
- package/dist/src/inference/sglang.js.map +0 -1
- package/dist/src/inference/supervisor.js +0 -460
- package/dist/src/inference/supervisor.js.map +0 -1
- package/dist/src/model-access/local-gateway-request.js +0 -139
- package/dist/src/model-access/local-gateway-request.js.map +0 -1
- package/dist/src/model-access/local-gateway.js +0 -323
- package/dist/src/model-access/local-gateway.js.map +0 -1
- package/dist/src/runs/local-inference-run.js +0 -53
- package/dist/src/runs/local-inference-run.js.map +0 -1
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Conservative, content-addressed local Docker build inputs.
|
|
2
|
+
|
|
3
|
+
Unsupported inputs keep Harbor's original build path. Snapshot before hashing:
|
|
4
|
+
the builder consumes exactly the bytes named by the key, never a mutable source
|
|
5
|
+
directory. Task names, runtime mounts and ownership labels are not build inputs.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import stat
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UnsupportedBuild(ValueError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@contextmanager
|
|
25
|
+
def read_context():
|
|
26
|
+
# Docker can ignore unreadable entries using .dockerignore; our complete
|
|
27
|
+
# snapshot cannot. Only source I/O becomes fallback, not destination errors.
|
|
28
|
+
try:
|
|
29
|
+
yield
|
|
30
|
+
except OSError as error:
|
|
31
|
+
raise UnsupportedBuild("context-read") from error
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def digest(value):
|
|
35
|
+
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_spec(service, default_platform):
|
|
39
|
+
build = service.get("build")
|
|
40
|
+
if not isinstance(build, dict) or set(build) - {"context", "dockerfile", "args", "target", "labels"}:
|
|
41
|
+
raise UnsupportedBuild("build-options")
|
|
42
|
+
if service.get("pull_policy") not in (None, "build", "missing", "if_not_present", "never"):
|
|
43
|
+
raise UnsupportedBuild("pull-policy")
|
|
44
|
+
context = build.get("context")
|
|
45
|
+
dockerfile = build.get("dockerfile", "Dockerfile")
|
|
46
|
+
# Resolved config re-escapes paths too, so an existing '$$' sibling could
|
|
47
|
+
# otherwise be mistaken for Compose's actual '$' input tree or Dockerfile.
|
|
48
|
+
if any(isinstance(value, str) and "$" in value for value in (context, dockerfile)):
|
|
49
|
+
raise UnsupportedBuild("escaped-build-paths")
|
|
50
|
+
if not isinstance(context, str) or not Path(context).is_absolute():
|
|
51
|
+
raise UnsupportedBuild("nonlocal-context")
|
|
52
|
+
source = Path(context)
|
|
53
|
+
if source.resolve() != source or not source.is_dir():
|
|
54
|
+
raise UnsupportedBuild("context-path")
|
|
55
|
+
if (not isinstance(dockerfile, str) or not dockerfile or "\\" in dockerfile
|
|
56
|
+
or Path(dockerfile).is_absolute() or any(p in ("", ".", "..") for p in dockerfile.split("/"))):
|
|
57
|
+
raise UnsupportedBuild("dockerfile-path")
|
|
58
|
+
platform = service.get("platform") or default_platform
|
|
59
|
+
if platform not in ("linux/amd64", "linux/arm64"):
|
|
60
|
+
raise UnsupportedBuild("platform")
|
|
61
|
+
args, labels = build.get("args", {}), build.get("labels", {})
|
|
62
|
+
for values in (args, labels):
|
|
63
|
+
if not isinstance(values, dict) or any(not isinstance(k, str) or not k or not isinstance(v, str) for k, v in values.items()):
|
|
64
|
+
raise UnsupportedBuild("unresolved-build-values")
|
|
65
|
+
# Compose config serializes literal dollars differently from its
|
|
66
|
+
# execution plan. Keep native interpolation/escaping for such inputs.
|
|
67
|
+
if any("$" in key or "$" in value for key, value in values.items()):
|
|
68
|
+
raise UnsupportedBuild("escaped-build-values")
|
|
69
|
+
if "BUILDKIT_SYNTAX" in args:
|
|
70
|
+
# This reserved argument selects a frontend just like a # syntax
|
|
71
|
+
# directive; even pinned frontends can introduce untracked inputs.
|
|
72
|
+
raise UnsupportedBuild("custom-frontend")
|
|
73
|
+
if "io.hitch.prepared-key" in labels:
|
|
74
|
+
raise UnsupportedBuild("reserved-cache-label")
|
|
75
|
+
target = build.get("target")
|
|
76
|
+
if target is not None and (not isinstance(target, str) or not re.fullmatch(r"[a-zA-Z0-9_.-]+", target)):
|
|
77
|
+
raise UnsupportedBuild("build-target")
|
|
78
|
+
return source, {"dockerfile": dockerfile, "platform": platform, "args": args, "labels": labels, "target": target}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def pinned_dockerfile(source):
|
|
82
|
+
with read_context():
|
|
83
|
+
if source.stat().st_size > 1024 * 1024:
|
|
84
|
+
raise UnsupportedBuild("dockerfile-size")
|
|
85
|
+
try:
|
|
86
|
+
content = source.read_text(encoding="utf-8")
|
|
87
|
+
except UnicodeDecodeError as error:
|
|
88
|
+
# Docker can accept non-UTF-8 comment bytes. Preserve its parser
|
|
89
|
+
# and the original input instead of decoding lossily for caching.
|
|
90
|
+
raise UnsupportedBuild("dockerfile-encoding") from error
|
|
91
|
+
# Restrict parser directives and syntax we cannot unambiguously inspect.
|
|
92
|
+
if "\0" in content or "<<" in content or re.search(r"(?im)^\s*#\s*escape\s*=", content):
|
|
93
|
+
raise UnsupportedBuild("dockerfile-syntax")
|
|
94
|
+
if re.search(r"(?im)^\s*#\s*syntax\s*=", content):
|
|
95
|
+
# Even a pinned custom frontend can introduce untracked input sources.
|
|
96
|
+
raise UnsupportedBuild("custom-frontend")
|
|
97
|
+
stages, bases = set(), []
|
|
98
|
+
logical = re.sub(r"\\\r?\n", " ", "\n".join(line for line in content.splitlines() if not line.lstrip().startswith("#")))
|
|
99
|
+
for line in logical.splitlines():
|
|
100
|
+
line = line.strip()
|
|
101
|
+
if not line or line.startswith("#"):
|
|
102
|
+
continue
|
|
103
|
+
tokens = re.split(r"\s+", line, maxsplit=1)
|
|
104
|
+
instruction, body = tokens[0], tokens[1] if len(tokens) == 2 else ""
|
|
105
|
+
instruction = instruction.upper()
|
|
106
|
+
if instruction not in {"FROM", "RUN", "CMD", "LABEL", "MAINTAINER", "EXPOSE", "ENV", "ENTRYPOINT", "VOLUME", "USER", "WORKDIR", "ARG", "STOPSIGNAL", "HEALTHCHECK", "SHELL", "COPY", "ADD", "ONBUILD"}:
|
|
107
|
+
raise UnsupportedBuild("dockerfile-instruction")
|
|
108
|
+
if instruction == "FROM":
|
|
109
|
+
match = re.fullmatch(r"(?:--platform=(linux/(?:amd64|arm64))\s+)?(\S+)(?:\s+[Aa][Ss]\s+([\w.-]+))?", body.strip())
|
|
110
|
+
if not match:
|
|
111
|
+
raise UnsupportedBuild("dynamic-base")
|
|
112
|
+
if match[1]:
|
|
113
|
+
raise UnsupportedBuild("per-stage-platform")
|
|
114
|
+
base, alias = match[2], match[3]
|
|
115
|
+
if base.lower() not in stages and base != "scratch":
|
|
116
|
+
if not re.fullmatch(r"[^\s$]+@sha256:[a-f0-9]{64}", base):
|
|
117
|
+
raise UnsupportedBuild("mutable-base")
|
|
118
|
+
bases.append(base)
|
|
119
|
+
if alias:
|
|
120
|
+
stages.add(alias.lower())
|
|
121
|
+
elif instruction == "ADD":
|
|
122
|
+
# ADD can read remote URLs or Git trees, independent of the context.
|
|
123
|
+
raise UnsupportedBuild("add-input")
|
|
124
|
+
elif instruction == "COPY":
|
|
125
|
+
if "--from" in body and not re.search(r"--from=\S+", body):
|
|
126
|
+
raise UnsupportedBuild("copy-source-syntax")
|
|
127
|
+
for external in re.findall(r"--from=(\S+)", body):
|
|
128
|
+
if external.lower() not in stages and not external.isdigit() and not re.fullmatch(r"[^\s$]+@sha256:[a-f0-9]{64}", external):
|
|
129
|
+
raise UnsupportedBuild("mutable-copy-source")
|
|
130
|
+
elif instruction == "RUN" and re.search(r"--(?:mount|device|security)=", body):
|
|
131
|
+
raise UnsupportedBuild("external-run-input")
|
|
132
|
+
elif instruction == "ONBUILD":
|
|
133
|
+
raise UnsupportedBuild("onbuild-input")
|
|
134
|
+
if not re.search(r"(?im)^\s*FROM\s", logical):
|
|
135
|
+
raise UnsupportedBuild("missing-base")
|
|
136
|
+
return sorted(set(bases))
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def snapshot_context(source, target):
|
|
140
|
+
listxattr = getattr(os, "listxattr", None)
|
|
141
|
+
if not callable(listxattr):
|
|
142
|
+
raise UnsupportedBuild("context-xattr-inspection")
|
|
143
|
+
entries = []
|
|
144
|
+
|
|
145
|
+
async def copy(current, destination, relative):
|
|
146
|
+
with read_context():
|
|
147
|
+
info = current.lstat()
|
|
148
|
+
mode = stat.S_IMODE(info.st_mode)
|
|
149
|
+
try:
|
|
150
|
+
attributes = listxattr(current, follow_symlinks=False)
|
|
151
|
+
except (OSError, NotImplementedError) as error:
|
|
152
|
+
# An uninspectable context must use the original build path; never
|
|
153
|
+
# assume unsupported metadata is absent from the cache identity.
|
|
154
|
+
raise UnsupportedBuild("context-xattr-inspection") from error
|
|
155
|
+
if attributes:
|
|
156
|
+
raise UnsupportedBuild("context-xattrs")
|
|
157
|
+
if stat.S_ISDIR(info.st_mode):
|
|
158
|
+
destination.mkdir(mode=0o700)
|
|
159
|
+
with read_context():
|
|
160
|
+
children = sorted(current.iterdir(), key=lambda p: os.fsencode(p.name))
|
|
161
|
+
entries.append([relative, "directory", mode])
|
|
162
|
+
for child in children:
|
|
163
|
+
await copy(child, destination / child.name, f"{relative}/{child.name}" if relative else child.name)
|
|
164
|
+
with read_context():
|
|
165
|
+
if [p.name for p in children] != sorted(os.listdir(current), key=os.fsencode):
|
|
166
|
+
raise UnsupportedBuild("context-changed")
|
|
167
|
+
os.chmod(destination, mode)
|
|
168
|
+
os.utime(destination, ns=(info.st_atime_ns, info.st_mtime_ns))
|
|
169
|
+
elif stat.S_ISREG(info.st_mode):
|
|
170
|
+
if info.st_nlink != 1:
|
|
171
|
+
# COPY can preserve link relationships. Separate copies and a
|
|
172
|
+
# byte-only identity cannot represent them, even on a hit.
|
|
173
|
+
raise UnsupportedBuild("context-hardlinks")
|
|
174
|
+
checksum = hashlib.sha256()
|
|
175
|
+
with read_context():
|
|
176
|
+
fd = os.open(current, os.O_RDONLY | os.O_NOFOLLOW)
|
|
177
|
+
with os.fdopen(fd, "rb") as reader, destination.open("xb") as writer:
|
|
178
|
+
with read_context():
|
|
179
|
+
before = os.fstat(reader.fileno())
|
|
180
|
+
if (before.st_ino, before.st_dev) != (info.st_ino, info.st_dev):
|
|
181
|
+
raise UnsupportedBuild("context-changed")
|
|
182
|
+
while True:
|
|
183
|
+
with read_context():
|
|
184
|
+
chunk = reader.read(1024 * 1024)
|
|
185
|
+
if not chunk:
|
|
186
|
+
break
|
|
187
|
+
checksum.update(chunk)
|
|
188
|
+
writer.write(chunk)
|
|
189
|
+
await asyncio.sleep(0)
|
|
190
|
+
with read_context():
|
|
191
|
+
after = os.fstat(reader.fileno())
|
|
192
|
+
if (before.st_size, before.st_mtime_ns, before.st_ctime_ns) != (after.st_size, after.st_mtime_ns, after.st_ctime_ns):
|
|
193
|
+
raise UnsupportedBuild("context-changed")
|
|
194
|
+
os.chmod(destination, mode)
|
|
195
|
+
os.utime(destination, ns=(info.st_atime_ns, info.st_mtime_ns))
|
|
196
|
+
entries.append([relative, "file", mode, checksum.hexdigest()])
|
|
197
|
+
else:
|
|
198
|
+
raise UnsupportedBuild("special-context-entry")
|
|
199
|
+
await asyncio.sleep(0)
|
|
200
|
+
|
|
201
|
+
await copy(source, target, "")
|
|
202
|
+
return digest(entries)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def remove_snapshot(directory):
|
|
206
|
+
# Source modes may be read-only. Never follow a symlink while cleaning up.
|
|
207
|
+
for base, directories, _ in os.walk(directory, followlinks=False):
|
|
208
|
+
os.chmod(base, 0o700)
|
|
209
|
+
for child in directories:
|
|
210
|
+
candidate = Path(base) / child
|
|
211
|
+
if not candidate.is_symlink():
|
|
212
|
+
os.chmod(candidate, 0o700)
|
|
213
|
+
shutil.rmtree(directory)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Supervise one Docker command across loss of its Harbor parent.
|
|
2
|
+
|
|
3
|
+
The parent owns stdin's write end and sends one byte only after both output
|
|
4
|
+
pipes close. EOF without that handshake means cancellation or parent death.
|
|
5
|
+
Inherited cache locks remain held here until the command group is terminated.
|
|
6
|
+
This file deliberately depends only on the Python standard library.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import os
|
|
12
|
+
import signal
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def group_has_live_members(pgid):
|
|
18
|
+
try:
|
|
19
|
+
os.killpg(pgid, 0)
|
|
20
|
+
except ProcessLookupError:
|
|
21
|
+
return False
|
|
22
|
+
except PermissionError:
|
|
23
|
+
# macOS can return EPERM for a group awaiting zombie reaping. A real
|
|
24
|
+
# permission failure also cannot establish that its members exited.
|
|
25
|
+
return True
|
|
26
|
+
if sys.platform != "linux":
|
|
27
|
+
return True
|
|
28
|
+
# An orphan zombie can remain until init reaps it, but cannot hold files
|
|
29
|
+
# or execute. A live D-state helper, however, can outlast SIGKILL.
|
|
30
|
+
try:
|
|
31
|
+
with os.scandir("/proc") as entries:
|
|
32
|
+
for entry in entries:
|
|
33
|
+
if not entry.name.isdecimal():
|
|
34
|
+
continue
|
|
35
|
+
try:
|
|
36
|
+
with open(f"{entry.path}/stat") as stream:
|
|
37
|
+
fields = stream.read().rpartition(")")[2].split()
|
|
38
|
+
except (FileNotFoundError, ProcessLookupError):
|
|
39
|
+
continue
|
|
40
|
+
if int(fields[2]) == pgid and fields[0] not in ("Z", "X"):
|
|
41
|
+
return True
|
|
42
|
+
except (OSError, ValueError, IndexError):
|
|
43
|
+
# Failed observation cannot authorize release of build ownership.
|
|
44
|
+
return True
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def stop_group(process):
|
|
49
|
+
try:
|
|
50
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
51
|
+
except ProcessLookupError:
|
|
52
|
+
await process.wait()
|
|
53
|
+
return
|
|
54
|
+
except PermissionError:
|
|
55
|
+
pass
|
|
56
|
+
deadline = time.monotonic() + 5
|
|
57
|
+
while time.monotonic() < deadline:
|
|
58
|
+
try:
|
|
59
|
+
os.killpg(process.pid, 0)
|
|
60
|
+
except ProcessLookupError:
|
|
61
|
+
break
|
|
62
|
+
except PermissionError:
|
|
63
|
+
pass
|
|
64
|
+
await asyncio.sleep(0.02)
|
|
65
|
+
else:
|
|
66
|
+
try:
|
|
67
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
68
|
+
except (ProcessLookupError, PermissionError):
|
|
69
|
+
pass
|
|
70
|
+
await process.wait()
|
|
71
|
+
while group_has_live_members(process.pid):
|
|
72
|
+
await asyncio.sleep(0.05)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def supervise(command):
|
|
76
|
+
loop = asyncio.get_running_loop()
|
|
77
|
+
interrupted = asyncio.Event()
|
|
78
|
+
for signum in (signal.SIGTERM, signal.SIGINT):
|
|
79
|
+
loop.add_signal_handler(signum, interrupted.set)
|
|
80
|
+
reader = asyncio.StreamReader()
|
|
81
|
+
transport, _ = await loop.connect_read_pipe(lambda: asyncio.StreamReaderProtocol(reader), sys.stdin.buffer)
|
|
82
|
+
process = None
|
|
83
|
+
pending = []
|
|
84
|
+
try:
|
|
85
|
+
# The liveness pipe and inherited locks belong to this supervisor only;
|
|
86
|
+
# Docker and its plugins receive neither, including through stdin.
|
|
87
|
+
process = await asyncio.create_subprocess_exec(*command, stdin=asyncio.subprocess.DEVNULL, start_new_session=True)
|
|
88
|
+
# Release our copies of the output pipes while keeping Python's
|
|
89
|
+
# streams valid. Buffered output or shutdown diagnostics must not
|
|
90
|
+
# replace the command's exit status with a stream-flush failure.
|
|
91
|
+
with open(os.devnull, "wb") as sink:
|
|
92
|
+
os.dup2(sink.fileno(), sys.stdout.fileno())
|
|
93
|
+
os.dup2(sink.fileno(), sys.stderr.fileno())
|
|
94
|
+
exited = asyncio.create_task(process.wait())
|
|
95
|
+
control = asyncio.create_task(reader.read(1))
|
|
96
|
+
stopped = asyncio.create_task(interrupted.wait())
|
|
97
|
+
pending = [exited, control, stopped]
|
|
98
|
+
acknowledged = False
|
|
99
|
+
while True:
|
|
100
|
+
watched = [control, stopped] if exited.done() and not acknowledged else [exited, control, stopped]
|
|
101
|
+
done, _ = await asyncio.wait(watched, return_when=asyncio.FIRST_COMPLETED)
|
|
102
|
+
if stopped in done:
|
|
103
|
+
return 125
|
|
104
|
+
if control in done:
|
|
105
|
+
if control.result() != b"1" or acknowledged:
|
|
106
|
+
return 125
|
|
107
|
+
acknowledged = True
|
|
108
|
+
control = asyncio.create_task(reader.read(1))
|
|
109
|
+
pending.append(control)
|
|
110
|
+
if acknowledged and exited.done():
|
|
111
|
+
code = exited.result()
|
|
112
|
+
return code if code >= 0 else 128 - code
|
|
113
|
+
finally:
|
|
114
|
+
# A successful leader and closed output pipes do not prove that a
|
|
115
|
+
# plugin's silent helper stopped. Reclaim the group on every exit.
|
|
116
|
+
if process is not None:
|
|
117
|
+
await stop_group(process)
|
|
118
|
+
for task in pending:
|
|
119
|
+
if not task.done():
|
|
120
|
+
task.cancel()
|
|
121
|
+
await asyncio.gather(*pending, return_exceptions=True)
|
|
122
|
+
transport.close()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
raise SystemExit(asyncio.run(supervise(sys.argv[1:])))
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Prepare immutable images and Harbor's own keepalive without pooling tasks."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import tempfile
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from hitch_build_inputs import UnsupportedBuild, build_spec, digest, pinned_dockerfile
|
|
11
|
+
from hitch_image_cache import ImagePreparationCache, atomic_json, docker, image_matches_platform
|
|
12
|
+
|
|
13
|
+
_DEFAULT_COMMAND = ["sh", "-c", "sleep infinity"]
|
|
14
|
+
_MANAGED_COMMAND = ["sh", "-c", 'trap \'kill "$child"; wait "$child"; exit 0\' TERM INT; sleep infinity & child=$!; wait "$child"']
|
|
15
|
+
_LIFECYCLE_FIELDS = ("init", "stop_signal", "stop_grace_period", "pre_stop")
|
|
16
|
+
_RUNTIME_PLATFORM_LABEL = "io.hitch.preparation.default-platform"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def compose_document(output):
|
|
20
|
+
# Harbor merges stderr into stdout. Benign Compose interpolation warnings
|
|
21
|
+
# may precede the document; only accept a complete trailing JSON object.
|
|
22
|
+
lines = output.splitlines(keepends=True)
|
|
23
|
+
for index, line in enumerate(lines):
|
|
24
|
+
if line.lstrip().startswith("{"):
|
|
25
|
+
try:
|
|
26
|
+
document = json.loads("".join(lines[index:]))
|
|
27
|
+
if isinstance(document, dict):
|
|
28
|
+
return document
|
|
29
|
+
except ValueError:
|
|
30
|
+
pass
|
|
31
|
+
raise UnsupportedBuild("compose-config-output")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def compose_services(output):
|
|
35
|
+
services = compose_document(output).get("services")
|
|
36
|
+
if not isinstance(services, dict):
|
|
37
|
+
raise UnsupportedBuild("compose-config-output")
|
|
38
|
+
return services
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def resolved_build_platforms(builds, output):
|
|
42
|
+
# The project .env participates in build defaults without appearing in
|
|
43
|
+
# service config or the parent's process environment. Ask Compose itself.
|
|
44
|
+
targets = compose_document(output).get("target")
|
|
45
|
+
if not isinstance(targets, dict) or set(targets) != set(builds):
|
|
46
|
+
raise UnsupportedBuild("compose-build-plan")
|
|
47
|
+
resolved = {}
|
|
48
|
+
for name, service in builds.items():
|
|
49
|
+
target = targets[name]
|
|
50
|
+
if not isinstance(target, dict):
|
|
51
|
+
raise UnsupportedBuild("compose-build-plan")
|
|
52
|
+
platforms = target.get("platforms", [])
|
|
53
|
+
if not isinstance(platforms, list) or len(platforms) > 1 or any(p not in ("linux/amd64", "linux/arm64") for p in platforms):
|
|
54
|
+
raise UnsupportedBuild("compose-build-platform")
|
|
55
|
+
resolved[name] = {**service, "platform": platforms[0]} if platforms else service
|
|
56
|
+
return resolved
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def image_tag_key(reference):
|
|
60
|
+
# Compare Compose-resolved names using Docker's familiar-name defaults.
|
|
61
|
+
# Digest references are not mutable build tags. Keep their identity intact.
|
|
62
|
+
if "@" in reference:
|
|
63
|
+
return reference
|
|
64
|
+
registry, separator, repository = reference.partition("/")
|
|
65
|
+
if not separator or (registry != "localhost" and "." not in registry
|
|
66
|
+
and ":" not in registry and registry == registry.lower()):
|
|
67
|
+
registry, repository = "docker.io", reference
|
|
68
|
+
if registry == "index.docker.io":
|
|
69
|
+
registry = "docker.io"
|
|
70
|
+
if registry == "docker.io" and "/" not in repository:
|
|
71
|
+
repository = "library/" + repository
|
|
72
|
+
if ":" not in repository.rsplit("/", 1)[-1]:
|
|
73
|
+
repository += ":latest"
|
|
74
|
+
return f"{registry}/{repository}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def refreshes_latest(reference, policy):
|
|
78
|
+
return policy not in ("never", "build") and image_tag_key(reference).endswith(":latest")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def require_independent_build_images(services):
|
|
82
|
+
references = {}
|
|
83
|
+
unnamed_build = False
|
|
84
|
+
image_only = False
|
|
85
|
+
for service in services.values():
|
|
86
|
+
builds = bool(service.get("build"))
|
|
87
|
+
reference = service.get("image")
|
|
88
|
+
if reference:
|
|
89
|
+
references.setdefault(image_tag_key(reference), []).append(builds)
|
|
90
|
+
elif builds:
|
|
91
|
+
unnamed_build = True
|
|
92
|
+
for mount in service.get("volumes") or []:
|
|
93
|
+
if isinstance(mount, dict) and mount.get("type") == "image":
|
|
94
|
+
source = mount.get("source")
|
|
95
|
+
if not isinstance(source, str) or not source:
|
|
96
|
+
raise UnsupportedBuild("image-mount-source")
|
|
97
|
+
references.setdefault(image_tag_key(source), []).append(False)
|
|
98
|
+
if not builds:
|
|
99
|
+
image_only = True
|
|
100
|
+
# Cached builds publish private tags, not Compose's shared mutable names.
|
|
101
|
+
# Leave tag publication, pull policy and competing producers to Compose.
|
|
102
|
+
if any(len(uses) > 1 and any(uses) for uses in references.values()):
|
|
103
|
+
raise UnsupportedBuild("shared-build-image")
|
|
104
|
+
# Any explicitly named service can consume or overwrite a generated tag,
|
|
105
|
+
# including another builder. Do not guess Compose's project-name rules.
|
|
106
|
+
# All-unnamed builders remain independent: service names are unique.
|
|
107
|
+
if unnamed_build and (references or image_only):
|
|
108
|
+
raise UnsupportedBuild("implicit-build-image")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ComposePreparation:
|
|
112
|
+
def __init__(self, environment, cache_directory=None, build_slots=2, managed_keepalive=False):
|
|
113
|
+
self.environment = environment
|
|
114
|
+
self.cache_directory, self.build_slots = cache_directory, build_slots
|
|
115
|
+
self.managed_keepalive = managed_keepalive
|
|
116
|
+
self.images = {}
|
|
117
|
+
self.overlay = {}
|
|
118
|
+
self.path = None
|
|
119
|
+
self.temporary = None
|
|
120
|
+
self.receipts = []
|
|
121
|
+
self.force_build = False
|
|
122
|
+
self.runtime_prepared = False
|
|
123
|
+
|
|
124
|
+
def reset(self, force_build):
|
|
125
|
+
if self.temporary is not None:
|
|
126
|
+
self.temporary.cleanup()
|
|
127
|
+
self.images, self.overlay, self.receipts = {}, {}, []
|
|
128
|
+
self.path, self.temporary = None, None
|
|
129
|
+
self.runtime_prepared, self.force_build = False, force_build
|
|
130
|
+
if force_build and self.cache_directory:
|
|
131
|
+
self.record({"event": "bypass", "reason": "force-build"})
|
|
132
|
+
|
|
133
|
+
async def build(self, run):
|
|
134
|
+
if not self.cache_directory or self.force_build:
|
|
135
|
+
return None
|
|
136
|
+
env = self.environment
|
|
137
|
+
cache = ImagePreparationCache(self.cache_directory, env._compose_env_vars(), self.build_slots)
|
|
138
|
+
result = await run(["config", "--format", "json"])
|
|
139
|
+
try:
|
|
140
|
+
services = compose_services(result.stdout)
|
|
141
|
+
builds = {name: value for name, value in services.items() if value.get("build")}
|
|
142
|
+
if not builds:
|
|
143
|
+
return result.model_copy(update={"stdout": "", "stderr": ""})
|
|
144
|
+
# A composition with unsupported inputs uses its original build path
|
|
145
|
+
# as a whole, including inter-service dependencies and extra contexts.
|
|
146
|
+
require_independent_build_images(services)
|
|
147
|
+
# Preserve documented latest refresh semantics across Compose
|
|
148
|
+
# versions. Leave the native choice of local or pulled image to up.
|
|
149
|
+
if any(service.get("image") and refreshes_latest(service["image"], service.get("pull_policy"))
|
|
150
|
+
for service in builds.values()):
|
|
151
|
+
raise UnsupportedBuild("refreshable-build-image")
|
|
152
|
+
plan = await run(["build", "--print"], check=False)
|
|
153
|
+
if plan.return_code != 0:
|
|
154
|
+
# Older Compose versions and conflicting defaults retain their
|
|
155
|
+
# native build behavior, including the original error handling.
|
|
156
|
+
raise UnsupportedBuild("compose-build-plan")
|
|
157
|
+
builds = resolved_build_platforms(builds, plan.stdout)
|
|
158
|
+
daemon = await cache.identity()
|
|
159
|
+
for service in builds.values():
|
|
160
|
+
context, spec = build_spec(service, cache.env.get("DOCKER_DEFAULT_PLATFORM") or daemon["platform"])
|
|
161
|
+
pinned_dockerfile(context / spec["dockerfile"])
|
|
162
|
+
prepared = {name: await cache.prepare(value) for name, value in builds.items()}
|
|
163
|
+
except UnsupportedBuild as error:
|
|
164
|
+
self.record({"event": "fallback", "reason": str(error)})
|
|
165
|
+
return None
|
|
166
|
+
for name, receipt in prepared.items():
|
|
167
|
+
self.images[name] = receipt["image"]
|
|
168
|
+
self.overlay[name] = {"image": receipt["image"], "build": "__RESET__", "pull_policy": "never"}
|
|
169
|
+
self.record({"event": "image", "service": name, **receipt})
|
|
170
|
+
if prepared:
|
|
171
|
+
self.write_overlay()
|
|
172
|
+
return result.model_copy(update={"stdout": "", "stderr": ""})
|
|
173
|
+
|
|
174
|
+
async def runtime(self, run):
|
|
175
|
+
if self.runtime_prepared or not self.managed_keepalive:
|
|
176
|
+
return
|
|
177
|
+
self.runtime_prepared = True
|
|
178
|
+
env = self.environment
|
|
179
|
+
if getattr(env, "_is_windows_container", False) or self.custom_keepalive():
|
|
180
|
+
return
|
|
181
|
+
# Resolve dotenv defaults through Compose itself. A config-only label
|
|
182
|
+
# yields an unambiguous JSON value even when other environment values
|
|
183
|
+
# contain newlines. This temporary probe never reaches container up.
|
|
184
|
+
with tempfile.TemporaryDirectory(prefix="hitch-compose-platform-") as directory:
|
|
185
|
+
probe = Path(directory) / "compose.json"
|
|
186
|
+
probe.write_text(json.dumps({"services": {"main": {"labels": {
|
|
187
|
+
_RUNTIME_PLATFORM_LABEL: "${DOCKER_DEFAULT_PLATFORM:-}",
|
|
188
|
+
}}}}))
|
|
189
|
+
result = await run(["-f", str(probe), "config", "--format", "json"])
|
|
190
|
+
try:
|
|
191
|
+
service = compose_services(result.stdout)["main"]
|
|
192
|
+
except UnsupportedBuild:
|
|
193
|
+
return
|
|
194
|
+
platform = service.get("platform") or (service.get("labels") or {}).get(_RUNTIME_PLATFORM_LABEL)
|
|
195
|
+
if platform not in (None, "", "linux/amd64", "linux/arm64"):
|
|
196
|
+
return
|
|
197
|
+
if (service.get("command") != _DEFAULT_COMMAND or service.get("entrypoint")
|
|
198
|
+
or any(key in service for key in _LIFECYCLE_FIELDS)):
|
|
199
|
+
return
|
|
200
|
+
reference = service.get("image") or env._main_image_name
|
|
201
|
+
policy = service.get("pull_policy")
|
|
202
|
+
# A successful local inspection says nothing about a replacement image
|
|
203
|
+
# pulled or rebuilt by up. Compose also refreshes latest under missing.
|
|
204
|
+
if (policy not in (None, "never", "missing", "if_not_present")
|
|
205
|
+
or refreshes_latest(reference, policy)):
|
|
206
|
+
return
|
|
207
|
+
try:
|
|
208
|
+
raw = await docker(["image", "inspect", "--format", "{{json .}}", reference],
|
|
209
|
+
env=env._compose_env_vars(), timeout=30, check=False)
|
|
210
|
+
if not raw:
|
|
211
|
+
return
|
|
212
|
+
image = json.loads(raw)
|
|
213
|
+
except (TimeoutError, OSError, ValueError):
|
|
214
|
+
# Metadata is optional for native startup. The Docker supervisor
|
|
215
|
+
# has already drained command cleanup before a timeout reaches us.
|
|
216
|
+
self.record({"event": "fallback", "reason": "keepalive-image-inspection"})
|
|
217
|
+
return
|
|
218
|
+
if not isinstance(image, dict) or not isinstance(image.get("Config", {}), dict):
|
|
219
|
+
return
|
|
220
|
+
if platform and not image_matches_platform(image, platform):
|
|
221
|
+
# Compose may pull/select a different variant if its default
|
|
222
|
+
# local inspection does not match the requested runtime platform.
|
|
223
|
+
return
|
|
224
|
+
config = image.get("Config", {})
|
|
225
|
+
stop_signal = service.get("stop_signal", config.get("StopSignal") or "SIGTERM")
|
|
226
|
+
if image.get("Os") != "linux" or config.get("Entrypoint") or stop_signal not in ("SIGTERM", "SIGINT", "15", "2"):
|
|
227
|
+
return
|
|
228
|
+
self.overlay.setdefault("main", {})["command"] = _MANAGED_COMMAND
|
|
229
|
+
self.write_overlay()
|
|
230
|
+
self.record({"event": "managed-keepalive", "service": "main"})
|
|
231
|
+
|
|
232
|
+
def custom_keepalive(self):
|
|
233
|
+
env = self.environment
|
|
234
|
+
for source in [env._environment_docker_compose_path, *env.extra_docker_compose_paths]:
|
|
235
|
+
if source.exists():
|
|
236
|
+
try:
|
|
237
|
+
document = yaml.safe_load(source.read_text(encoding="utf-8"))
|
|
238
|
+
except yaml.YAMLError:
|
|
239
|
+
# Compose supports tags such as !reset and !override that
|
|
240
|
+
# PyYAML does not. Leave their interpretation to Compose.
|
|
241
|
+
return True
|
|
242
|
+
if document is None:
|
|
243
|
+
continue
|
|
244
|
+
if not isinstance(document, dict) or "include" in document:
|
|
245
|
+
return True
|
|
246
|
+
services = document.get("services") or {}
|
|
247
|
+
if not isinstance(services, dict):
|
|
248
|
+
return True
|
|
249
|
+
main = services.get("main") or {}
|
|
250
|
+
# An extended/included definition may explicitly choose even
|
|
251
|
+
# the default command; we cannot attribute it to Harbor.
|
|
252
|
+
if not isinstance(main, dict) or any(key in main for key in ("extends", "command", "entrypoint", *_LIFECYCLE_FIELDS)):
|
|
253
|
+
return True
|
|
254
|
+
return False
|
|
255
|
+
|
|
256
|
+
def write_overlay(self):
|
|
257
|
+
if self.temporary is None:
|
|
258
|
+
self.temporary = tempfile.TemporaryDirectory(prefix="hitch-compose-prepared-")
|
|
259
|
+
self.path = Path(self.temporary.name) / "compose.yaml"
|
|
260
|
+
# Compose interpolates '$' even in JSON/YAML list-form commands.
|
|
261
|
+
content = json.dumps({"services": self.overlay}, sort_keys=True).replace("$", "$$")
|
|
262
|
+
self.path.write_text(content.replace('"build": "__RESET__"', '"build": !reset null'))
|
|
263
|
+
|
|
264
|
+
def record(self, value):
|
|
265
|
+
self.receipts.append(value)
|
|
266
|
+
env = self.environment
|
|
267
|
+
env.logger.info("Hitch image preparation: %s", json.dumps(value, sort_keys=True))
|
|
268
|
+
# Host-only evidence; do not expose context paths/args/credentials or
|
|
269
|
+
# mount this cache into candidates, sidecars, or verifier containers.
|
|
270
|
+
directory = env.trial_paths.trial_dir
|
|
271
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
272
|
+
atomic_json(directory / f"hitch-preparation-{digest(env.session_id)[:16]}.json", {
|
|
273
|
+
"schema_version": "1", "session_id": env.session_id, "events": self.receipts,
|
|
274
|
+
})
|