Compare commits

...

2 Commits

Author SHA1 Message Date
Sergey Antropoff 65af983dea Raise deploy rollout timeout to 1200s and set images in one step.
Avoid double ReplicaSet churn and ImagePullBackOff races during Hub pulls.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 00:56:46 +03:00
Sergey Antropoff f81d6186ab Add SemVer VERSION, Jenkins CI/CD, and release 0.1.1.
Ship Harbor/Hub push with latest+version tags, deploy to proxmox.devops.org.ru, and show the package version badge in Help → About.
2026-07-28 00:24:05 +03:00
21 changed files with 610 additions and 19 deletions
+1
View File
@@ -27,6 +27,7 @@ ENV PATH="/opt/venv/bin:$PATH" \
RUN groupadd --system --gid 10001 simulator \ RUN groupadd --system --gid 10001 simulator \
&& useradd --system --uid 10001 --gid simulator --home-dir /app --no-create-home simulator && useradd --system --uid 10001 --gid simulator --home-dir /app --no-create-home simulator
COPY --from=builder /opt/venv /opt/venv COPY --from=builder /opt/venv /opt/venv
COPY VERSION /app/VERSION
COPY contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json /app/contracts/pve-9.2.3.json COPY contracts/e61a893e996d05d376579226e7dfbedbcfce8b71787adacffbc557e6e35901c1/snapshot.json /app/contracts/pve-9.2.3.json
COPY evidence/ /app/evidence/ COPY evidence/ /app/evidence/
WORKDIR /app WORKDIR /app
Vendored
+201
View File
@@ -0,0 +1,201 @@
// Proxmox 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://proxmox.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/proxmox-api-simulator'
DOCKERHUB_IMAGE = 'inecs/proxmox-api-simulator'
BUILDX_BUILDER = "jenkins-proxmox-api-simulator-${env.BUILD_NUMBER}"
HELM_NAMESPACE = 'simulators'
DEPLOYMENT_NAME = 'simulators-proxmox'
INGRESS_HOST = 'proxmox.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"
}
}
}
+26 -5
View File
@@ -4,19 +4,22 @@ SERVICE_SIM := simulator
PYTEST_OFFLINE := -m "not integration and not compatibility" PYTEST_OFFLINE := -m "not integration and not compatibility"
# Docker Hub release image (runtime target only — not the local bind-mount "dev" image). # Docker Hub release image (runtime target only — not the local bind-mount "dev" image).
# SemVer source of truth: VERSION (synced to pyproject + Helm via bump targets).
DOCKERHUB_USER ?= inecs DOCKERHUB_USER ?= inecs
IMAGE_NAME ?= proxmox-api-simulator IMAGE_NAME ?= proxmox-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) DOCKER_IMAGE ?= $(DOCKERHUB_USER)/$(IMAGE_NAME)
PUSH_LATEST ?= 1 PUSH_LATEST ?= 1
COMPOSE_RELEASE ?= $(COMPOSE) -f docker-compose.release.yml COMPOSE_RELEASE ?= $(COMPOSE) -f docker-compose.release.yml
HELM_CHART ?= ./helm/proxmox-api-simulator HELM_CHART ?= ./helm/proxmox-api-simulator
.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 release release-build release-up release-down release-seed helm-deps helm-template helm-lint pulumi-tests push .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 release release-build release-up release-down release-seed helm-deps helm-template helm-lint pulumi-tests push bump-patch bump-minor version-commit
help: ## Show available commands help: ## Show available commands
@awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_-]+:.*## / {printf "%-22s %s\n", $$1, $$2}' $(MAKEFILE_LIST) @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 install: ## Build runtime and development images
@test -f .env || cp .env.example .env @test -f .env || cp .env.example .env
@@ -213,16 +216,34 @@ pulumi-tests: ## Full Pulumi suite (surface majors 69 + lifecycle, HTML repor
$(MAKE) -C pulumi-tests up $(MAKE) -C pulumi-tests up
$(MAKE) -C pulumi-tests test $(MAKE) -C pulumi-tests test
push: ## git add ., prompt for commit message, push origin (GitHub + antropoff.ru) 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/proxmox-api-simulator/Chart.yaml helm/proxmox-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 (GitHub + antropoff.ru)
@$(MAKE) bump-patch
@$(MAKE) version-commit
@set -e; \ @set -e; \
git add .; \ git add .; \
echo "=== staged ==="; \ echo "=== staged ==="; \
git status --short; \ git status --short; \
echo; \ echo; \
if git diff --cached --quiet; then \ if git diff --cached --quiet; then \
echo "Nothing to commit — pushing current branch."; \ echo "Nothing else to commit — pushing current branch."; \
else \ else \
echo "Enter commit message, then Ctrl-D:"; \ echo "Enter commit message for remaining changes, then Ctrl-D:"; \
msg=$$(cat </dev/tty); \ msg=$$(cat </dev/tty); \
if [ -z "$$msg" ]; then echo "Empty commit message, aborting." >&2; exit 1; fi; \ if [ -z "$$msg" ]; then echo "Empty commit message, aborting." >&2; exit 1; fi; \
git commit -m "$$msg"; \ git commit -m "$$msg"; \
+1
View File
@@ -0,0 +1 @@
0.1.1
+2 -1
View File
@@ -25,6 +25,7 @@ from app.tasks.lxc import lxc_handler
from app.tasks.qemu import qemu_handler from app.tasks.qemu import qemu_handler
from app.tasks.repository import TaskRepository from app.tasks.repository import TaskRepository
from app.tasks.worker import TaskWorker from app.tasks.worker import TaskWorker
from app.version import get_app_version
from app.web.routes import router as web_router from app.web.routes import router as web_router
@@ -117,7 +118,7 @@ def create_app(
resolved_workers = (task_worker,) resolved_workers = (task_worker,)
app = FastAPI( app = FastAPI(
title=resolved.app_name, title=resolved.app_name,
version="0.1.0", version=get_app_version(),
openapi_tags=openapi_tag_metadata(), openapi_tags=openapi_tag_metadata(),
lifespan=create_lifespan(resolved, database_factory, resolved_workers or ()), lifespan=create_lifespan(resolved, database_factory, resolved_workers or ()),
) )
+130
View File
@@ -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" / "proxmox-api-simulator" / "Chart.yaml"
_VALUES_FILE = _PROJECT_ROOT / "helm" / "proxmox-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("proxmox-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
+2
View File
@@ -102,8 +102,10 @@ def list_majors(
*, *,
runtime_version: str | None, runtime_version: str | None,
settings: Settings | None = None, settings: Settings | None = None,
app_version: str | None = None,
) -> dict[str, object]: ) -> dict[str, object]:
return { return {
"app_version": app_version,
"runtime_version": runtime_version, "runtime_version": runtime_version,
"majors": [ "majors": [
{ {
+25
View File
@@ -796,6 +796,10 @@
} }
.help-about-meta { .help-about-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem 0.65rem;
margin: 0; margin: 0;
padding: 0 14px 12px; padding: 0 14px 12px;
font-size: 11px; font-size: 11px;
@@ -809,6 +813,21 @@
color: var(--text); 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 { .help-panel {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@@ -3932,6 +3951,7 @@
major: 9, major: 9,
majors: [], majors: [],
runtimeVersion: null, runtimeVersion: null,
appVersion: null,
catalog: null, catalog: null,
method: null, method: null,
pathValues: {}, pathValues: {},
@@ -5797,6 +5817,7 @@
function buildAboutHtml() { function buildAboutHtml() {
const version = state.runtimeVersion || "—"; const version = state.runtimeVersion || "—";
const catalogMajor = state.major || "—"; const catalogMajor = state.major || "—";
const appVersion = state.appVersion ? `v${state.appVersion}` : "v—";
return ` return `
<div class="help-about-panel"> <div class="help-about-panel">
<div class="help-report-head"> <div class="help-report-head">
@@ -5806,7 +5827,10 @@
automation, and infrastructure tooling without a real hypervisor cluster. automation, and infrastructure tooling without a real hypervisor cluster.
</p> </p>
<p class="help-about-meta"> <p class="help-about-meta">
<span class="help-about-version" title="Simulator package version">${escapeHtml(appVersion)}</span>
<span>
Runtime <code>${escapeHtml(version)}</code> · catalog PVE <code>${escapeHtml(String(catalogMajor))}</code> Runtime <code>${escapeHtml(version)}</code> · catalog PVE <code>${escapeHtml(String(catalogMajor))}</code>
</span>
</p> </p>
</div> </div>
<div class="help-report-head"> <div class="help-report-head">
@@ -6905,6 +6929,7 @@
async function loadVersions() { async function loadVersions() {
const p = await fetch("/ui/api/versions").then((r) => r.json()); const p = await fetch("/ui/api/versions").then((r) => r.json());
state.runtimeVersion = p.runtime_version; state.runtimeVersion = p.runtime_version;
state.appVersion = p.app_version || null;
state.majors = p.majors || []; state.majors = p.majors || [];
setText(els.statRuntime, p.runtime_version || "—"); setText(els.statRuntime, p.runtime_version || "—");
const storedMajor = readStoredMajor(); const storedMajor = readStoredMajor();
+8 -1
View File
@@ -16,6 +16,7 @@ from app.contracts.source import SourceError
from app.db.pool import AsyncpgDatabase from app.db.pool import AsyncpgDatabase
from app.dependencies import get_database from app.dependencies import get_database
from app.simulation.seed import apply_seed, build_profile, simulation_state_summary from app.simulation.seed import apply_seed, build_profile, simulation_state_summary
from app.version import get_app_version
from app.web.assets import console_html from app.web.assets import console_html
from app.web.compatibility_catalog import compatibility_payload from app.web.compatibility_catalog import compatibility_payload
from app.web.contract_catalog import catalog_payload, list_majors, load_snapshot, method_payload from app.web.contract_catalog import catalog_payload, list_majors, load_snapshot, method_payload
@@ -37,7 +38,13 @@ async def console() -> HTMLResponse:
async def ui_versions(request: Request) -> JSONResponse: async def ui_versions(request: Request) -> JSONResponse:
settings = _settings(request) settings = _settings(request)
runtime_version = _runtime_version(request) runtime_version = _runtime_version(request)
return JSONResponse(list_majors(runtime_version=runtime_version, settings=settings)) return JSONResponse(
list_majors(
runtime_version=runtime_version,
settings=settings,
app_version=get_app_version(),
)
)
@router.get("/ui/api/catalog", include_in_schema=False) @router.get("/ui/api/catalog", include_in_schema=False)
+1
View File
@@ -19,6 +19,7 @@ page. Russian mirrors live under [`ru/`](ru/README.md).
| [Domains](domains/README.md) | QEMU, LXC, storage, HA, SDN, … | | [Domains](domains/README.md) | QEMU, LXC, storage, HA, SDN, … |
| [Web UI](web-ui.md) | Interactive console and catalogs | | [Web UI](web-ui.md) | Interactive console and catalogs |
| [Operations](operations.md) | Migrate, reseed, upgrade, Hub publish | | [Operations](operations.md) | Migrate, reseed, upgrade, Hub publish |
| [CI/CD (Jenkins)](ci-cd.md) | VERSION, Harbor/Hub push, deploy to proxmox.devops.org.ru |
| [Docker Hub overview](docker-hub-overview.md) | Paste-ready Hub repository description | | [Docker Hub overview](docker-hub-overview.md) | Paste-ready Hub repository description |
| [Kubernetes / Helm](kubernetes.md) | Hub image + Ingress + Let's Encrypt | | [Kubernetes / Helm](kubernetes.md) | Hub image + Ingress + Let's Encrypt |
| [Security](security.md) | Lab threat model and credentials | | [Security](security.md) | Lab threat model and credentials |
+78
View File
@@ -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://proxmox.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-proxmox` (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:
proxmox.devops.org.ru`).
### Images
| Registry | Repository |
|---|---|
| Harbor | `hub.antropoff.ru/devops-tools/proxmox-api-simulator` |
| Docker Hub | `inecs/proxmox-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@…:…/proxmox_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
`proxmox-api-simulator` (create the repository on first push or pre-create).
5. **Docker Hub**`inecs/proxmox-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-proxmox`.
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://proxmox.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/proxmox-api-simulator
```
+1
View File
@@ -19,6 +19,7 @@
| [Домены](domains/README.md) | QEMU, LXC, storage, HA, SDN, … | | [Домены](domains/README.md) | QEMU, LXC, storage, HA, SDN, … |
| [Web UI](web-ui.md) | Интерактивная консоль и каталоги | | [Web UI](web-ui.md) | Интерактивная консоль и каталоги |
| [Эксплуатация](operations.md) | Миграция, reseed, обновление, публикация в Hub | | [Эксплуатация](operations.md) | Миграция, reseed, обновление, публикация в Hub |
| [CI/CD (Jenkins)](ci-cd.md) | VERSION, push Harbor/Hub, деплой на proxmox.devops.org.ru |
| [Обзор Docker Hub](../docker-hub-overview.md) | Готовый текст описания репозитория Hub (EN) | | [Обзор Docker Hub](../docker-hub-overview.md) | Готовый текст описания репозитория Hub (EN) |
| [Kubernetes / Helm](kubernetes.md) | Образ Hub + Ingress + Let's Encrypt | | [Kubernetes / Helm](kubernetes.md) | Образ Hub + Ingress + Let's Encrypt |
| [Безопасность](security.md) | Модель угроз лаборатории и учётные данные | | [Безопасность](security.md) | Модель угроз лаборатории и учётные данные |
+77
View File
@@ -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://proxmox.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-proxmox`
(контейнер `simulator` + initContainer `migrate`), затем rollout status
(`--timeout=1200s`).
Первый install в кластере (Ingress, Postgres, секреты) — **не** этот job, а
`make addon-simulators` в DevOpsTools/K3S (`simulators_proxmox_host:
proxmox.devops.org.ru`).
### Образы
| Registry | Repository |
|---|---|
| Harbor | `hub.antropoff.ru/devops-tools/proxmox-api-simulator` |
| Docker Hub | `inecs/proxmox-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@…:…/proxmox_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
`proxmox-api-simulator`.
5. **Docker Hub** — репозиторий `inecs/proxmox-api-simulator`, права у
`docker-hub`.
6. **Кластер** — релиз `simulators` в ns `simulators` уже установлен
(`make addon-simulators`). Deployment: `simulators-proxmox`.
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://proxmox.devops.org.ru/
## Локальный релиз без Jenkins
```bash
make bump-patch # или bump-minor
make version-commit
git push origin HEAD
# или только Hub вручную:
make release
```
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: proxmox-api-simulator name: proxmox-api-simulator
description: Stateful Proxmox VE API simulator (PostgreSQL-backed) for labs and CI description: Stateful Proxmox VE API simulator (PostgreSQL-backed) for labs and CI
type: application type: application
version: 0.1.0 version: 0.1.1
appVersion: "0.1.0" appVersion: "0.1.1"
home: https://github.com/sergeyantropoff/proxmox-api-simulator home: https://github.com/sergeyantropoff/proxmox-api-simulator
keywords: keywords:
- proxmox - proxmox
@@ -9,8 +9,8 @@
# -n proxmox-sim --create-namespace \ # -n proxmox-sim --create-namespace \
# -f helm/proxmox-api-simulator/values-ingress-example.yaml \ # -f helm/proxmox-api-simulator/values-ingress-example.yaml \
# --set certManager.email=you@example.com \ # --set certManager.email=you@example.com \
# --set ingress.hosts[0].host=pve-sim.example.com \ # --set 'ingress.hosts[0].host=proxmox.devops.org.ru' \
# --set ingress.tls[0].hosts[0]=pve-sim.example.com \ # --set 'ingress.tls[0].hosts[0]=proxmox.devops.org.ru' \
# --set secret.ticketSigningKey="$(openssl rand -hex 32)" \ # --set secret.ticketSigningKey="$(openssl rand -hex 32)" \
# --set postgresql.auth.password="$(openssl rand -hex 16)" # --set postgresql.auth.password="$(openssl rand -hex 16)"
@@ -45,14 +45,14 @@ ingress:
# custom-http-errors on this Ingress (overrides controller defaults): # custom-http-errors on this Ingress (overrides controller defaults):
nginx.ingress.kubernetes.io/custom-http-errors: "502,503" nginx.ingress.kubernetes.io/custom-http-errors: "502,503"
hosts: hosts:
- host: pve-sim.example.com - host: proxmox.devops.org.ru
paths: paths:
- path: / - path: /
pathType: Prefix pathType: Prefix
tls: tls:
- secretName: proxmox-api-simulator-tls - secretName: proxmox-api-simulator-tls
hosts: hosts:
- pve-sim.example.com - proxmox.devops.org.ru
certManager: certManager:
enabled: true enabled: true
+2 -2
View File
@@ -10,8 +10,8 @@ replicaCount: 1
image: image:
repository: inecs/proxmox-api-simulator repository: inecs/proxmox-api-simulator
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion. # Synced from VERSION via make bump-patch / scripts/bump_version.py
tag: "" tag: "0.1.1"
imagePullSecrets: [] imagePullSecrets: []
nameOverride: "" nameOverride: ""
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "proxmox-api-simulator" name = "proxmox-api-simulator"
version = "0.1.0" version = "0.1.1"
description = "Stateful asynchronous Proxmox VE API simulator" description = "Stateful asynchronous Proxmox VE API simulator"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13,<3.14" requires-python = ">=3.13,<3.14"
+27
View File
@@ -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())
+2 -1
View File
@@ -37,11 +37,12 @@ def _snapshot() -> Snapshot:
def test_list_majors_includes_latest_releases() -> None: def test_list_majors_includes_latest_releases() -> None:
payload = list_majors(runtime_version="9.2.3") payload = list_majors(runtime_version="9.2.3", app_version="0.1.0")
majors_list = cast(list[dict[str, Any]], payload["majors"]) majors_list = cast(list[dict[str, Any]], payload["majors"])
majors = {item["major"] for item in majors_list} majors = {item["major"] for item in majors_list}
assert majors == {6, 7, 8, 9} assert majors == {6, 7, 8, 9}
assert payload["runtime_version"] == "9.2.3" assert payload["runtime_version"] == "9.2.3"
assert payload["app_version"] == "0.1.0"
def test_list_majors_includes_artifact_urls() -> None: def test_list_majors_includes_artifact_urls() -> None:
+14
View File
@@ -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}"
+4 -1
View File
@@ -111,7 +111,10 @@ async def test_ui_versions_and_catalog_endpoints() -> None:
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
versions = await client.get("/ui/api/versions") versions = await client.get("/ui/api/versions")
assert versions.status_code == 200 assert versions.status_code == 200
assert {item["major"] for item in versions.json()["majors"]} == {6, 7, 8, 9} body = versions.json()
assert {item["major"] for item in body["majors"]} == {6, 7, 8, 9}
assert body["app_version"]
assert body["app_version"].count(".") == 2
catalog = await client.get("/ui/api/catalog", params={"major": 9}) catalog = await client.get("/ui/api/catalog", params={"major": 9})
assert catalog.status_code == 200 assert catalog.status_code == 200
assert catalog.json()["source_version"] == "9.2.3" assert catalog.json()["source_version"] == "9.2.3"