Compare commits

..

No commits in common. "eeb27c6e29dbfc45b06a1ba848f8cfd7d5c04259" and "0a819381753749858e70eca7dbfc2ce3d6ac352d" have entirely different histories.

4 changed files with 75 additions and 136 deletions

View File

@ -5,7 +5,6 @@ include:
stages: stages:
- build - build
- slices - slices
- slices-publish
variables: variables:
RELEASE_NAME: iac-offline RELEASE_NAME: iac-offline
@ -36,7 +35,7 @@ build_offline_mirror:
- docker push ${CR}/${RELEASE_NAME}:latest - docker push ${CR}/${RELEASE_NAME}:latest
timeout: 30m timeout: 30m
generate_slice_jobs: publish_contour_slices:
stage: slices stage: slices
image: ${CR}/infra/slice-builder:1.0.0 image: ${CR}/infra/slice-builder:1.0.0
tags: tags:
@ -46,20 +45,7 @@ generate_slice_jobs:
variables: variables:
GIT_DEPTH: "1" GIT_DEPTH: "1"
script: script:
- python3 ci/publish-slice.py generate --release "$CI_COMMIT_TAG" --commit "$CI_COMMIT_SHA" --out slices-pipeline.yml - printf '%s' "$SLICES_PUSHER_KEY" | base64 -d > /tmp/slices-key.json
- cat slices-pipeline.yml - python3 ci/publish-slices.py --repo . --release "$CI_COMMIT_TAG" --commit "$CI_COMMIT_SHA" --key-file /tmp/slices-key.json
artifacts: after_script:
paths: - rm -f /tmp/slices-key.json
- slices-pipeline.yml
publish_contour_slices:
stage: slices-publish
rules:
- if: $CI_COMMIT_TAG =~ /^v?(?:\d+\.){2}\d+$/
needs:
- generate_slice_jobs
trigger:
include:
- artifact: slices-pipeline.yml
job: generate_slice_jobs
strategy: depend

View File

@ -68,7 +68,7 @@ def collect(repo: Path, start: str) -> tuple[set[PurePosixPath], list[str]]:
try: try:
return PurePosixPath(p.resolve().relative_to(repo).as_posix()) return PurePosixPath(p.resolve().relative_to(repo).as_posix())
except ValueError: except ValueError:
raise SliceError(f"reference outside the repository: {p}") raise SliceError(f"ссылка за пределы репозитория: {p}")
def add_file(p: Path): def add_file(p: Path):
r = rel(p) r = rel(p)
@ -86,21 +86,21 @@ def collect(repo: Path, start: str) -> tuple[set[PurePosixPath], list[str]]:
seen_dirs.add(d) seen_dirs.add(d)
kfile = kustomization_file(d) kfile = kustomization_file(d)
if kfile is None: if kfile is None:
raise SliceError(f"no kustomization.yaml in {rel(d)}") raise SliceError(f"нет kustomization.yaml в {rel(d)}")
if entry: if entry:
entries.append(str(rel(d))) entries.append(str(rel(d)))
add_file(kfile) add_file(kfile)
doc = yaml.safe_load(kfile.read_text()) or {} doc = yaml.safe_load(kfile.read_text()) or {}
for ref in local_refs(doc): for ref in local_refs(doc):
if is_remote(ref): if is_remote(ref):
raise SliceError(f"remote reference {ref} in {rel(kfile)}") raise SliceError(f"удалённая ссылка {ref} в {rel(kfile)}")
target = (d / ref) target = (d / ref)
if target.is_dir(): if target.is_dir():
visit_dir(target) visit_dir(target)
elif target.is_file(): elif target.is_file():
add_file(target) add_file(target)
else: else:
raise SliceError(f"reference {ref} in {rel(kfile)} does not exist") raise SliceError(f"ссылка {ref} в {rel(kfile)} никуда не ведёт")
visit_dir(repo / start, entry=True) visit_dir(repo / start, entry=True)
return files, sorted(set(entries)) return files, sorted(set(entries))
@ -124,7 +124,7 @@ def write_tar(repo: Path, files: set[PurePosixPath], out: Path):
def kustomize_build(path: Path) -> bytes: def kustomize_build(path: Path) -> bytes:
p = subprocess.run(["kubectl", "kustomize", str(path)], capture_output=True) p = subprocess.run(["kubectl", "kustomize", str(path)], capture_output=True)
if p.returncode != 0: if p.returncode != 0:
raise SliceError(f"kustomize build {path} failed: {p.stderr.decode().strip()}") raise SliceError(f"kustomize build {path}: {p.stderr.decode().strip()}")
return p.stdout return p.stdout
@ -135,7 +135,7 @@ def verify(repo: Path, out: Path, entries: list[str]):
tar.extractall(tmp, filter="data") tar.extractall(tmp, filter="data")
for e in entries: for e in entries:
if kustomize_build(repo / e) != kustomize_build(Path(tmp) / e): if kustomize_build(repo / e) != kustomize_build(Path(tmp) / e):
raise SliceError(f"kustomize build of {e} differs between slice and full repository") raise SliceError(f"build {e} в срезе отличается от полного репозитория")
def main() -> int: def main() -> int:
@ -153,9 +153,9 @@ def main() -> int:
if not args.no_verify: if not args.no_verify:
verify(repo, out, entries) verify(repo, out, entries)
except SliceError as e: except SliceError as e:
print(f"slice {args.contour}: {e}", file=sys.stderr) print(f"срез {args.contour}: {e}", file=sys.stderr)
return 1 return 1
print(f"slice {args.contour}: {len(files)} files, entry points: {', '.join(entries)}") print(f"срез {args.contour}: {len(files)} файлов, слоёв {len(entries)}: {', '.join(entries)}")
return 0 return 0

