From 229a705987264eac91c2499ef80668d47e7c81e8 Mon Sep 17 00:00:00 2001 From: Sergey Antropoff Date: Tue, 28 Jul 2026 00:57:11 +0300 Subject: [PATCH] Add SemVer VERSION, Jenkins CI/CD, and release 0.1.1. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship Harbor/Hub push with latest+version tags, deploy to vmware.devops.org.ru, and show the package version badge in Help → About. --- Dockerfile | 1 + Jenkinsfile | 201 ++++++++++++++++++ Makefile | 36 +++- VERSION | 1 + app/main.py | 3 +- app/version.py | 130 +++++++++++ app/web/contract_catalog.py | 2 + app/web/index.html | 29 ++- app/web/routes.py | 9 +- docs/README.md | 1 + docs/ci-cd.md | 78 +++++++ docs/ru/README.md | 1 + docs/ru/ci-cd.md | 77 +++++++ helm/vmware-api-simulator/Chart.yaml | 4 +- .../values-ingress-example.yaml | 8 +- helm/vmware-api-simulator/values.yaml | 2 +- pyproject.toml | 2 +- scripts/bump_version.py | 27 +++ tests/unit/test_version.py | 14 ++ 19 files changed, 606 insertions(+), 20 deletions(-) create mode 100644 Jenkinsfile create mode 100644 VERSION create mode 100644 app/version.py create mode 100644 docs/ci-cd.md create mode 100644 docs/ru/ci-cd.md create mode 100644 scripts/bump_version.py create mode 100644 tests/unit/test_version.py diff --git a/Dockerfile b/Dockerfile index f6580a9..f91546c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,7 @@ ENV PATH="/opt/venv/bin:$PATH" \ RUN groupadd --system --gid 10001 simulator \ && useradd --system --uid 10001 --gid simulator --home-dir /app --no-create-home simulator COPY --from=builder /opt/venv /opt/venv +COPY VERSION /app/VERSION COPY contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json /app/contracts/pve-9.2.3.json # Optional legacy PVE stub plane only (ENABLE_PVE_STUB=true). Native vSphere # contracts live under contracts/vsphere/ and app/vsphere/. diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..28ff78c --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,201 @@ +// VMware API Simulator — CI/CD: multi-arch push (Harbor + Docker Hub) + deploy +// +// Один файл: Build (DinD) → Deploy (kubectl set image в релиз simulators). +// Первый install lab — через DevOpsTools/K3S: make addon-simulators +// (Ingress https://vmware.devops.org.ru уже из addon values). +// +// Версия образа = VERSION из коммита. Локальный bump: make bump-patch / make push. +// Теги: :{VERSION} и :latest (без sha). +// +// Credentials (Global): +// harbor-devops-tools-push-pull-access — Harbor devops-tools (robot) +// docker-hub — Docker Hub (inecs) +// k3s-kubeconfig — kubeconfig к K3S +// ssh-gitea-key — SCM checkout Gitea (в job) + +pipeline { + agent none + + options { + buildDiscarder(logRotator(numToKeepStr: '20')) + disableConcurrentBuilds() + timeout(time: 60, unit: 'MINUTES') + timestamps() + } + + environment { + HARBOR_REGISTRY = 'hub.antropoff.ru' + HARBOR_IMAGE = 'hub.antropoff.ru/devops-tools/vmware-api-simulator' + DOCKERHUB_IMAGE = 'inecs/vmware-api-simulator' + BUILDX_BUILDER = "jenkins-vmware-api-simulator-${env.BUILD_NUMBER}" + HELM_NAMESPACE = 'simulators' + DEPLOYMENT_NAME = 'simulators-vmware' + INGRESS_HOST = 'vmware.devops.org.ru' + TZ = 'Europe/Moscow' + } + + stages { + stage('Build & push') { + when { + anyOf { + branch 'main' + branch 'master' + } + } + agent { label 'docker' } + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Version') { + steps { + script { + env.IMAGE_VERSION = readFile('VERSION').trim() + if (!env.IMAGE_VERSION) { + error('VERSION file is empty') + } + echo "IMAGE_VERSION from VERSION → ${env.IMAGE_VERSION}" + } + } + } + + stage('Buildx push') { + steps { + withCredentials([ + usernamePassword( + credentialsId: 'harbor-devops-tools-push-pull-access', + usernameVariable: 'HARBOR_USER', + passwordVariable: 'HARBOR_PASS' + ), + usernamePassword( + credentialsId: 'docker-hub', + usernameVariable: 'DOCKERHUB_USER', + passwordVariable: 'DOCKERHUB_PASS' + ) + ]) { + container('docker') { + sh ''' + set -eux + + test -n "${IMAGE_VERSION}" + echo "Building tags: ${IMAGE_VERSION}, latest (amd64+arm64)" + + echo "$HARBOR_PASS" | docker login "$HARBOR_REGISTRY" -u "$HARBOR_USER" --password-stdin + echo "$DOCKERHUB_PASS" | docker login -u "$DOCKERHUB_USER" --password-stdin + + docker buildx rm "$BUILDX_BUILDER" 2>/dev/null || true + docker buildx create --name "$BUILDX_BUILDER" --driver docker-container --use + docker buildx inspect --bootstrap >/dev/null + + docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --target runtime \ + --build-arg "APP_VERSION=${IMAGE_VERSION}" \ + --provenance=false --sbom=false --push \ + -t "${HARBOR_IMAGE}:${IMAGE_VERSION}" \ + -t "${HARBOR_IMAGE}:latest" \ + -t "${DOCKERHUB_IMAGE}:${IMAGE_VERSION}" \ + -t "${DOCKERHUB_IMAGE}:latest" \ + -f Dockerfile . + + echo "--- Harbor ---" + docker buildx imagetools inspect "${HARBOR_IMAGE}:${IMAGE_VERSION}" | sed -n '1,40p' + echo "--- Docker Hub ---" + docker buildx imagetools inspect "${DOCKERHUB_IMAGE}:${IMAGE_VERSION}" | sed -n '1,40p' + + docker buildx rm "$BUILDX_BUILDER" || true + ''' + } + } + } + } + + stage('Deploy') { + steps { + withCredentials([ + file(credentialsId: 'k3s-kubeconfig', variable: 'KUBECONFIG') + ]) { + container('docker') { + sh ''' + set -eux + + if ! command -v kubectl >/dev/null 2>&1; then + apk add --no-cache curl >/dev/null + ARCH="$(uname -m)" + case "$ARCH" in + x86_64) ARCH=amd64 ;; + aarch64|arm64) ARCH=arm64 ;; + esac + KVER="$(curl -fsSL https://dl.k8s.io/release/stable.txt)" + curl -fsSLo /usr/local/bin/kubectl \ + "https://dl.k8s.io/release/${KVER}/bin/linux/${ARCH}/kubectl" + chmod +x /usr/local/bin/kubectl + fi + + kubectl version --client || true + + if ! kubectl -n "$HELM_NAMESPACE" get deploy "$DEPLOYMENT_NAME" >/dev/null 2>&1; then + echo "Deployment ${HELM_NAMESPACE}/${DEPLOYMENT_NAME} not found." >&2 + echo "Сначала: make addon-simulators (Ingress ${INGRESS_HOST})." >&2 + exit 1 + fi + + echo "Rolling ${DOCKERHUB_IMAGE}:${IMAGE_VERSION} → ${HELM_NAMESPACE}/${DEPLOYMENT_NAME}" + echo "Public URL: https://${INGRESS_HOST}/" + + # One set-image avoids double ReplicaSet churn; migrate when present. + SET_ARGS=("simulator=${DOCKERHUB_IMAGE}:${IMAGE_VERSION}") + if kubectl -n "$HELM_NAMESPACE" get deploy "$DEPLOYMENT_NAME" \ + -o jsonpath='{.spec.template.spec.initContainers[*].name}' \ + | tr ' ' '\n' | grep -qx migrate; then + SET_ARGS+=("migrate=${DOCKERHUB_IMAGE}:${IMAGE_VERSION}") + fi + kubectl -n "$HELM_NAMESPACE" set image \ + "deployment/${DEPLOYMENT_NAME}" "${SET_ARGS[@]}" + + kubectl -n "$HELM_NAMESPACE" rollout status \ + "deployment/${DEPLOYMENT_NAME}" --timeout=1200s + kubectl -n "$HELM_NAMESPACE" get deploy,po,ing -o wide + ''' + } + } + } + } + } + post { + always { + script { + try { + container('docker') { + sh 'docker buildx rm "$BUILDX_BUILDER" 2>/dev/null || true' + } + } catch (Ignored) { + // pod may already be gone + } + } + } + success { + echo "✓ ${DOCKERHUB_IMAGE}:{${IMAGE_VERSION},latest} → https://${INGRESS_HOST}/" + } + failure { + echo "✗ Build/push/deploy failed — см. лог" + } + } + } + } + + post { + success { + echo "✓ Version: ${IMAGE_VERSION}" + echo "✓ Harbor: ${HARBOR_IMAGE}:{${IMAGE_VERSION},latest}" + echo "✓ Hub: ${DOCKERHUB_IMAGE}:{${IMAGE_VERSION},latest}" + echo "✓ Live: https://${INGRESS_HOST}/" + } + failure { + echo "✗ Pipeline failed" + } + } +} diff --git a/Makefile b/Makefile index 69b99e6..4b0a9cc 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ PYTEST_OFFLINE := -m "not integration and not compatibility and not pve_stub" # Docker Hub release image (runtime target only — not the local bind-mount "dev" image). DOCKERHUB_USER ?= inecs IMAGE_NAME ?= vmware-api-simulator -VERSION ?= $(shell sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml) +VERSION ?= $(shell cat VERSION 2>/dev/null || echo 0.1.0) DOCKER_IMAGE ?= $(DOCKERHUB_USER)/$(IMAGE_NAME) PUSH_LATEST ?= 1 @@ -20,11 +20,13 @@ LOCAL_HTTP_PORT ?= 18080 LOCAL_HTTPS_PORT ?= 18443 LOCAL_POSTGRES_PORT ?= 15434 -.PHONY: help install format lint typecheck test test-unit test-integration test-contract test-compatibility test-surface evidence coverage run dev up up-local down down-local restart logs docker-build docker-up docker-down docker-logs docker-restart db-up db-down db-migrate db-reset api-import api-diff seed clean ci ci-all shell push release release-build release-up release-down release-seed helm-deps helm-template \ +.PHONY: bump-patch bump-minor version-commit help install format lint typecheck test test-unit test-integration test-contract test-compatibility test-surface evidence coverage run dev up up-local down down-local restart logs docker-build docker-up docker-down docker-logs docker-restart db-up db-down db-migrate db-reset api-import api-diff seed clean ci ci-all shell push release release-build release-up release-down release-seed helm-deps helm-template \ pulumi-tests pulumi-tests-smoke test-pulumi-smoke test-pulumi test-smoke-all test-all clean-test-resources vsphere-universe vsphere-param-index vsphere-bundles vsphere-seed-dump help: ## Show available commands @awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_-]+:.*## / {printf "%-22s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + @echo + @echo "Version: $$(cat VERSION 2>/dev/null || echo 0.1.0) (make bump-patch | bump-minor)" install: ## Build runtime and development images @test -f .env || cp .env.example .env @@ -240,23 +242,41 @@ seed: ## Seed simulation data (vSphere; PROFILE= / VSPHERE_PROFILE=small|large|b shell: ## Open an interactive shell in the development container $(COMPOSE) run --rm --no-deps $(SERVICE_DEV) bash -push: ## git add ., multiline commit message (Ctrl-D), push to both remotes +bump-patch: ## VERSION +0.0.1 (pyproject + Helm) + @PYTHONPATH=. python3 scripts/bump_version.py patch + @echo "VERSION → $$(cat VERSION)" + +bump-minor: ## VERSION +0.1.0 (pyproject + Helm) + @PYTHONPATH=. python3 scripts/bump_version.py minor + @echo "VERSION → $$(cat VERSION)" + +version-commit: ## Stage version files and commit "Bump version to …" + @git add VERSION pyproject.toml helm/vmware-api-simulator/Chart.yaml helm/vmware-api-simulator/values.yaml + @if git diff --cached --quiet; then \ + echo "Nothing to commit for version files."; \ + else \ + git commit -m "Bump version to $$(cat VERSION)."; \ + fi + +push: ## bump-patch + commit version + push origin + @$(MAKE) bump-patch + @$(MAKE) version-commit @set -e; \ git add .; \ echo "=== staged ==="; \ git status --short; \ echo; \ if git diff --cached --quiet; then \ - echo "Nothing to commit — pushing current branch."; \ + echo "Nothing else to commit — pushing current branch."; \ else \ - echo "Enter commit message, then Ctrl-D:"; \ + echo "Enter commit message for remaining changes, then Ctrl-D:"; \ msg=$$(cat &2; exit 1; fi; \ git commit -m "$$msg"; \ fi; \ - echo "Pushing to both remotes via origin..."; \ - git remote get-url --push --all origin 2>/dev/null | sed 's/^/ - /' || true; \ - git push -u origin HEAD + echo "Pushing to both remotes:"; \ + git remote get-url --push --all origin | sed 's/^/ - /'; \ + git push origin HEAD clean: ## Remove generated local artifacts rm -rf .coverage coverage.xml htmlcov .mypy_cache .pytest_cache .ruff_cache diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..17e51c3 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.1 diff --git a/app/main.py b/app/main.py index 220f8d9..4a172d4 100644 --- a/app/main.py +++ b/app/main.py @@ -30,6 +30,7 @@ from app.tasks.repository import TaskRepository from app.tasks.worker import TaskWorker from app.vsphere.rest import vsphere_rest_router from app.vsphere.soap.router import router as vsphere_soap_router +from app.version import get_app_version from app.web.routes import router as web_router @@ -117,7 +118,7 @@ def create_app( resolved_workers = (task_worker,) app = FastAPI( title=resolved.app_name, - version="0.1.0", + version=get_app_version(), openapi_tags=openapi_tag_metadata(include_pve=pve_stub), lifespan=create_lifespan(resolved, database_factory, resolved_workers or ()), ) diff --git a/app/version.py b/app/version.py new file mode 100644 index 0000000..714cd85 --- /dev/null +++ b/app/version.py @@ -0,0 +1,130 @@ +"""SemVer source of truth: VERSION file (synced to pyproject + Helm).""" + +from __future__ import annotations + +import re +from functools import lru_cache +from pathlib import Path + +_VERSION_FILE = Path(__file__).resolve().parents[1] / "VERSION" +_PROJECT_ROOT = _VERSION_FILE.parent +_PYPROJECT_FILE = _PROJECT_ROOT / "pyproject.toml" +_CHART_FILE = _PROJECT_ROOT / "helm" / "vmware-api-simulator" / "Chart.yaml" +_VALUES_FILE = _PROJECT_ROOT / "helm" / "vmware-api-simulator" / "values.yaml" +_DEFAULT_VERSION = "0.1.0" +_VERSION_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") + + +def _read_version_file(path: Path) -> str | None: + try: + text = path.read_text(encoding="utf-8").strip() + except OSError: + return None + if _VERSION_RE.match(text): + return text + return None + + +@lru_cache +def get_app_version() -> str: + for path in ( + Path("/app/VERSION"), + _VERSION_FILE, + Path.cwd() / "VERSION", + ): + found = _read_version_file(path) + if found: + return found + try: + from importlib.metadata import version + + meta = version("vmware-api-simulator") + if _VERSION_RE.match(meta): + return meta + except Exception: + pass + return _DEFAULT_VERSION + + +def get_app_version_label() -> str: + clear_version_cache() + return f"v{get_app_version()}" + + +def parse_version(value: str) -> tuple[int, int, int]: + match = _VERSION_RE.match(value.strip()) + if not match: + raise ValueError(f"Invalid semantic version: {value!r}") + return int(match.group(1)), int(match.group(2)), int(match.group(3)) + + +def format_version(major: int, minor: int, patch: int) -> str: + return f"{major}.{minor}.{patch}" + + +def clear_version_cache() -> None: + get_app_version.cache_clear() + + +def write_project_version(version: str) -> None: + parse_version(version) + _VERSION_FILE.write_text(f"{version}\n", encoding="utf-8") + + pyproject = _PYPROJECT_FILE.read_text(encoding="utf-8") + pyproject, count = re.subn( + r'^version = ".*"$', + f'version = "{version}"', + pyproject, + count=1, + flags=re.MULTILINE, + ) + if count != 1: + raise RuntimeError("Failed to update pyproject.toml version") + _PYPROJECT_FILE.write_text(pyproject, encoding="utf-8") + + chart = _CHART_FILE.read_text(encoding="utf-8") + chart, chart_count = re.subn( + r"^version: .*$", + f"version: {version}", + chart, + count=1, + flags=re.MULTILINE, + ) + chart, app_count = re.subn( + r'^appVersion: ".*"$', + f'appVersion: "{version}"', + chart, + count=1, + flags=re.MULTILINE, + ) + if chart_count != 1 or app_count != 1: + raise RuntimeError("Failed to update Chart.yaml version") + _CHART_FILE.write_text(chart, encoding="utf-8") + + values = _VALUES_FILE.read_text(encoding="utf-8") + values, values_count = re.subn( + r'^(\s*tag:\s*).*$', + rf'\1"{version}"', + values, + count=1, + flags=re.MULTILINE, + ) + if values_count != 1: + raise RuntimeError("Failed to update helm values.yaml image.tag") + _VALUES_FILE.write_text(values, encoding="utf-8") + + clear_version_cache() + + +def bump_patch_version() -> str: + major, minor, patch = parse_version(get_app_version()) + version = format_version(major, minor, patch + 1) + write_project_version(version) + return version + + +def bump_minor_version() -> str: + major, minor, patch = parse_version(get_app_version()) + version = format_version(major, minor + 1, 0) + write_project_version(version) + return version diff --git a/app/web/contract_catalog.py b/app/web/contract_catalog.py index cf1e726..7a05ac9 100644 --- a/app/web/contract_catalog.py +++ b/app/web/contract_catalog.py @@ -122,8 +122,10 @@ def list_majors( *, runtime_version: str | None, settings: Settings | None = None, + app_version: str | None = None, ) -> dict[str, object]: return { + "app_version": app_version, "runtime_version": runtime_version, "majors": [ { diff --git a/app/web/index.html b/app/web/index.html index 8e99887..00634dc 100644 --- a/app/web/index.html +++ b/app/web/index.html @@ -795,7 +795,12 @@ stroke: none; } + .help-about-meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.45rem 0.65rem; margin: 0; padding: 0 14px 12px; font-size: 11px; @@ -809,6 +814,22 @@ color: var(--text); } + .help-about-version { + flex-shrink: 0; + padding: 0.22rem 0.55rem; + border-radius: 999px; + border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); + background: color-mix(in srgb, var(--accent) 14%, transparent); + color: var(--accent); + font-family: var(--mono); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.03em; + line-height: 1; + white-space: nowrap; + } + + .help-panel { flex: 1; min-height: 0; @@ -3971,6 +3992,7 @@ major: 9, majors: [], runtimeVersion: null, + appVersion: null, catalog: null, method: null, pathValues: {}, @@ -6023,6 +6045,7 @@ function buildAboutHtml() { const version = state.runtimeVersion || "—"; const catalogMajor = state.major || "—"; + const appVersion = state.appVersion ? `v${state.appVersion}` : "v—"; return `
@@ -6032,7 +6055,10 @@ automation, and infrastructure tooling without a real hypervisor cluster.

