agent-hitch 0.2.15 → 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 +56 -15
- 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,357 @@
|
|
|
1
|
+
"""Host-local, cross-process preparation cache for resolved Compose builds."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
from contextlib import asynccontextmanager
|
|
6
|
+
from contextvars import ContextVar
|
|
7
|
+
import fcntl
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import re
|
|
12
|
+
import stat
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
from hitch_build_inputs import UnsupportedBuild, build_spec, digest, pinned_dockerfile, remove_snapshot, snapshot_context
|
|
18
|
+
|
|
19
|
+
_IMAGE_ID = re.compile(r"sha256:[a-f0-9]{64}")
|
|
20
|
+
_PROXY_NAMES = ("HTTP_PROXY", "HTTPS_PROXY", "FTP_PROXY", "ALL_PROXY", "NO_PROXY")
|
|
21
|
+
_LOCK_FDS = ContextVar("hitch_build_lock_fds", default=())
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def docker(args, *, env, timeout=600, check=True):
|
|
25
|
+
"""Keep command lifetime and cache locks tied to the worker's liveness."""
|
|
26
|
+
spawning = asyncio.create_task(asyncio.create_subprocess_exec(
|
|
27
|
+
sys.executable, "-I", "-B", str(Path(__file__).with_name("hitch_build_process.py")), "docker", *args,
|
|
28
|
+
env=env, start_new_session=True, pass_fds=_LOCK_FDS.get(), stdin=asyncio.subprocess.PIPE,
|
|
29
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
30
|
+
))
|
|
31
|
+
process = communication = None
|
|
32
|
+
try:
|
|
33
|
+
# Do not lose ownership if cancellation arrives during process creation.
|
|
34
|
+
process = await asyncio.shield(spawning)
|
|
35
|
+
communication = asyncio.create_task(communicate(process))
|
|
36
|
+
stdout, _ = await asyncio.wait_for(asyncio.shield(communication), timeout)
|
|
37
|
+
except BaseException:
|
|
38
|
+
async def stop():
|
|
39
|
+
owned = process if process is not None else await spawning
|
|
40
|
+
owned.stdin.close()
|
|
41
|
+
await (communication if communication is not None else communicate(owned))
|
|
42
|
+
|
|
43
|
+
cleanup = asyncio.create_task(stop())
|
|
44
|
+
while not cleanup.done():
|
|
45
|
+
try:
|
|
46
|
+
await asyncio.shield(cleanup)
|
|
47
|
+
except asyncio.CancelledError:
|
|
48
|
+
# Repeated cancellation cannot release image/slot locks early.
|
|
49
|
+
pass
|
|
50
|
+
cleanup.result()
|
|
51
|
+
raise
|
|
52
|
+
finally:
|
|
53
|
+
if process is not None:
|
|
54
|
+
process.stdin.close()
|
|
55
|
+
if check and process.returncode:
|
|
56
|
+
# Do not surface build args, proxy credentials or registry stderr.
|
|
57
|
+
raise RuntimeError(f"Hitch image preparation Docker {args[0]} failed ({process.returncode})")
|
|
58
|
+
return stdout.decode("utf-8", errors="strict") if process.returncode == 0 else None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def communicate(process):
|
|
62
|
+
stdout, stderr = await asyncio.gather(process.stdout.read(), process.stderr.read())
|
|
63
|
+
try:
|
|
64
|
+
# Unlike Process.communicate(), keep stdin open while Docker runs. The
|
|
65
|
+
# supervisor also waits for descendants holding either output pipe.
|
|
66
|
+
process.stdin.write(b"1")
|
|
67
|
+
await process.stdin.drain()
|
|
68
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
69
|
+
pass
|
|
70
|
+
await process.wait()
|
|
71
|
+
return stdout, stderr
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@asynccontextmanager
|
|
75
|
+
async def lock(paths):
|
|
76
|
+
handles = []
|
|
77
|
+
acquired = None
|
|
78
|
+
token = None
|
|
79
|
+
try:
|
|
80
|
+
for path in paths:
|
|
81
|
+
fd = os.open(path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
|
82
|
+
handles.append(os.fdopen(fd, "a"))
|
|
83
|
+
while acquired is None:
|
|
84
|
+
for handle in handles:
|
|
85
|
+
try:
|
|
86
|
+
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
87
|
+
acquired = handle
|
|
88
|
+
break
|
|
89
|
+
except BlockingIOError:
|
|
90
|
+
pass
|
|
91
|
+
if acquired is None:
|
|
92
|
+
await asyncio.sleep(0.05)
|
|
93
|
+
token = _LOCK_FDS.set((*_LOCK_FDS.get(), acquired.fileno()))
|
|
94
|
+
yield
|
|
95
|
+
finally:
|
|
96
|
+
if token is not None:
|
|
97
|
+
_LOCK_FDS.reset(token)
|
|
98
|
+
for handle in handles:
|
|
99
|
+
handle.close()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def atomic_json(path, value):
|
|
103
|
+
fd, name = tempfile.mkstemp(dir=path.parent, prefix=".record-")
|
|
104
|
+
try:
|
|
105
|
+
with os.fdopen(fd, "w") as stream:
|
|
106
|
+
json.dump(value, stream, sort_keys=True, separators=(",", ":"))
|
|
107
|
+
os.replace(name, path)
|
|
108
|
+
finally:
|
|
109
|
+
Path(name).unlink(missing_ok=True)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def read_json(path):
|
|
113
|
+
try:
|
|
114
|
+
if path.is_symlink() or path.stat().st_size > 65536:
|
|
115
|
+
return None
|
|
116
|
+
return json.loads(path.read_text())
|
|
117
|
+
except (OSError, ValueError):
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def image_matches_platform(info, platform):
|
|
122
|
+
variants = (None, "", "v8") if platform == "linux/arm64" else (None, "")
|
|
123
|
+
return (isinstance(info, dict)
|
|
124
|
+
and f"{info.get('Os', info.get('os'))}/{info.get('Architecture', info.get('architecture'))}" == platform
|
|
125
|
+
and info.get("Variant", info.get("variant")) in variants)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def recover_contexts(directory):
|
|
129
|
+
# Called under the registry lock. Legacy/unrecognized directories have no
|
|
130
|
+
# ownership proof and are retained. Never use PID age or a time threshold.
|
|
131
|
+
for candidate in directory.glob(".context-*"):
|
|
132
|
+
if candidate.is_symlink() or not candidate.is_dir():
|
|
133
|
+
continue
|
|
134
|
+
try:
|
|
135
|
+
fd = os.open(candidate / ".owner.lock", os.O_RDWR | os.O_NOFOLLOW)
|
|
136
|
+
except OSError:
|
|
137
|
+
continue
|
|
138
|
+
with os.fdopen(fd, "a") as owner:
|
|
139
|
+
info = os.fstat(owner.fileno())
|
|
140
|
+
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid():
|
|
141
|
+
continue
|
|
142
|
+
try:
|
|
143
|
+
fcntl.flock(owner, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
144
|
+
except BlockingIOError:
|
|
145
|
+
continue
|
|
146
|
+
if candidate.exists():
|
|
147
|
+
remove_snapshot(candidate)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@asynccontextmanager
|
|
151
|
+
async def context_snapshot(directory):
|
|
152
|
+
temporary = owner = token = None
|
|
153
|
+
try:
|
|
154
|
+
# Serialize publication of the directory and its already-held lock.
|
|
155
|
+
# No copier/builder starts before ownership has been established.
|
|
156
|
+
async with lock([directory / "context-registry.lock"]):
|
|
157
|
+
recover_contexts(directory)
|
|
158
|
+
temporary = Path(tempfile.mkdtemp(dir=directory, prefix=".context-"))
|
|
159
|
+
fd = os.open(temporary / ".owner.lock", os.O_CREAT | os.O_EXCL | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
|
160
|
+
owner = os.fdopen(fd, "a")
|
|
161
|
+
fcntl.flock(owner, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
162
|
+
# Supervisors inherit this descriptor along with key/slot locks. A
|
|
163
|
+
# dead parent cannot make a live build's context eligible for recovery.
|
|
164
|
+
token = _LOCK_FDS.set((*_LOCK_FDS.get(), owner.fileno()))
|
|
165
|
+
yield temporary
|
|
166
|
+
finally:
|
|
167
|
+
try:
|
|
168
|
+
if temporary is not None:
|
|
169
|
+
remove_snapshot(temporary)
|
|
170
|
+
finally:
|
|
171
|
+
if token is not None:
|
|
172
|
+
_LOCK_FDS.reset(token)
|
|
173
|
+
if owner is not None:
|
|
174
|
+
owner.close()
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class ImagePreparationCache:
|
|
178
|
+
def __init__(self, directory, env, slots=2):
|
|
179
|
+
self.directory = Path(directory)
|
|
180
|
+
if not self.directory.is_absolute() or self.directory.resolve() != self.directory:
|
|
181
|
+
raise ValueError("Hitch image cache requires an absolute non-symlink directory")
|
|
182
|
+
if isinstance(slots, bool) or not isinstance(slots, int) or not 1 <= slots <= 64:
|
|
183
|
+
raise ValueError("Hitch image build slots must be between 1 and 64")
|
|
184
|
+
self.directory.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
185
|
+
if self.directory.stat().st_uid != os.getuid() or self.directory.stat().st_mode & 0o022:
|
|
186
|
+
raise ValueError("Hitch image cache must be owned by this user and not writable by others")
|
|
187
|
+
self.env, self.slots, self.daemon = env, slots, None
|
|
188
|
+
|
|
189
|
+
async def identity(self):
|
|
190
|
+
if self.env.get("DOCKER_BUILDKIT", "").lower() in ("0", "f", "false"):
|
|
191
|
+
raise UnsupportedBuild("legacy-builder")
|
|
192
|
+
try:
|
|
193
|
+
return await self._identity()
|
|
194
|
+
except UnsupportedBuild:
|
|
195
|
+
raise
|
|
196
|
+
except (RuntimeError, TimeoutError, OSError, ValueError, TypeError, AttributeError) as error:
|
|
197
|
+
# Optional capability probes must not prevent native Compose
|
|
198
|
+
# builds. Actual image build failures remain errors in prepare().
|
|
199
|
+
raise UnsupportedBuild("builder-capabilities") from error
|
|
200
|
+
|
|
201
|
+
async def _identity(self):
|
|
202
|
+
# Source policies can deny or remap even digest-pinned base inputs.
|
|
203
|
+
# Their files and external policy dependencies are outside this key.
|
|
204
|
+
if self.env.get("EXPERIMENTAL_BUILDKIT_SOURCE_POLICY") or self.env.get("BUILDX_DEFAULT_POLICY"):
|
|
205
|
+
raise UnsupportedBuild("source-policy")
|
|
206
|
+
# Buildx discovers enclosing Git state from the context's location,
|
|
207
|
+
# which an independent snapshot deliberately does not retain.
|
|
208
|
+
for name in ("BUILDX_GIT_LABELS", "BUILDX_GIT_CHECK_DIRTY"):
|
|
209
|
+
if self.env.get(name, "").lower() not in ("", "0", "f", "false"):
|
|
210
|
+
raise UnsupportedBuild("git-build-metadata")
|
|
211
|
+
if self.daemon is None:
|
|
212
|
+
raw = await docker(["info", "--format", "{{json .}}"], env=self.env, timeout=30)
|
|
213
|
+
data = json.loads(raw)
|
|
214
|
+
architecture = {"x86_64": "amd64", "aarch64": "arm64"}.get(data.get("Architecture"), data.get("Architecture"))
|
|
215
|
+
if not data.get("ID") or data.get("OSType") != "linux":
|
|
216
|
+
raise UnsupportedBuild("daemon-platform")
|
|
217
|
+
name = self.env.get("BUILDX_BUILDER")
|
|
218
|
+
if not name:
|
|
219
|
+
inspected = await docker(["buildx", "inspect"], env=self.env, timeout=30)
|
|
220
|
+
match = re.search(r"(?m)^Name:\s+(\S+)", inspected)
|
|
221
|
+
name = match[1] if match else None
|
|
222
|
+
# Human inspect output iterates driver-option maps in random order.
|
|
223
|
+
# Use structured data and discard activity, status and GC counters.
|
|
224
|
+
listing = await docker(["buildx", "ls", "--format", "{{json .}}"], env=self.env, timeout=30)
|
|
225
|
+
builders = [json.loads(line) for line in listing.splitlines() if line.strip()]
|
|
226
|
+
selected = next((entry for entry in builders if entry.get("Name") == name), None)
|
|
227
|
+
if not selected or not selected.get("Driver") or not selected.get("Nodes"):
|
|
228
|
+
raise UnsupportedBuild("builder-identity")
|
|
229
|
+
# A custom builder can run on a different architecture and have
|
|
230
|
+
# different defaults from the daemon Compose will use. Optimize
|
|
231
|
+
# only the current Docker context's built-in builder; let native
|
|
232
|
+
# Compose select and configure all other builders.
|
|
233
|
+
if selected["Driver"] != "docker" or (await docker(["context", "show"], env=self.env, timeout=30)).strip() != name:
|
|
234
|
+
raise UnsupportedBuild("nondefault-builder")
|
|
235
|
+
nodes = []
|
|
236
|
+
for node in selected["Nodes"]:
|
|
237
|
+
normalized = {key: node.get(key) for key in ("Name", "Endpoint", "DriverOpts", "Flags", "Version", "ProxyConfig", "Labels")}
|
|
238
|
+
normalized.update({key: sorted(node.get(key) or []) for key in ("Platforms", "IDs")})
|
|
239
|
+
nodes.append(normalized)
|
|
240
|
+
builder = {"name": name, "driver": selected["Driver"], "nodes": sorted(nodes, key=lambda n: str(n["Name"]))}
|
|
241
|
+
config = Path(self.env.get("DOCKER_CONFIG") or str(Path(self.env.get("HOME") or Path.home()) / ".docker")) / "config.json"
|
|
242
|
+
try:
|
|
243
|
+
proxies = json.loads(config.read_text()).get("proxies", {})
|
|
244
|
+
except FileNotFoundError:
|
|
245
|
+
proxies = {}
|
|
246
|
+
except (OSError, ValueError, AttributeError) as error:
|
|
247
|
+
raise UnsupportedBuild("docker-client-config") from error
|
|
248
|
+
self.daemon = {"id": data["ID"], "platform": f"linux/{architecture}", "builder": digest(builder), "builder_name": name, "proxy_config": digest(proxies)}
|
|
249
|
+
return self.daemon
|
|
250
|
+
|
|
251
|
+
async def inspect(self, image, *, platform=None):
|
|
252
|
+
selection = ["--platform", platform] if platform else []
|
|
253
|
+
raw = await docker(["image", "inspect", *selection, "--format", "{{json .}}", image], env=self.env, timeout=30, check=False)
|
|
254
|
+
return json.loads(raw) if raw else None
|
|
255
|
+
|
|
256
|
+
async def _inspect_for_reuse(self, image, *, platform=None):
|
|
257
|
+
try:
|
|
258
|
+
return await self.inspect(image, platform=platform)
|
|
259
|
+
except (TimeoutError, OSError, ValueError) as error:
|
|
260
|
+
# A failed optional probe cannot certify reuse, but must not block
|
|
261
|
+
# native Compose. Post-build verification remains strict below.
|
|
262
|
+
raise UnsupportedBuild("image-inspection") from error
|
|
263
|
+
|
|
264
|
+
async def check_bases(self, bases, platform):
|
|
265
|
+
# Digest-pinned base definitions can contain inherited ONBUILD triggers.
|
|
266
|
+
# Inspect the immutable config once; a registry error falls back to Harbor.
|
|
267
|
+
for base in bases:
|
|
268
|
+
# Older receipts did not validate variant selection.
|
|
269
|
+
key = digest(["base-v2", base, platform])
|
|
270
|
+
path = self.directory / f"base-{key}.json"
|
|
271
|
+
async with lock([self.directory / f"base-{key}.lock"]):
|
|
272
|
+
cached = read_json(path)
|
|
273
|
+
if cached == {"base": base, "platform": platform, "no_onbuild": True}:
|
|
274
|
+
continue
|
|
275
|
+
# Ask Docker to select the requested index member. A default
|
|
276
|
+
# inspection can refer to another pre-pulled variant, even
|
|
277
|
+
# when its config omits the optional Variant field. Older
|
|
278
|
+
# daemons lacking this option use the registry/fallback path.
|
|
279
|
+
info = await self._inspect_for_reuse(base, platform=platform)
|
|
280
|
+
if not image_matches_platform(info, platform):
|
|
281
|
+
try:
|
|
282
|
+
raw = await docker(["buildx", "imagetools", "inspect", base, "--format", "{{json .Image}}"], env=self.env, timeout=30)
|
|
283
|
+
info = json.loads(raw)
|
|
284
|
+
info = info.get(platform, info)
|
|
285
|
+
config = info.get("config")
|
|
286
|
+
if not isinstance(config, dict) or not image_matches_platform(info, platform):
|
|
287
|
+
raise UnsupportedBuild("base-platform")
|
|
288
|
+
except (RuntimeError, asyncio.TimeoutError, OSError, ValueError, AttributeError) as error:
|
|
289
|
+
raise UnsupportedBuild("base-config-unavailable") from error
|
|
290
|
+
else:
|
|
291
|
+
config = info.get("Config", {})
|
|
292
|
+
if config.get("OnBuild") or config.get("onbuild"):
|
|
293
|
+
raise UnsupportedBuild("inherited-onbuild")
|
|
294
|
+
atomic_json(path, {"base": base, "platform": platform, "no_onbuild": True})
|
|
295
|
+
|
|
296
|
+
async def prepare(self, service):
|
|
297
|
+
started = time.monotonic()
|
|
298
|
+
phases = {}
|
|
299
|
+
daemon = await self.identity()
|
|
300
|
+
source, spec = build_spec(service, self.env.get("DOCKER_DEFAULT_PLATFORM") or daemon["platform"])
|
|
301
|
+
if self.directory.is_relative_to(source):
|
|
302
|
+
raise UnsupportedBuild("cache-inside-context")
|
|
303
|
+
async with context_snapshot(self.directory) as temporary:
|
|
304
|
+
context = temporary / "context"
|
|
305
|
+
phase = time.monotonic()
|
|
306
|
+
context_digest = await snapshot_context(source, context)
|
|
307
|
+
phases["snapshot_seconds"] = time.monotonic() - phase
|
|
308
|
+
bases = pinned_dockerfile(context / spec["dockerfile"])
|
|
309
|
+
phase = time.monotonic()
|
|
310
|
+
await self.check_bases(bases, spec["platform"])
|
|
311
|
+
phases["base_check_seconds"] = time.monotonic() - phase
|
|
312
|
+
implicit = {name: self.env.get(name) for name in (
|
|
313
|
+
"BUILDX_BUILDER", "BUILDKIT_HOST", "SOURCE_DATE_EPOCH",
|
|
314
|
+
*_PROXY_NAMES, *(name.lower() for name in _PROXY_NAMES),
|
|
315
|
+
)}
|
|
316
|
+
implicit.update({name: value for name, value in self.env.items() if name.startswith(("BUILDX_", "BUILDKIT_"))})
|
|
317
|
+
key = digest({"schema": 1, "context": context_digest, "build": spec, "daemon": daemon,
|
|
318
|
+
"implicit": implicit})
|
|
319
|
+
record = self.directory / f"image-{key}.json"
|
|
320
|
+
tag = f"hitch-prepared:{digest(str(self.directory))[:16]}-{key}"
|
|
321
|
+
phase = time.monotonic()
|
|
322
|
+
async with lock([self.directory / f"image-{key}.lock"]):
|
|
323
|
+
phases["key_lock_wait_seconds"] = time.monotonic() - phase
|
|
324
|
+
cached = read_json(record)
|
|
325
|
+
if isinstance(cached, dict) and cached.get("key") == key and _IMAGE_ID.fullmatch(str(cached.get("image"))):
|
|
326
|
+
phase = time.monotonic()
|
|
327
|
+
info = await self._inspect_for_reuse(cached["image"])
|
|
328
|
+
phases["probe_seconds"] = time.monotonic() - phase
|
|
329
|
+
if self.valid_image(info, key, spec["platform"]):
|
|
330
|
+
return {"image": info["Id"], "cache_key": key, "cache_hit": True, "seconds": time.monotonic() - started, "phases": phases}
|
|
331
|
+
slots = [self.directory / f"slot-{digest(daemon['id'])}-{index}.lock" for index in range(self.slots)]
|
|
332
|
+
phase = time.monotonic()
|
|
333
|
+
async with lock(slots):
|
|
334
|
+
phases["build_slot_wait_seconds"] = time.monotonic() - phase
|
|
335
|
+
args = ["buildx", "build", "--builder", daemon["builder_name"], "--load", "--progress", "plain", "--platform", spec["platform"],
|
|
336
|
+
"--file", str(context / spec["dockerfile"]), "--tag", tag]
|
|
337
|
+
if spec["target"]:
|
|
338
|
+
args.extend(["--target", spec["target"]])
|
|
339
|
+
for key_name, value in sorted(spec["args"].items()):
|
|
340
|
+
args.extend(["--build-arg", f"{key_name}={value}"])
|
|
341
|
+
for key_name, value in sorted(spec["labels"].items()):
|
|
342
|
+
args.extend(["--label", f"{key_name}={value}"])
|
|
343
|
+
args.extend(["--label", f"io.hitch.prepared-key={key}", str(context)])
|
|
344
|
+
phase = time.monotonic()
|
|
345
|
+
await docker(args, env=self.env, timeout=None)
|
|
346
|
+
phases["build_seconds"] = time.monotonic() - phase
|
|
347
|
+
info = await self.inspect(tag)
|
|
348
|
+
if not self.valid_image(info, key, spec["platform"]):
|
|
349
|
+
raise RuntimeError("Hitch prepared image identity/platform mismatch")
|
|
350
|
+
atomic_json(record, {"key": key, "image": info["Id"], "platform": spec["platform"], "tag": tag})
|
|
351
|
+
return {"image": info["Id"], "cache_key": key, "cache_hit": False, "seconds": time.monotonic() - started, "phases": phases}
|
|
352
|
+
|
|
353
|
+
@staticmethod
|
|
354
|
+
def valid_image(info, key, platform):
|
|
355
|
+
return (isinstance(info, dict) and _IMAGE_ID.fullmatch(str(info.get("Id")))
|
|
356
|
+
and image_matches_platform(info, platform)
|
|
357
|
+
and (info.get("Config", {}).get("Labels") or {}).get("io.hitch.prepared-key") == key)
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""Read-only content inventory for the interpreter which launches Harbor.
|
|
2
|
+
|
|
3
|
+
Invoked with the actual launcher interpreter, before starting a task. Generated
|
|
4
|
+
bytecode caches do not change identity; a cache that can disagree with its source
|
|
5
|
+
is rejected instead of being silently omitted. No imported package is trusted to
|
|
6
|
+
report its own version or integrity.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
from collections import Counter
|
|
11
|
+
import importlib.util
|
|
12
|
+
import json
|
|
13
|
+
import marshal
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import re
|
|
17
|
+
import stat
|
|
18
|
+
import struct
|
|
19
|
+
import sys
|
|
20
|
+
import sysconfig
|
|
21
|
+
import types
|
|
22
|
+
import zipimport
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
MAX_FILES = 250_000
|
|
26
|
+
MAX_BYTES = 2 * 1024**3
|
|
27
|
+
LIMIT_ENV_PREFIX = "HITCH_HARBOR_PYTHON_RUNTIME_"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def inventory_limit(name, default):
|
|
31
|
+
"""Bound host work independently of the candidate's task and model deadlines."""
|
|
32
|
+
value = os.environ.get(LIMIT_ENV_PREFIX + name)
|
|
33
|
+
if value is None:
|
|
34
|
+
return default
|
|
35
|
+
if not re.fullmatch(r"[1-9][0-9]{0,18}", value) or int(value) > sys.maxsize:
|
|
36
|
+
raise ValueError("invalid Python runtime inventory limit")
|
|
37
|
+
return int(value)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def constant_key(value):
|
|
41
|
+
"""Compare compiled constants without NaN equality or signed-zero ambiguity."""
|
|
42
|
+
if isinstance(value, types.CodeType):
|
|
43
|
+
# Preserve the interpreter's code comparison for every non-constant field.
|
|
44
|
+
return (types.CodeType, value.replace(co_consts=()),
|
|
45
|
+
tuple(constant_key(item) for item in value.co_consts))
|
|
46
|
+
if isinstance(value, float):
|
|
47
|
+
return (float, struct.pack("!d", value))
|
|
48
|
+
if isinstance(value, complex):
|
|
49
|
+
return (complex, struct.pack("!dd", value.real, value.imag))
|
|
50
|
+
if isinstance(value, tuple):
|
|
51
|
+
return (tuple, tuple(constant_key(item) for item in value))
|
|
52
|
+
if isinstance(value, frozenset):
|
|
53
|
+
# Distinct NaNs with identical bits can coexist in a frozen set.
|
|
54
|
+
return (frozenset, frozenset(Counter(constant_key(item) for item in value).items()))
|
|
55
|
+
return (type(value), value)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def nan_graph(value, references):
|
|
59
|
+
"""Project the only constants whose equality depends on shared identity."""
|
|
60
|
+
if isinstance(value, (float, complex)) and value != value:
|
|
61
|
+
references[id(value)] += 1
|
|
62
|
+
return ("nan", id(value))
|
|
63
|
+
if isinstance(value, types.CodeType):
|
|
64
|
+
value = value.co_consts
|
|
65
|
+
if isinstance(value, (tuple, frozenset)):
|
|
66
|
+
children = []
|
|
67
|
+
for index, child in enumerate(value):
|
|
68
|
+
graph = nan_graph(child, references)
|
|
69
|
+
if graph is not None:
|
|
70
|
+
label = constant_key(child) if isinstance(value, frozenset) else index
|
|
71
|
+
children.append((label, graph))
|
|
72
|
+
if children:
|
|
73
|
+
return ("set" if isinstance(value, frozenset) else "sequence", tuple(children))
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def mapped_nan_graph(graph, mapping=None):
|
|
78
|
+
"""An order-independent key once every left-hand NaN has a known partner."""
|
|
79
|
+
kind, items = graph
|
|
80
|
+
if kind == "nan":
|
|
81
|
+
identity = items if mapping is None else mapping.get(items)
|
|
82
|
+
return None if identity is None else (kind, identity)
|
|
83
|
+
children = []
|
|
84
|
+
for label, child in items:
|
|
85
|
+
key = mapped_nan_graph(child, mapping)
|
|
86
|
+
if key is None:
|
|
87
|
+
return None
|
|
88
|
+
children.append((label, key))
|
|
89
|
+
return (kind, frozenset(Counter(children).items()) if kind == "set" else tuple(children))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def compiled_constants_equal(left, right):
|
|
93
|
+
if constant_key(left) != constant_key(right):
|
|
94
|
+
return False
|
|
95
|
+
left_refs, right_refs = Counter(), Counter()
|
|
96
|
+
left_graph, right_graph = nan_graph(left, left_refs), nan_graph(right, right_refs)
|
|
97
|
+
if sorted(left_refs.values()) != sorted(right_refs.values()):
|
|
98
|
+
return False
|
|
99
|
+
if all(count == 1 for count in left_refs.values()):
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
def set_choices(first, second, pending, forward, backward):
|
|
103
|
+
label, child = first[0]
|
|
104
|
+
for index, (other_label, other_child) in enumerate(second):
|
|
105
|
+
if label == other_label:
|
|
106
|
+
remaining = second[:index] + second[index + 1:]
|
|
107
|
+
yield (pending + [(("set", first[1:]), ("set", remaining)), (child, other_child)],
|
|
108
|
+
forward.copy(), backward.copy())
|
|
109
|
+
|
|
110
|
+
# Set iteration order changes across compilation/marshal. Match a bijection
|
|
111
|
+
# lazily, retaining alternatives when later ordered constants constrain it.
|
|
112
|
+
# An explicit stack avoids recursion proportional to the number of constants.
|
|
113
|
+
choices = [iter([([(left_graph, right_graph)], {}, {})])]
|
|
114
|
+
while choices:
|
|
115
|
+
state = next(choices[-1], None)
|
|
116
|
+
if state is None:
|
|
117
|
+
choices.pop()
|
|
118
|
+
continue
|
|
119
|
+
pending, forward, backward = state
|
|
120
|
+
while pending:
|
|
121
|
+
first, second = pending.pop()
|
|
122
|
+
kind, items = first
|
|
123
|
+
other_kind, other_items = second
|
|
124
|
+
if kind != other_kind or (kind != "nan" and len(items) != len(other_items)):
|
|
125
|
+
break
|
|
126
|
+
if kind == "nan":
|
|
127
|
+
if forward.get(items, other_items) != other_items or backward.get(other_items, items) != items:
|
|
128
|
+
break
|
|
129
|
+
forward[items], backward[other_items] = other_items, items
|
|
130
|
+
elif kind == "sequence":
|
|
131
|
+
if any(a[0] != b[0] for a, b in zip(items, other_items)):
|
|
132
|
+
break
|
|
133
|
+
for a, b in reversed(tuple(zip(items, other_items))):
|
|
134
|
+
pair = (a[1], b[1])
|
|
135
|
+
# Ordered positions constrain identities without guessing.
|
|
136
|
+
# Apply all such anchors before considering unordered sets.
|
|
137
|
+
if a[1][0] == "set":
|
|
138
|
+
pending.insert(0, pair)
|
|
139
|
+
else:
|
|
140
|
+
pending.append(pair)
|
|
141
|
+
elif items:
|
|
142
|
+
mapped = mapped_nan_graph(first, forward)
|
|
143
|
+
if mapped is not None:
|
|
144
|
+
if mapped != mapped_nan_graph(second):
|
|
145
|
+
break
|
|
146
|
+
continue
|
|
147
|
+
choices.append(set_choices(items, other_items, pending, forward, backward))
|
|
148
|
+
break
|
|
149
|
+
else:
|
|
150
|
+
return True
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def inventory(launcher):
|
|
155
|
+
max_files = inventory_limit("MAX_FILES", MAX_FILES)
|
|
156
|
+
max_bytes = inventory_limit("MAX_BYTES", MAX_BYTES)
|
|
157
|
+
launcher = Path(launcher).absolute()
|
|
158
|
+
if not launcher.is_file():
|
|
159
|
+
raise ValueError("missing launcher")
|
|
160
|
+
if sys.pycache_prefix is not None:
|
|
161
|
+
raise ValueError("external Python cache directories are unsupported")
|
|
162
|
+
# A -c probe starts with cwd; a script starts with its containing directory.
|
|
163
|
+
# Never accept relative PYTHONPATH entries: execution uses a different cwd.
|
|
164
|
+
search_path = list(sys.path) if getattr(sys.flags, "safe_path", False) else [str(launcher.resolve(strict=True).parent), *sys.path[1:]]
|
|
165
|
+
if any(not os.path.isabs(value) for value in search_path):
|
|
166
|
+
raise ValueError("relative Python module path")
|
|
167
|
+
records = {}
|
|
168
|
+
snapshots = {}
|
|
169
|
+
directories = {}
|
|
170
|
+
active = set()
|
|
171
|
+
visited = set()
|
|
172
|
+
byte_count = 0
|
|
173
|
+
|
|
174
|
+
def read(file):
|
|
175
|
+
nonlocal byte_count
|
|
176
|
+
before = file.stat()
|
|
177
|
+
if not stat.S_ISREG(before.st_mode):
|
|
178
|
+
raise ValueError("non-regular Python runtime file")
|
|
179
|
+
if byte_count + before.st_size > max_bytes:
|
|
180
|
+
raise ValueError("Python runtime inventory exceeds byte limit")
|
|
181
|
+
data = file.read_bytes()
|
|
182
|
+
byte_count += len(data)
|
|
183
|
+
after = file.stat()
|
|
184
|
+
signature = lambda s: (s.st_dev, s.st_ino, s.st_size, s.st_mtime_ns, s.st_ctime_ns, s.st_mode)
|
|
185
|
+
if signature(before) != signature(after):
|
|
186
|
+
raise ValueError("Python runtime changed while reading")
|
|
187
|
+
snapshots[str(file)] = signature(after)
|
|
188
|
+
return data
|
|
189
|
+
|
|
190
|
+
def cache_is_derived(file):
|
|
191
|
+
if file.suffix != ".pyc" or file.parent.name != "__pycache__":
|
|
192
|
+
return False
|
|
193
|
+
tag = re.escape(sys.implementation.cache_tag)
|
|
194
|
+
match = re.fullmatch(r"(.+)\." + tag + r"(?:\.opt-([012]))?\.pyc", file.name)
|
|
195
|
+
if not match:
|
|
196
|
+
# Another interpreter's tagged cache cannot be imported here.
|
|
197
|
+
return True
|
|
198
|
+
source = file.parent.parent / (match[1] + ".py")
|
|
199
|
+
if not source.is_file():
|
|
200
|
+
# CPython does not import orphan files inside __pycache__.
|
|
201
|
+
return True
|
|
202
|
+
data = read(file)
|
|
203
|
+
if len(data) < 16 or data[:4] != importlib.util.MAGIC_NUMBER:
|
|
204
|
+
raise ValueError("unverifiable Python bytecode cache")
|
|
205
|
+
code = marshal.loads(data[16:])
|
|
206
|
+
if not isinstance(code, types.CodeType):
|
|
207
|
+
raise ValueError("invalid Python bytecode cache")
|
|
208
|
+
expected = compile(read(source), code.co_filename, "exec", dont_inherit=True, optimize=int(match[2] or 0))
|
|
209
|
+
if not compiled_constants_equal(code, expected):
|
|
210
|
+
raise ValueError("Python bytecode cache disagrees with source")
|
|
211
|
+
return True
|
|
212
|
+
|
|
213
|
+
def visit(file):
|
|
214
|
+
name = str(file)
|
|
215
|
+
if name in visited:
|
|
216
|
+
return
|
|
217
|
+
# Derived caches still consume traversal work even though they do not
|
|
218
|
+
# contribute records to the runtime's content identity.
|
|
219
|
+
if len(visited) >= max_files:
|
|
220
|
+
raise ValueError("Python runtime inventory exceeds file limit")
|
|
221
|
+
visited.add(name)
|
|
222
|
+
if file.is_symlink():
|
|
223
|
+
target = file.resolve(strict=True)
|
|
224
|
+
records[name] = ["link", os.readlink(file), str(target)]
|
|
225
|
+
visit(target)
|
|
226
|
+
elif file.is_dir():
|
|
227
|
+
if name in active:
|
|
228
|
+
raise ValueError("cyclic Python runtime directory")
|
|
229
|
+
active.add(name)
|
|
230
|
+
names = sorted(os.listdir(file))
|
|
231
|
+
directories[name] = names
|
|
232
|
+
# Cache directories and verified caches are derived, not inputs.
|
|
233
|
+
if file.name != "__pycache__":
|
|
234
|
+
records[name] = ["directory"]
|
|
235
|
+
for child in names:
|
|
236
|
+
visit(file / child)
|
|
237
|
+
active.remove(name)
|
|
238
|
+
elif file.is_file():
|
|
239
|
+
if cache_is_derived(file):
|
|
240
|
+
return
|
|
241
|
+
data = read(file)
|
|
242
|
+
records[name] = ["file", stat.S_IMODE(file.stat().st_mode), hashlib.sha256(data).hexdigest()]
|
|
243
|
+
elif file.exists():
|
|
244
|
+
raise ValueError("unsupported Python runtime file")
|
|
245
|
+
else:
|
|
246
|
+
# sys.path and module origins can refer inside an archive. Those
|
|
247
|
+
# paths are importable even though filesystem stat reports missing.
|
|
248
|
+
try:
|
|
249
|
+
importer = zipimport.zipimporter(name)
|
|
250
|
+
except zipimport.ZipImportError:
|
|
251
|
+
records[name] = ["missing"]
|
|
252
|
+
else:
|
|
253
|
+
archive = Path(importer.archive)
|
|
254
|
+
records[name] = ["zip-path", str(archive), importer.prefix]
|
|
255
|
+
visit(archive)
|
|
256
|
+
|
|
257
|
+
for file in [launcher, Path(sys.executable), Path(sys.prefix) / "pyvenv.cfg"]:
|
|
258
|
+
visit(file)
|
|
259
|
+
for value in search_path:
|
|
260
|
+
visit(Path(value))
|
|
261
|
+
# Startup hooks (for example Homebrew sitecustomize) may have been imported
|
|
262
|
+
# from a directory later removed from sys.path. Include their actual files.
|
|
263
|
+
for module in list(sys.modules.values()):
|
|
264
|
+
origin = getattr(module, "__file__", None)
|
|
265
|
+
if isinstance(origin, str):
|
|
266
|
+
file = Path(origin)
|
|
267
|
+
if not file.is_absolute():
|
|
268
|
+
file = file.absolute()
|
|
269
|
+
visit(file)
|
|
270
|
+
library = Path(sysconfig.get_config_var("LIBDIR") or sys.base_prefix) / (sysconfig.get_config_var("LDLIBRARY") or "libpython")
|
|
271
|
+
visit(library)
|
|
272
|
+
for name, expected in snapshots.items():
|
|
273
|
+
s = Path(name).stat()
|
|
274
|
+
if (s.st_dev, s.st_ino, s.st_size, s.st_mtime_ns, s.st_ctime_ns, s.st_mode) != expected:
|
|
275
|
+
raise ValueError("Python runtime changed during inventory")
|
|
276
|
+
for name, expected in directories.items():
|
|
277
|
+
if sorted(os.listdir(name)) != expected:
|
|
278
|
+
raise ValueError("Python runtime directory changed during inventory")
|
|
279
|
+
identity = {
|
|
280
|
+
"schema_version": "1",
|
|
281
|
+
"implementation": sys.implementation.name,
|
|
282
|
+
"version": sys.version,
|
|
283
|
+
"prefix": sys.prefix,
|
|
284
|
+
"base_prefix": sys.base_prefix,
|
|
285
|
+
"search_path": search_path,
|
|
286
|
+
"files": sorted(records.items()),
|
|
287
|
+
}
|
|
288
|
+
encoded = json.dumps(identity, sort_keys=True, ensure_ascii=True, separators=(",", ":")).encode()
|
|
289
|
+
return {
|
|
290
|
+
"schema_version": "1",
|
|
291
|
+
"integrity": "sha256:" + hashlib.sha256(encoded).hexdigest(),
|
|
292
|
+
"python_version": ".".join(map(str, sys.version_info[:3])),
|
|
293
|
+
"file_count": sum(value[0] == "file" for value in records.values()),
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
if __name__ == "__main__":
|
|
298
|
+
try:
|
|
299
|
+
print(json.dumps(inventory(sys.argv[1]), separators=(",", ":")))
|
|
300
|
+
except Exception:
|
|
301
|
+
# Paths or package import errors can contain credentials or private data.
|
|
302
|
+
print("Harbor Python runtime cannot be verified", file=sys.stderr)
|
|
303
|
+
sys.exit(1)
|