build(hermes-agent): source base image + test deps from in-cluster mirrors
The hermes-agent-image pipeline failed intermittently on external network: Kaniko's docker.io fallback for the base image is IPv6-broken from build pods, and the "Validate reviewed release source" stage pip-installed pytest from files.pythonhosted.org (DNS failures). Neither should touch the public internet. Base image: repoint the Dockerfile FROM from docker.io to the in-cluster Harbor "mirror" project, keeping the exact content-addressed index digest (9c841866...) and both arch leaves. A Flux-managed one-shot Job (services/harbor/hermes-agent-base-mirror-job.yaml, suspend: true like the cassandra bootstrap job) runs `skopeo copy --all` from docker.io into Harbor using the same Vault-injected admin credential as the existing Harbor immutability jobs; a tiny fail-closed helper ensures the public target project first. Digest pinning and multi-arch are preserved; Kaniko pulls it over the internal insecure registry with no docker.io fallback. Test deps: install pytest/PyYAML fully offline (`pip --no-index --find-links`) from a reviewed in-repo wheelhouse (ci/vendor/hermes-agent-test-wheels) matching the arm64 python:3.12 build container, so the validate stage never resolves a public index. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BvMSXH8VH2tMWXanb8SJdf
This commit is contained in:
parent
2675241739
commit
8a71084585
@ -178,7 +178,15 @@ spec:
|
|||||||
container('python') {
|
container('python') {
|
||||||
sh '''
|
sh '''
|
||||||
set -eu
|
set -eu
|
||||||
|
# Install the pinned test deps fully offline from the reviewed,
|
||||||
|
# in-repo wheelhouse (ci/vendor/hermes-agent-test-wheels). --no-index
|
||||||
|
# forbids any network index, so this stage never resolves
|
||||||
|
# pypi.org/files.pythonhosted.org and cannot fail on public-internet
|
||||||
|
# DNS. The wheels match this pipeline's arm64 python:3.12 container
|
||||||
|
# (PyYAML is the cp312 manylinux aarch64 build; the rest are
|
||||||
|
# py3-none-any). Bump the wheelhouse when these pins change.
|
||||||
python3 -m pip install --disable-pip-version-check --no-cache-dir \
|
python3 -m pip install --disable-pip-version-check --no-cache-dir \
|
||||||
|
--no-index --find-links="${WORKSPACE}/ci/vendor/hermes-agent-test-wheels" \
|
||||||
--target=/tmp/hermes-agent-release-test-deps \
|
--target=/tmp/hermes-agent-release-test-deps \
|
||||||
pytest==8.3.4 PyYAML==6.0.2
|
pytest==8.3.4 PyYAML==6.0.2
|
||||||
PYTHONPATH=/tmp/hermes-agent-release-test-deps \
|
PYTHONPATH=/tmp/hermes-agent-release-test-deps \
|
||||||
|
|||||||
Binary file not shown.
BIN
ci/vendor/hermes-agent-test-wheels/iniconfig-2.3.0-py3-none-any.whl
vendored
Normal file
BIN
ci/vendor/hermes-agent-test-wheels/iniconfig-2.3.0-py3-none-any.whl
vendored
Normal file
Binary file not shown.
BIN
ci/vendor/hermes-agent-test-wheels/packaging-26.3-py3-none-any.whl
vendored
Normal file
BIN
ci/vendor/hermes-agent-test-wheels/packaging-26.3-py3-none-any.whl
vendored
Normal file
Binary file not shown.
BIN
ci/vendor/hermes-agent-test-wheels/pluggy-1.6.0-py3-none-any.whl
vendored
Normal file
BIN
ci/vendor/hermes-agent-test-wheels/pluggy-1.6.0-py3-none-any.whl
vendored
Normal file
Binary file not shown.
BIN
ci/vendor/hermes-agent-test-wheels/pytest-8.3.4-py3-none-any.whl
vendored
Normal file
BIN
ci/vendor/hermes-agent-test-wheels/pytest-8.3.4-py3-none-any.whl
vendored
Normal file
Binary file not shown.
@ -10,7 +10,19 @@
|
|||||||
# arm64 build is unchanged; Kaniko/containerd auto-selects the matching leaf per
|
# arm64 build is unchanged; Kaniko/containerd auto-selects the matching leaf per
|
||||||
# build platform (arm64 rpi5 pod vs amd64 titan-24 pod). Do NOT replace this with
|
# build platform (arm64 rpi5 pod vs amd64 titan-24 pod). Do NOT replace this with
|
||||||
# a per-arch leaf digest -- that would break the amd64 build leg.
|
# a per-arch leaf digest -- that would break the amd64 build leg.
|
||||||
FROM nousresearch/hermes-agent@sha256:9c841866021c54c4596849f6135717e8a4d52ba510b7f52c50aef1de1a283973
|
#
|
||||||
|
# The reference below points at the in-cluster Harbor "mirror" project, NOT
|
||||||
|
# docker.io. The identical upstream index (same content-addressed digest
|
||||||
|
# 9c841866..., both leaves) is mirrored into Harbor with
|
||||||
|
# skopeo copy --all docker://nousresearch/hermes-agent@sha256:9c841866... \
|
||||||
|
# docker://harbor-core.harbor.svc.cluster.local/mirror/hermes-agent@sha256:9c841866...
|
||||||
|
# by the Flux-managed one-shot Job services/harbor/hermes-agent-base-mirror-job.yaml.
|
||||||
|
# Because the digest is content-addressed, mirroring reproduces the exact index
|
||||||
|
# and both leaf digests, so this remains fully digest-pinned and multi-arch while
|
||||||
|
# Kaniko pulls it over the internal insecure registry (see the pipeline's
|
||||||
|
# --insecure-registry/--registry-mirror flags) with no docker.io fallback.
|
||||||
|
# Re-run that Job whenever this digest is bumped, before publishing the image.
|
||||||
|
FROM harbor-core.harbor.svc.cluster.local/mirror/hermes-agent@sha256:9c841866021c54c4596849f6135717e8a4d52ba510b7f52c50aef1de1a283973
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
|
|||||||
141
services/harbor/hermes-agent-base-mirror-job.yaml
Normal file
141
services/harbor/hermes-agent-base-mirror-job.yaml
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
# services/harbor/hermes-agent-base-mirror-job.yaml
|
||||||
|
#
|
||||||
|
# One-shot mirror of the Hermes agent base image INDEX from docker.io into the
|
||||||
|
# in-cluster Harbor "mirror" project, so the reviewed image build
|
||||||
|
# (dockerfiles/Dockerfile.hermes-agent) pulls its FROM base internally with no
|
||||||
|
# docker.io fallback (which is IPv6-broken from build pods).
|
||||||
|
#
|
||||||
|
# Kept `suspend: true` like bootstrap-jobs/cassandra-registry-ensure-job.yaml:
|
||||||
|
# it needs egress to docker.io (registry-1.docker.io / *.pythonhosted is NOT
|
||||||
|
# involved here) and is only run deliberately, once per base-digest bump. To run
|
||||||
|
# it, an operator clears suspend (or `kubectl create job --from`) AFTER updating
|
||||||
|
# the digest in BOTH args below and in the Dockerfile FROM. Because the digest
|
||||||
|
# is content-addressed, `skopeo copy --all` reproduces the identical index and
|
||||||
|
# both arch leaves in Harbor -- the build stays digest-pinned and multi-arch.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: harbor-hermes-agent-base-mirror-1
|
||||||
|
namespace: harbor
|
||||||
|
spec:
|
||||||
|
suspend: true
|
||||||
|
backoffLimit: 2
|
||||||
|
activeDeadlineSeconds: 1800
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
annotations:
|
||||||
|
vault.hashicorp.com/agent-inject: "true"
|
||||||
|
vault.hashicorp.com/agent-pre-populate-only: "true"
|
||||||
|
vault.hashicorp.com/agent-run-as-user: "65532"
|
||||||
|
vault.hashicorp.com/agent-run-as-group: "65532"
|
||||||
|
vault.hashicorp.com/role: harbor-policy-bootstrap
|
||||||
|
vault.hashicorp.com/agent-inject-secret-harbor-admin-password: kv/data/atlas/harbor/harbor-core
|
||||||
|
vault.hashicorp.com/agent-inject-template-harbor-admin-password: |
|
||||||
|
{{- with secret "kv/data/atlas/harbor/harbor-core" -}}
|
||||||
|
{{ .Data.data.harbor_admin_password }}
|
||||||
|
{{- end -}}
|
||||||
|
spec:
|
||||||
|
serviceAccountName: harbor-policy-bootstrap
|
||||||
|
enableServiceLinks: false
|
||||||
|
restartPolicy: Never
|
||||||
|
nodeSelector:
|
||||||
|
hardware: rpi5
|
||||||
|
kubernetes.io/arch: arm64
|
||||||
|
node-role.kubernetes.io/worker: "true"
|
||||||
|
affinity:
|
||||||
|
nodeAffinity:
|
||||||
|
requiredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
nodeSelectorTerms:
|
||||||
|
- matchExpressions:
|
||||||
|
- key: kubernetes.io/hostname
|
||||||
|
operator: NotIn
|
||||||
|
values: [titan-04, titan-14, titan-18, titan-19, titan-24]
|
||||||
|
securityContext:
|
||||||
|
fsGroup: 65532
|
||||||
|
fsGroupChangePolicy: OnRootMismatch
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
initContainers:
|
||||||
|
# Ensure the public "mirror" project exists before skopeo tries to push
|
||||||
|
# into it (Harbor only auto-creates repositories inside an existing
|
||||||
|
# project). Uses the same Vault-injected admin password as the sibling
|
||||||
|
# Harbor immutability jobs -- no new credential is introduced.
|
||||||
|
- name: ensure-project
|
||||||
|
image: docker.io/library/python@sha256:efcdfa6a6b2fd2afb9c7dfa9a5b288a6f68338b5cfdebe6b637d986067d85757
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
command: [python3, /scripts/harbor_mirror_project_ensure.py]
|
||||||
|
env:
|
||||||
|
- name: HARBOR_API_ORIGIN
|
||||||
|
value: http://harbor-core.harbor.svc.cluster.local/api/v2.0
|
||||||
|
- name: HARBOR_ADMIN_PASSWORD_FILE
|
||||||
|
value: /vault/secrets/harbor-admin-password
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
runAsGroup: 65532
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 65532
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
volumeMounts:
|
||||||
|
- name: scripts
|
||||||
|
mountPath: /scripts
|
||||||
|
readOnly: true
|
||||||
|
- name: tmp
|
||||||
|
mountPath: /tmp
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 25m, memory: 32Mi}
|
||||||
|
limits: {cpu: 250m, memory: 128Mi}
|
||||||
|
containers:
|
||||||
|
# skopeo copies the WHOLE multi-arch index (--all) straight from docker.io
|
||||||
|
# to the internal Harbor registry over HTTP (--dest-tls-verify=false, the
|
||||||
|
# same insecure in-cluster endpoint Kaniko already trusts). Source is the
|
||||||
|
# public upstream image, so no source credential is needed; the digest is
|
||||||
|
# asserted on both ends so a drifted upstream tag cannot be mirrored.
|
||||||
|
- name: mirror
|
||||||
|
image: quay.io/skopeo/stable@sha256:94f5c5e26997e2e78c234ec9abf19a391c234b39eb22e6d1210d0b527c97dcc8
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
command: ["/bin/sh", "-c"]
|
||||||
|
args:
|
||||||
|
- |
|
||||||
|
set -eu
|
||||||
|
src="docker://nousresearch/hermes-agent@sha256:9c841866021c54c4596849f6135717e8a4d52ba510b7f52c50aef1de1a283973"
|
||||||
|
dst="docker://harbor-core.harbor.svc.cluster.local/mirror/hermes-agent@sha256:9c841866021c54c4596849f6135717e8a4d52ba510b7f52c50aef1de1a283973"
|
||||||
|
pw="$(cat /vault/secrets/harbor-admin-password)"
|
||||||
|
exec skopeo copy --all \
|
||||||
|
--src-tls-verify=true \
|
||||||
|
--dest-tls-verify=false \
|
||||||
|
--dest-creds "admin:${pw}" \
|
||||||
|
"${src}" "${dst}"
|
||||||
|
env:
|
||||||
|
- name: HOME
|
||||||
|
value: /tmp
|
||||||
|
- name: TMPDIR
|
||||||
|
value: /tmp
|
||||||
|
- name: XDG_RUNTIME_DIR
|
||||||
|
value: /tmp
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
capabilities:
|
||||||
|
drop: ["ALL"]
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
runAsGroup: 65532
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 65532
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
volumeMounts:
|
||||||
|
- name: tmp
|
||||||
|
mountPath: /tmp
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 100m, memory: 256Mi}
|
||||||
|
limits: {cpu: "1", memory: 1Gi}
|
||||||
|
volumes:
|
||||||
|
- name: scripts
|
||||||
|
configMap:
|
||||||
|
name: harbor-hermes-agent-base-mirror-script
|
||||||
|
defaultMode: 0555
|
||||||
|
- name: tmp
|
||||||
|
emptyDir: {}
|
||||||
@ -16,6 +16,7 @@ resources:
|
|||||||
- hermes-agent-immutability-job.yaml
|
- hermes-agent-immutability-job.yaml
|
||||||
- hermes-webui-immutability-job.yaml
|
- hermes-webui-immutability-job.yaml
|
||||||
- hermes-chat-router-immutability-job.yaml
|
- hermes-chat-router-immutability-job.yaml
|
||||||
|
- hermes-agent-base-mirror-job.yaml
|
||||||
- bootstrap-jobs/cassandra-registry-ensure-job.yaml
|
- bootstrap-jobs/cassandra-registry-ensure-job.yaml
|
||||||
- image.yaml
|
- image.yaml
|
||||||
configMapGenerator:
|
configMapGenerator:
|
||||||
@ -32,3 +33,6 @@ configMapGenerator:
|
|||||||
files:
|
files:
|
||||||
- harbor_immutable_rule_ensure.py=scripts/harbor_immutable_rule_ensure.py
|
- harbor_immutable_rule_ensure.py=scripts/harbor_immutable_rule_ensure.py
|
||||||
- harbor_hermes_chat_router_immutability_ensure.py=scripts/harbor_hermes_chat_router_immutability_ensure.py
|
- harbor_hermes_chat_router_immutability_ensure.py=scripts/harbor_hermes_chat_router_immutability_ensure.py
|
||||||
|
- name: harbor-hermes-agent-base-mirror-script
|
||||||
|
files:
|
||||||
|
- harbor_mirror_project_ensure.py=scripts/harbor_mirror_project_ensure.py
|
||||||
|
|||||||
133
services/harbor/scripts/harbor_mirror_project_ensure.py
Normal file
133
services/harbor/scripts/harbor_mirror_project_ensure.py
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Idempotently ensure the public Harbor ``mirror`` project exists.
|
||||||
|
|
||||||
|
The Hermes agent image base is pulled by Kaniko from an in-cluster Harbor
|
||||||
|
project instead of docker.io (see dockerfiles/Dockerfile.hermes-agent). This
|
||||||
|
helper creates that project so the sibling ``skopeo copy --all`` step has a
|
||||||
|
push target. It is deliberately tiny and fail-closed: any unexpected Harbor
|
||||||
|
response aborts with a non-zero exit so the mirror Job does not silently push
|
||||||
|
into a mis-scoped project.
|
||||||
|
|
||||||
|
The project is created ``public`` on purpose: it only ever holds mirrored
|
||||||
|
copies of already-public upstream base images (content-addressed by digest),
|
||||||
|
and public read lets Kaniko pull it over the internal registry without needing
|
||||||
|
a Harbor pull credential in its build config.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT = "mirror"
|
||||||
|
MAX_RESPONSE = 1_048_576
|
||||||
|
|
||||||
|
|
||||||
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
|
"""Never replay Basic credentials across an unexpected redirect."""
|
||||||
|
|
||||||
|
def redirect_request(self, *_args): # noqa: D401 - urllib signature
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _admin_password() -> str:
|
||||||
|
path = os.environ.get(
|
||||||
|
"HARBOR_ADMIN_PASSWORD_FILE", "/vault/secrets/harbor-admin-password"
|
||||||
|
)
|
||||||
|
password = Path(path).read_text(encoding="utf-8").strip()
|
||||||
|
if not password:
|
||||||
|
raise SystemExit(f"Harbor admin password at {path} is empty")
|
||||||
|
return password
|
||||||
|
|
||||||
|
|
||||||
|
def _api_origin() -> str:
|
||||||
|
origin = os.environ.get(
|
||||||
|
"HARBOR_API_ORIGIN",
|
||||||
|
"http://harbor-core.harbor.svc.cluster.local/api/v2.0",
|
||||||
|
).rstrip("/")
|
||||||
|
return origin
|
||||||
|
|
||||||
|
|
||||||
|
def _request(opener, origin, auth, method, path, payload=None, ok=(200,)):
|
||||||
|
url = f"{origin}{path}"
|
||||||
|
data = None
|
||||||
|
headers = {"Authorization": f"Basic {auth}"}
|
||||||
|
if payload is not None:
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
||||||
|
try:
|
||||||
|
with opener.open(req, timeout=30) as response:
|
||||||
|
body = response.read(MAX_RESPONSE + 1)
|
||||||
|
if len(body) > MAX_RESPONSE:
|
||||||
|
raise SystemExit(f"{method} {path} response exceeded cap")
|
||||||
|
if response.status not in ok:
|
||||||
|
raise SystemExit(f"{method} {path} returned {response.status}")
|
||||||
|
if not body:
|
||||||
|
return None
|
||||||
|
return json.loads(body.decode("utf-8"))
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
detail = exc.read().decode("utf-8", errors="replace")
|
||||||
|
if exc.code in ok:
|
||||||
|
return None
|
||||||
|
raise SystemExit(f"{method} {path} returned {exc.code}: {detail}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
origin = _api_origin()
|
||||||
|
auth = base64.b64encode(f"admin:{_admin_password()}".encode("utf-8")).decode(
|
||||||
|
"ascii"
|
||||||
|
)
|
||||||
|
opener = urllib.request.build_opener(NoRedirect())
|
||||||
|
|
||||||
|
existing = _request(
|
||||||
|
opener, origin, auth, "GET", f"/projects?name={PROJECT}", ok=(200,)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
match = next(
|
||||||
|
(
|
||||||
|
item
|
||||||
|
for item in existing
|
||||||
|
if item.get("name") == PROJECT
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if match is not None:
|
||||||
|
public = str(
|
||||||
|
(match.get("metadata") or {}).get("public", "")
|
||||||
|
).lower()
|
||||||
|
if public not in ("true", "1"):
|
||||||
|
raise SystemExit(
|
||||||
|
f"Harbor project {PROJECT!r} exists but is not public; "
|
||||||
|
"refusing to push mirrored base image into it"
|
||||||
|
)
|
||||||
|
print(f"Harbor project {PROJECT!r} already present and public")
|
||||||
|
return
|
||||||
|
|
||||||
|
_request(
|
||||||
|
opener,
|
||||||
|
origin,
|
||||||
|
auth,
|
||||||
|
"POST",
|
||||||
|
"/projects",
|
||||||
|
{
|
||||||
|
"project_name": PROJECT,
|
||||||
|
"metadata": {
|
||||||
|
"public": "true",
|
||||||
|
"auto_scan": "false",
|
||||||
|
"enable_content_trust": "false",
|
||||||
|
"prevent_vul": "false",
|
||||||
|
"reuse_sys_cve_allowlist": "true",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ok=(201, 409),
|
||||||
|
)
|
||||||
|
print(f"Ensured public Harbor project {PROJECT!r}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
226
testing/tests/test_hermes_mirror_project_ensure.py
Normal file
226
testing/tests/test_hermes_mirror_project_ensure.py
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
"""Boundary coverage for the Harbor ``mirror`` project bootstrap helper.
|
||||||
|
|
||||||
|
The Hermes agent image build pulls its base from the in-cluster Harbor
|
||||||
|
``mirror`` project instead of docker.io; this exercises the tiny fail-closed
|
||||||
|
helper that guarantees the project exists (and is public) before the sibling
|
||||||
|
``skopeo copy --all`` step pushes into it. No network is touched.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
SCRIPT = REPO_ROOT / "services/harbor/scripts/harbor_mirror_project_ensure.py"
|
||||||
|
|
||||||
|
|
||||||
|
def _load(name: str):
|
||||||
|
spec = importlib.util.spec_from_file_location(name, SCRIPT)
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class Response(io.BytesIO):
|
||||||
|
"""Context-managed urllib response used without network access."""
|
||||||
|
|
||||||
|
def __init__(self, status: int, body: bytes = b"") -> None:
|
||||||
|
super().__init__(body)
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_args):
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
class FakeOpener:
|
||||||
|
"""Ordered opener fake matching urllib's build_opener().open() contract."""
|
||||||
|
|
||||||
|
def __init__(self, results) -> None:
|
||||||
|
self.results = list(results)
|
||||||
|
self.requests = []
|
||||||
|
|
||||||
|
def open(self, req, timeout=None): # noqa: A003 - urllib signature
|
||||||
|
self.requests.append((req.get_method(), req.full_url, req.data))
|
||||||
|
result = self.results.pop(0)
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
raise result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _http_error(code: int, body: bytes = b"boom") -> urllib.error.HTTPError:
|
||||||
|
return urllib.error.HTTPError(
|
||||||
|
"http://harbor/api", code, "err", {}, io.BytesIO(body)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _password_file(tmp_path: Path, contents: str) -> Path:
|
||||||
|
path = tmp_path / "harbor-admin-password"
|
||||||
|
path.write_text(contents, encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# _admin_password / _api_origin
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_admin_password_reads_and_strips_the_injected_file(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
module = _load("mirror_pw_ok")
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"HARBOR_ADMIN_PASSWORD_FILE", str(_password_file(tmp_path, " s3cret \n"))
|
||||||
|
)
|
||||||
|
assert module._admin_password() == "s3cret"
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_password_rejects_empty_secret(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
module = _load("mirror_pw_empty")
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"HARBOR_ADMIN_PASSWORD_FILE", str(_password_file(tmp_path, " \n"))
|
||||||
|
)
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
module._admin_password()
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_origin_defaults_and_honours_override(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
module = _load("mirror_origin")
|
||||||
|
monkeypatch.delenv("HARBOR_API_ORIGIN", raising=False)
|
||||||
|
assert module._api_origin().endswith("harbor.svc.cluster.local/api/v2.0")
|
||||||
|
monkeypatch.setenv("HARBOR_API_ORIGIN", "http://example/api/v2.0/")
|
||||||
|
assert module._api_origin() == "http://example/api/v2.0"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# _request
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_request_returns_parsed_body_and_records_method() -> None:
|
||||||
|
module = _load("mirror_req_ok")
|
||||||
|
opener = FakeOpener([Response(200, json.dumps([{"name": "mirror"}]).encode())])
|
||||||
|
result = module._request(opener, "http://h", "auth", "GET", "/projects")
|
||||||
|
assert result == [{"name": "mirror"}]
|
||||||
|
assert opener.requests[0][0] == "GET"
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_posts_payload_and_tolerates_empty_body() -> None:
|
||||||
|
module = _load("mirror_req_post")
|
||||||
|
opener = FakeOpener([Response(201, b"")])
|
||||||
|
result = module._request(
|
||||||
|
opener, "http://h", "auth", "POST", "/projects", {"a": 1}, ok=(201, 409)
|
||||||
|
)
|
||||||
|
assert result is None
|
||||||
|
method, _url, data = opener.requests[0]
|
||||||
|
assert method == "POST" and data == json.dumps({"a": 1}).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_rejects_oversized_response(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
module = _load("mirror_req_big")
|
||||||
|
monkeypatch.setattr(module, "MAX_RESPONSE", 4)
|
||||||
|
opener = FakeOpener([Response(200, b"xxxxxxxx")])
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
module._request(opener, "http://h", "auth", "GET", "/projects")
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_rejects_unexpected_status() -> None:
|
||||||
|
module = _load("mirror_req_status")
|
||||||
|
opener = FakeOpener([Response(500, b"nope")])
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
module._request(opener, "http://h", "auth", "GET", "/projects")
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_maps_http_error_status_to_ok_or_failure() -> None:
|
||||||
|
module = _load("mirror_req_httperror")
|
||||||
|
tolerated = FakeOpener([_http_error(409)])
|
||||||
|
assert (
|
||||||
|
module._request(
|
||||||
|
tolerated, "http://h", "auth", "POST", "/projects", {"a": 1}, ok=(201, 409)
|
||||||
|
)
|
||||||
|
is None
|
||||||
|
)
|
||||||
|
fatal = FakeOpener([_http_error(403)])
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
module._request(fatal, "http://h", "auth", "POST", "/projects", {"a": 1})
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# main
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _prime(module, monkeypatch, tmp_path, results):
|
||||||
|
monkeypatch.setenv(
|
||||||
|
"HARBOR_ADMIN_PASSWORD_FILE", str(_password_file(tmp_path, "pw"))
|
||||||
|
)
|
||||||
|
monkeypatch.setenv("HARBOR_API_ORIGIN", "http://harbor/api/v2.0")
|
||||||
|
opener = FakeOpener(results)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
module.urllib.request, "build_opener", lambda *_a, **_k: opener
|
||||||
|
)
|
||||||
|
return opener
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_is_a_noop_when_public_project_exists(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
module = _load("mirror_main_present")
|
||||||
|
body = json.dumps(
|
||||||
|
[{"name": "mirror", "metadata": {"public": "true"}}]
|
||||||
|
).encode()
|
||||||
|
opener = _prime(module, monkeypatch, tmp_path, [Response(200, body)])
|
||||||
|
module.main()
|
||||||
|
assert [call[0] for call in opener.requests] == ["GET"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_main_fails_closed_on_a_private_collision(
|
||||||
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
module = _load("mirror_main_private")
|
||||||
|
body = json.dumps(
|
||||||
|
[{"name": "mirror", "metadata": {"public": "false"}}]
|
||||||
|
).encode()
|
||||||
|
_prime(module, monkeypatch, tmp_path, [Response(200, body)])
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
module.main()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"listing",
|
||||||
|
[b"[]", json.dumps([{"name": "other", "metadata": {"public": "true"}}]).encode()],
|
||||||
|
)
|
||||||
|
def test_main_creates_the_project_when_absent(
|
||||||
|
listing: bytes, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
module = _load("mirror_main_create")
|
||||||
|
opener = _prime(
|
||||||
|
module,
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
[Response(200, listing), Response(201, b"")],
|
||||||
|
)
|
||||||
|
module.main()
|
||||||
|
methods = [call[0] for call in opener.requests]
|
||||||
|
assert methods == ["GET", "POST"]
|
||||||
|
assert opener.requests[1][1].endswith("/projects")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# misc
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def test_no_redirect_handler_refuses_to_replay_credentials() -> None:
|
||||||
|
module = _load("mirror_noredirect")
|
||||||
|
handler = module.NoRedirect()
|
||||||
|
assert handler.redirect_request(None, None, 302, "m", {}, "http://elsewhere") is None
|
||||||
Loading…
x
Reference in New Issue
Block a user