- Runtime ${escapeHtml(version)} · catalog ${escapeHtml(seriesLabel(catalogMajor))} + ${escapeHtml(appVersion)} + + Runtime ${escapeHtml(version)} · catalog ${escapeHtml(String(catalogMajor))} +

@@ -7189,6 +7215,7 @@ async function loadVersions() { const p = await fetch("/ui/api/versions").then((r) => r.json()); state.runtimeVersion = p.runtime_version; + state.appVersion = p.app_version || null; state.majors = p.majors || []; setText(els.statRuntime, p.runtime_version || "—"); const storedMajor = readStoredMajor(); diff --git a/app/web/routes.py b/app/web/routes.py index 3df8d8d..1a30925 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -18,6 +18,7 @@ from app.dependencies import get_database from app.simulation.seed import apply_seed, build_profile, simulation_state_summary from app.web.assets import compact_console_html, console_html from app.web.compatibility_catalog import compatibility_payload +from app.version import get_app_version from app.web.contract_catalog import catalog_payload, list_majors, load_snapshot, method_payload router = APIRouter(tags=["Simulator"]) @@ -55,8 +56,12 @@ async def ui_versions(request: Request) -> JSONResponse: if _use_vsphere_plane(request): from app.vsphere.contracts.catalog import list_vsphere_majors - return JSONResponse(list_vsphere_majors(runtime_version=runtime_version)) - return JSONResponse(list_majors(runtime_version=runtime_version, settings=settings)) + payload = list_vsphere_majors(runtime_version=runtime_version) + else: + payload = list_majors(runtime_version=runtime_version, settings=settings) + if isinstance(payload, dict): + payload = {**payload, "app_version": get_app_version()} + return JSONResponse(payload) @router.get("/ui/api/catalog", include_in_schema=False) diff --git a/docs/README.md b/docs/README.md index 9f59c5d..32ef69a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -21,6 +21,7 @@ Interactive console walkthrough: [Web UI](web-ui.md). | [Seed profiles](seed-profiles.md) | Deterministic inventory fixtures | | [Domains](domains/README.md) | Session, inventory, VM, storage, networking, tagging, SOAP, tasks, … | | [Web UI](web-ui.md) | Interactive console and catalogs | +| [CI/CD (Jenkins)](ci-cd.md) | VERSION, Harbor/Hub push, deploy | | [Operations](operations.md) | Reseed, migrate, release, upgrade | | [Kubernetes / Helm](kubernetes.md) | Hub image + Ingress + Let's Encrypt | | [Security](security.md) | Lab threat model and credentials | diff --git a/docs/ci-cd.md b/docs/ci-cd.md new file mode 100644 index 0000000..fbc9b25 --- /dev/null +++ b/docs/ci-cd.md @@ -0,0 +1,78 @@ +**Language / Язык:** [English](ci-cd.md) | [Русский](ru/ci-cd.md) + +# CI/CD (Jenkins + Gitea) + +SemVer lives in [`VERSION`](../VERSION). Bump locally with `make bump-patch` / +`make bump-minor` (also syncs `pyproject.toml` and Helm chart/values). Jenkins +does **not** bump — it builds whatever is in the commit. + +Public lab URL after deploy: **https://vmware.devops.org.ru/** + +## Pipeline + +Single [`Jenkinsfile`](../Jenkinsfile) on `main` / `master`: + +1. **Build & push** (agent `docker` / DinD) — multi-arch `linux/amd64,linux/arm64` + to Harbor and Docker Hub with tags **`:{VERSION}`** and **`:latest`** only. +2. **Deploy** — `kubectl set image` on `simulators/simulators-vmware` (main + container `simulator` + initContainer `migrate`), then rollout status + (`--timeout=1200s`). + +First cluster install (Ingress, Postgres, secrets) is **not** this job — use +`make addon-simulators` in DevOpsTools/K3S (`simulators_proxmox_host: +vmware.devops.org.ru`). + +### Images + +| Registry | Repository | +|---|---| +| Harbor | `hub.antropoff.ru/devops-tools/vmware-api-simulator` | +| Docker Hub | `inecs/vmware-api-simulator` | + +### Credentials (Jenkins Global) + +| ID | Type | Use | +|---|---|---| +| `harbor-devops-tools-push-pull-access` | Username/password | Harbor robot push | +| `docker-hub` | Username/password | Docker Hub `inecs` | +| `k3s-kubeconfig` | Secret file | Kubeconfig for deploy | +| `ssh-gitea-key` | SSH private key | Gitea SCM checkout | + +## Gitea setup + +1. Ensure the repo is on Gitea (mirror or primary) with branch `main`. +2. Deploy key / Jenkins credential `ssh-gitea-key` can clone the repo + (`git@…:…/vmware_api_simulator.git` or your path). +3. Optional: webhook from Gitea → Jenkins Multibranch (or rely on Jenkins SCM + polling / Organization Folder). + +## Jenkins setup + +1. **Credentials** — create the four IDs above if missing (same as Wrapped). +2. **Agent** — Kubernetes cloud pod template with label `docker` and a + `docker` container (DinD / buildx), same as Wrapped. +3. **Job** — Multibranch Pipeline (or Pipeline from SCM): + - Script Path: `Jenkinsfile` + - Branch discover: `main` (and `master` if needed) + - SCM: Gitea SSH URL + credential `ssh-gitea-key` +4. **Harbor project** — `devops-tools` must allow the robot to push + `vmware-api-simulator` (create the repository on first push or pre-create). +5. **Docker Hub** — `inecs/vmware-api-simulator` exists / push rights for + credential `docker-hub`. +6. **Cluster** — release `simulators` in namespace `simulators` already + installed (`make addon-simulators`). Deployment name must be + `simulators-vmware`. +7. Run once on `main` after bumping `VERSION` and pushing. Confirm: + - Harbor/Hub tags `:0.x.y` and `:latest` + - `kubectl -n simulators get deploy,ing` + - Help → About shows badge `v0.x.y` on https://vmware.devops.org.ru/ + +## Local release (without Jenkins) + +```bash +make bump-patch # or bump-minor +make version-commit +git push origin HEAD +# or manual Hub-only: +make release # DOCKER_IMAGE=inecs/vmware-api-simulator +``` diff --git a/docs/ru/README.md b/docs/ru/README.md index e31dbda..2146bef 100644 --- a/docs/ru/README.md +++ b/docs/ru/README.md @@ -21,6 +21,7 @@ | [Профили seed](seed-profiles.md) | Детерминированные фикстуры инвентаря | | [Домены](domains/README.md) | Session, inventory, VM, storage, networking, tagging, SOAP, tasks, … | | [Web UI](web-ui.md) | Интерактивная консоль и каталоги | +| [CI/CD (Jenkins)](ci-cd.md) | VERSION, push Harbor/Hub, деплой | | [Эксплуатация](operations.md) | Reseed, migrate, release, upgrade | | [Kubernetes / Helm](kubernetes.md) | Образ Hub + Ingress + Let's Encrypt | | [Безопасность](security.md) | Модель угроз лаборатории и учётные данные | diff --git a/docs/ru/ci-cd.md b/docs/ru/ci-cd.md new file mode 100644 index 0000000..7d770ad --- /dev/null +++ b/docs/ru/ci-cd.md @@ -0,0 +1,77 @@ +**Language / Язык:** [English](../ci-cd.md) | [Русский](ci-cd.md) + +# CI/CD (Jenkins + Gitea) + +SemVer хранится в [`VERSION`](../../VERSION). Локальный bump: +`make bump-patch` / `make bump-minor` (синхронизирует `pyproject.toml` и Helm). +Jenkins версию **не** поднимает — собирает то, что в коммите. + +Публичный URL после деплоя: **https://vmware.devops.org.ru/** + +## Pipeline + +Один [`Jenkinsfile`](../../Jenkinsfile) на ветках `main` / `master`: + +1. **Build & push** (agent `docker` / DinD) — multi-arch `linux/amd64,linux/arm64` + в Harbor и Docker Hub, теги только **`:{VERSION}`** и **`:latest`**. +2. **Deploy** — `kubectl set image` для `simulators/simulators-vmware` + (контейнер `simulator` + initContainer `migrate`), затем rollout status + (`--timeout=1200s`). + +Первый install в кластере (Ingress, Postgres, секреты) — **не** этот job, а +`make addon-simulators` в DevOpsTools/K3S (`simulators_proxmox_host: +vmware.devops.org.ru`). + +### Образы + +| Registry | Repository | +|---|---| +| Harbor | `hub.antropoff.ru/devops-tools/vmware-api-simulator` | +| Docker Hub | `inecs/vmware-api-simulator` | + +### Credentials (Jenkins Global) + +| ID | Тип | Назначение | +|---|---|---| +| `harbor-devops-tools-push-pull-access` | Username/password | Push в Harbor | +| `docker-hub` | Username/password | Docker Hub `inecs` | +| `k3s-kubeconfig` | Secret file | Kubeconfig для deploy | +| `ssh-gitea-key` | SSH private key | Checkout из Gitea | + +## Что сделать в Gitea + +1. Репозиторий на Gitea (зеркало или primary), ветка `main`. +2. Deploy key / credential Jenkins `ssh-gitea-key` с правом clone + (`git@…:…/vmware_api_simulator.git`). +3. По желанию: webhook Gitea → Jenkins Multibranch (или polling / Organization + Folder). + +## Что сделать в Jenkins + +1. **Credentials** — четыре ID выше (как у Wrapped), если ещё нет. +2. **Agent** — pod template label `docker` с контейнером `docker` (DinD / + buildx), как у Wrapped. +3. **Job** — Multibranch Pipeline (или Pipeline from SCM): + - Script Path: `Jenkinsfile` + - Ветки: `main` (и `master` при необходимости) + - SCM: SSH URL Gitea + `ssh-gitea-key` +4. **Harbor** — проект `devops-tools`, robot может push + `vmware-api-simulator`. +5. **Docker Hub** — репозиторий `inecs/vmware-api-simulator`, права у + `docker-hub`. +6. **Кластер** — релиз `simulators` в ns `simulators` уже установлен + (`make addon-simulators`). Deployment: `simulators-vmware`. +7. После bump `VERSION` и push в `main` — прогнать job и проверить: + - теги `:0.x.y` и `:latest` в Harbor/Hub + - `kubectl -n simulators get deploy,ing` + - Help → About: badge `v0.x.y` на https://vmware.devops.org.ru/ + +## Локальный релиз без Jenkins + +```bash +make bump-patch # или bump-minor +make version-commit +git push origin HEAD +# или только Hub вручную: +make release +``` diff --git a/helm/vmware-api-simulator/Chart.yaml b/helm/vmware-api-simulator/Chart.yaml index ada6714..fac78c8 100644 --- a/helm/vmware-api-simulator/Chart.yaml +++ b/helm/vmware-api-simulator/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: vmware-api-simulator description: Stateful VMware API simulator (PostgreSQL-backed) for labs and CI type: application -version: 0.1.0 -appVersion: "0.1.0" +version: 0.1.1 +appVersion: "0.1.1" home: https://github.com/inecs/vmware-api-simulator keywords: - vmware diff --git a/helm/vmware-api-simulator/values-ingress-example.yaml b/helm/vmware-api-simulator/values-ingress-example.yaml index 8fc03d5..b7586a7 100644 --- a/helm/vmware-api-simulator/values-ingress-example.yaml +++ b/helm/vmware-api-simulator/values-ingress-example.yaml @@ -4,8 +4,8 @@ # -n vmware-sim --create-namespace \ # -f ./helm/vmware-api-simulator/values-ingress-example.yaml \ # --set certManager.email=you@example.com \ -# --set ingress.hosts[0].host=vmware-sim.example.com \ -# --set ingress.tls[0].hosts[0]=vmware-sim.example.com \ +# --set ingress.hosts[0].host=vmware.devops.org.ru \ +# --set ingress.tls[0].hosts[0]=vmware.devops.org.ru \ # --set secret.ticketSigningKey="$(openssl rand -hex 32)" \ # --set postgresql.auth.password="$(openssl rand -hex 16)" @@ -40,14 +40,14 @@ ingress: # custom-http-errors on this Ingress (overrides controller defaults): nginx.ingress.kubernetes.io/custom-http-errors: "502,503" hosts: - - host: vmware-sim.example.com + - host: vmware.devops.org.ru paths: - path: / pathType: Prefix tls: - secretName: vmware-api-simulator-tls hosts: - - vmware-sim.example.com + - vmware.devops.org.ru certManager: enabled: true diff --git a/helm/vmware-api-simulator/values.yaml b/helm/vmware-api-simulator/values.yaml index d69e518..3695a3e 100644 --- a/helm/vmware-api-simulator/values.yaml +++ b/helm/vmware-api-simulator/values.yaml @@ -7,7 +7,7 @@ image: repository: inecs/vmware-api-simulator pullPolicy: IfNotPresent # Overrides the image tag whose default is the chart appVersion. - tag: "" + tag: "0.1.1" imagePullSecrets: [] nameOverride: "" diff --git a/pyproject.toml b/pyproject.toml index 5833a0d..ac40201 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "vmware-api-simulator" -version = "0.1.0" +version = "0.1.1" description = "Stateful asynchronous VMware / vSphere API simulator" readme = "README.md" requires-python = ">=3.13,<3.14" diff --git a/scripts/bump_version.py b/scripts/bump_version.py new file mode 100644 index 0000000..a75bb02 --- /dev/null +++ b/scripts/bump_version.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""CLI: bump project SemVer (VERSION + pyproject + Helm chart/values).""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from app.version import bump_minor_version, bump_patch_version # noqa: E402 + + +def main(argv: list[str] | None = None) -> int: + args = argv if argv is not None else sys.argv[1:] + if len(args) != 1 or args[0] not in {"patch", "minor"}: + print("Usage: bump_version.py patch|minor", file=sys.stderr) + return 1 + version = bump_patch_version() if args[0] == "patch" else bump_minor_version() + print(version) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/unit/test_version.py b/tests/unit/test_version.py new file mode 100644 index 0000000..bd0b417 --- /dev/null +++ b/tests/unit/test_version.py @@ -0,0 +1,14 @@ +"""SemVer helpers.""" + +from __future__ import annotations + +from app.version import get_app_version, get_app_version_label, parse_version + + +def test_get_app_version_reads_version_file() -> None: + version = get_app_version() + major, minor, patch = parse_version(version) + assert major >= 0 + assert minor >= 0 + assert patch >= 0 + assert get_app_version_label() == f"v{version}"