View File

@ -4,10 +4,10 @@ import base64
import hashlib import hashlib
import importlib.util import importlib.util
import json import json
import os
import re import re
import sys import sys
import tempfile import tempfile
import time
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
@ -22,10 +22,10 @@ spec.loader.exec_module(bs)
REGISTRY_HOST = "cr.yandex" REGISTRY_HOST = "cr.yandex"
SLICE_REGISTRY = "crpct2b2lsmao4nm0cjk" SLICE_REGISTRY = "crpct2b2lsmao4nm0cjk"
INSTALLER_GITEA_URL = "http://gitea.gitea.svc:3000/infra/iac.git"
CONFIG_TYPE = "application/vnd.sarex.slice.config.v1+json" CONFIG_TYPE = "application/vnd.sarex.slice.config.v1+json"
LAYER_TYPE = "application/vnd.oci.image.layer.v1.tar" LAYER_TYPE = "application/vnd.oci.image.layer.v1.tar"
MANIFEST_TYPE = "application/vnd.oci.image.manifest.v1+json" MANIFEST_TYPE = "application/vnd.oci.image.manifest.v1+json"
JOB_IMAGE = "${CR}/infra/slice-builder:1.0.0"
class PublishError(Exception): class PublishError(Exception):
@ -36,28 +36,13 @@ def digest(data: bytes) -> str:
return "sha256:" + hashlib.sha256(data).hexdigest() return "sha256:" + hashlib.sha256(data).hexdigest()
def list_contours(repo: Path) -> list[str]: def connected_contours(repo: Path) -> list[str]:
return sorted(p.parent.name for p in repo.glob("clusters/*/kustomization.yaml")) found = []
for sync in sorted(repo.glob("clusters/*/flux-system/gotk-sync.yaml")):
for doc in yaml.safe_load_all(sync.read_text()):
def child_pipeline(contours: list[str], release: str, commit: str) -> str: if isinstance(doc, dict) and doc.get("kind") == "GitRepository" and doc.get("spec", {}).get("url") == INSTALLER_GITEA_URL:
jobs: dict = {"stages": ["slices"]} found.append(sync.parents[1].name)
for c in contours: return found
jobs[f"slice {c}"] = {
"stage": "slices",
"image": JOB_IMAGE,
"tags": ["build"],
"allow_failure": True,
"variables": {"GIT_DEPTH": "1"},
"script": [
"printf '%s' \"$SLICES_PUSHER_KEY\" | base64 -d > /tmp/slices-key.json",
f"python3 ci/publish-slice.py publish --contour {c} --release {release} --commit {commit} --key-file /tmp/slices-key.json",
],
"after_script": ["rm -f /tmp/slices-key.json"],
}
if not contours:
jobs["no contours"] = {"stage": "slices", "image": JOB_IMAGE, "tags": ["build"], "script": ["echo 'no clusters/*/kustomization.yaml found'"]}
return yaml.safe_dump(jobs, sort_keys=False, allow_unicode=False)
def build_manifest(layer: bytes, annotations: dict[str, str]) -> tuple[bytes, bytes]: def build_manifest(layer: bytes, annotations: dict[str, str]) -> tuple[bytes, bytes]:
@ -97,7 +82,7 @@ class Registry:
params["scope"] = scope params["scope"] = scope
status, _, body = self._send("GET", f"{realm}?{urllib.parse.urlencode(params)}", headers={"Authorization": self.basic}) status, _, body = self._send("GET", f"{realm}?{urllib.parse.urlencode(params)}", headers={"Authorization": self.basic})
if status != 200: if status != 200:
raise PublishError(f"registry auth failed: HTTP {status}") raise PublishError(f"авторизация в реестре: HTTP {status}")
payload = json.loads(body) payload = json.loads(body)
return payload.get("token") or payload.get("access_token") return payload.get("token") or payload.get("access_token")
@ -123,94 +108,70 @@ class Registry:
raise PublishError(f"{repo}:current: HTTP {status}") raise PublishError(f"{repo}:current: HTTP {status}")
return json.loads(body)["layers"][0]["digest"] return json.loads(body)["layers"][0]["digest"]
def put_blob(self, repo: str, data: bytes) -> bool: def put_blob(self, repo: str, data: bytes):
d = digest(data) d = digest(data)
if self.request("HEAD", repo, f"blobs/{d}")[0] == 200: if self.request("HEAD", repo, f"blobs/{d}")[0] == 200:
return False return
status, h, _ = self.request("POST", repo, "blobs/uploads/") status, h, _ = self.request("POST", repo, "blobs/uploads/")
if status != 202: if status != 202:
raise PublishError(f"blob upload to {repo}: HTTP {status}") raise PublishError(f"загрузка блоба в {repo}: HTTP {status}")
location = h["location"] location = h["location"]
if location.startswith("/"): if location.startswith("/"):
location = self.base + location location = self.base + location
sep = "&" if "?" in location else "?" sep = "&" if "?" in location else "?"
status, _, _ = self.request("PUT", repo, f"{location}{sep}digest={d}", data, {"Content-Type": "application/octet-stream"}) status, _, _ = self.request("PUT", repo, f"{location}{sep}digest={d}", data, {"Content-Type": "application/octet-stream"})
if status != 201: if status != 201:
raise PublishError(f"blob upload to {repo}: HTTP {status}") raise PublishError(f"загрузка блоба в {repo}: HTTP {status}")
return True
def push(self, repo: str, tags: list[str], layer: bytes, annotations: dict[str, str]) -> bool: def push(self, repo: str, tags: list[str], layer: bytes, annotations: dict[str, str]):
config, manifest = build_manifest(layer, annotations) config, manifest = build_manifest(layer, annotations)
self.put_blob(repo, config) self.put_blob(repo, config)
uploaded = self.put_blob(repo, layer) self.put_blob(repo, layer)
for tag in tags: for tag in tags:
status, _, body = self.request("PUT", repo, f"manifests/{tag}", manifest, {"Content-Type": MANIFEST_TYPE}) status, _, body = self.request("PUT", repo, f"manifests/{tag}", manifest, {"Content-Type": MANIFEST_TYPE})
if status != 201: if status != 201:
raise PublishError(f"{repo}:{tag}: HTTP {status} {body[:200]!r}") raise PublishError(f"{repo}:{tag}: HTTP {status} {body[:200]!r}")
return uploaded
def log(msg: str):
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
def publish(repo: Path, contour: str, release: str, commit: str, key_file: Path) -> int:
target = f"{SLICE_REGISTRY}/{contour}-iac"
log(f"contour {contour}, source iac@{commit[:8]}, release {release}")
try:
with tempfile.TemporaryDirectory() as tmp:
out = Path(tmp) / "slice.tar"
files, entries = bs.collect(repo, f"clusters/{contour}")
log(f"collected {len(files)} files, entry points: {', '.join(entries)}")
bs.write_tar(repo, files, out)
bs.verify(repo, out, entries)
log(f"verified: kustomize build of {len(entries)} entry point(s) identical to full repository")
layer = out.read_bytes()
new = digest(layer)
log(f"slice size {len(layer)} bytes, digest {new}")
registry = Registry("json_key", key_file.read_text())
current = registry.current_layer(f"{target}")
changed = current != new
log(f"registry {REGISTRY_HOST}/{target}, current " + (current or "absent") + (", content changed" if changed else ", content unchanged"))
tags = [commit[:8], release] + (["current"] if changed else [])
uploaded = registry.push(target, tags, layer, {
"org.opencontainers.image.source": "iac",
"org.opencontainers.image.revision": commit,
"org.opencontainers.image.version": release,
"org.opencontainers.image.created": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"io.sarex.slice.contour": contour,
"io.sarex.slice.kind": "iac",
})
log(f"layer {'uploaded' if uploaded else 'already in registry'}, tags pushed: {', '.join(tags)}")
except (bs.SliceError, PublishError) as e:
log(f"FAILED: {e}")
return 1
log("done")
return 0
def main() -> int: def main() -> int:
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True) ap.add_argument("--repo", default=".")
gen = sub.add_parser("generate") ap.add_argument("--release", required=True)
gen.add_argument("--repo", default=".") ap.add_argument("--commit", required=True)
gen.add_argument("--release", required=True) ap.add_argument("--branch", default="")
gen.add_argument("--commit", required=True) ap.add_argument("--key-file", required=True)
gen.add_argument("--out", required=True)
pub = sub.add_parser("publish")
pub.add_argument("--repo", default=".")
pub.add_argument("--contour", required=True)
pub.add_argument("--release", required=True)
pub.add_argument("--commit", required=True)
pub.add_argument("--key-file", required=True)
args = ap.parse_args() args = ap.parse_args()
repo = Path(args.repo).resolve() repo = Path(args.repo).resolve()
if args.cmd == "generate": registry = Registry("json_key", Path(args.key_file).read_text())
contours = list_contours(repo) contours = connected_contours(repo)
Path(args.out).write_text(child_pipeline(contours, args.release, args.commit)) print(f"подключённые контуры: {', '.join(contours) or 'нет'}")
log(f"{len(contours)} contours: {', '.join(contours)}") failed = []
return 0 for contour in contours:
return publish(repo, args.contour, args.release, args.commit, Path(args.key_file)) target = f"{SLICE_REGISTRY}/{contour}-iac"
try:
with tempfile.TemporaryDirectory() as tmp:
out = Path(tmp) / "slice.tar"
files, entries = bs.collect(repo, f"clusters/{contour}")
bs.write_tar(repo, files, out)
bs.verify(repo, out, entries)
layer = out.read_bytes()
changed = registry.current_layer(target) != digest(layer)
tags = [args.commit[:8], args.release] + (["current"] if changed else [])
registry.push(target, tags, layer, {
"org.opencontainers.image.source": "iac",
"org.opencontainers.image.revision": args.commit,
"org.opencontainers.image.version": args.release,
"org.opencontainers.image.created": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"io.sarex.slice.branch": args.branch,
"io.sarex.slice.contour": contour,
"io.sarex.slice.kind": "iac",
})
state = "current переставлен" if changed else "содержимое как в current"
print(f"{target}: {len(files)} файлов, {digest(layer)[:19]}, теги {', '.join(tags)}, {state}")
except (bs.SliceError, PublishError) as e:
print(f"{target}: ОШИБКА {e}", file=sys.stderr)
failed.append(contour)
return 1 if failed else 0
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -3,7 +3,6 @@ import tarfile
from pathlib import Path from pathlib import Path
import pytest import pytest
import yaml
spec = importlib.util.spec_from_file_location("build_slice", Path(__file__).parents[1] / "build-slice.py") spec = importlib.util.spec_from_file_location("build_slice", Path(__file__).parents[1] / "build-slice.py")
bs = importlib.util.module_from_spec(spec) bs = importlib.util.module_from_spec(spec)
@ -49,13 +48,13 @@ def test_slice_follows_flux_paths_and_skips_foreign(repo):
def test_remote_reference_stops_build(repo): def test_remote_reference_stops_build(repo):
put(repo, "clusters/a/kustomization.yaml", "resources:\n - https://github.com/x/y//z\n") put(repo, "clusters/a/kustomization.yaml", "resources:\n - https://github.com/x/y//z\n")
with pytest.raises(bs.SliceError, match="remote reference"): with pytest.raises(bs.SliceError, match="удалённая ссылка"):
bs.collect(repo, "clusters/a") bs.collect(repo, "clusters/a")
def test_dangling_reference_stops_build(repo): def test_dangling_reference_stops_build(repo):
put(repo, "clusters/a/kustomization.yaml", "resources:\n - ./missing.yaml\n") put(repo, "clusters/a/kustomization.yaml", "resources:\n - ./missing.yaml\n")
with pytest.raises(bs.SliceError, match="does not exist"): with pytest.raises(bs.SliceError, match="никуда не ведёт"):
bs.collect(repo, "clusters/a") bs.collect(repo, "clusters/a")
@ -71,23 +70,16 @@ def test_tar_is_byte_identical_across_runs(repo, tmp_path_factory):
assert all(m.mtime == 0 for m in t.getmembers()) assert all(m.mtime == 0 for m in t.getmembers())
pspec = importlib.util.spec_from_file_location("publish_slices", Path(__file__).parents[1] / "publish-slices.py")
pspec = importlib.util.spec_from_file_location("publish_slice", Path(__file__).parents[1] / "publish-slice.py")
ps = importlib.util.module_from_spec(pspec) ps = importlib.util.module_from_spec(pspec)
pspec.loader.exec_module(ps) pspec.loader.exec_module(ps)
def test_every_cluster_with_kustomization_is_a_contour(tmp_path): def test_only_installer_contours_are_connected(tmp_path):
put(tmp_path, "clusters/stand/kustomization.yaml", "resources: []\n") put(tmp_path, "clusters/stand/flux-system/gotk-sync.yaml",
put(tmp_path, "clusters/vad/kustomization.yaml", "resources: []\n") f"apiVersion: source.toolkit.fluxcd.io/v1\nkind: GitRepository\nspec:\n url: {ps.INSTALLER_GITEA_URL}\n")
put(tmp_path, "clusters/contour/apps.yaml", "kind: Kustomization\n") put(tmp_path, "clusters/vad/flux-system/gotk-sync.yaml",
assert ps.list_contours(tmp_path) == ["stand", "vad"] "apiVersion: source.toolkit.fluxcd.io/v1\nkind: GitRepository\nspec:\n url: http://192.168.8.141:31361/infra/iac.git\n")
put(tmp_path, "clusters/old/flux-system/gotk-sync.yaml",
"apiVersion: source.toolkit.fluxcd.io/v1\nkind: GitRepository\nspec:\n url: https://gitlab.sarex.io/infra/iac.git\n")
def test_child_pipeline_has_one_job_per_contour(): assert ps.connected_contours(tmp_path) == ["stand"]
doc = yaml.safe_load(ps.child_pipeline(["stand", "vad"], "3.9.68", "abcdef1234"))
jobs = {k: v for k, v in doc.items() if k != "stages"}
assert sorted(jobs) == ["slice stand", "slice vad"]
assert all(j["allow_failure"] for j in jobs.values())
assert "--contour vad --release 3.9.68 --commit abcdef1234" in jobs["slice vad"]["script"][1]
assert all(ord(ch) < 128 for ch in ps.child_pipeline(["stand"], "1.0.0", "a" * 40))