Add SemVer VERSION, Jenkins CI/CD, and release 0.1.1.
Ship Harbor/Hub push with latest+version tags, deploy to openstack.devops.org.ru, and show the package version badge in Help → About.
This commit is contained in:
@@ -26,6 +26,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
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
USER 10001:10001
|
USER 10001:10001
|
||||||
# Internal listen only — public OpenStack ports are on api-gateway.
|
# Internal listen only — public OpenStack ports are on api-gateway.
|
||||||
|
|||||||
Vendored
+201
@@ -0,0 +1,201 @@
|
|||||||
|
// OpenStack 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://openstack.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/openstack-api-simulator'
|
||||||
|
DOCKERHUB_IMAGE = 'inecs/openstack-api-simulator'
|
||||||
|
BUILDX_BUILDER = "jenkins-openstack-api-simulator-${env.BUILD_NUMBER}"
|
||||||
|
HELM_NAMESPACE = 'simulators'
|
||||||
|
DEPLOYMENT_NAME = 'simulators-openstack'
|
||||||
|
INGRESS_HOST = 'openstack.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ 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).
|
||||||
DOCKERHUB_USER ?= inecs
|
DOCKERHUB_USER ?= inecs
|
||||||
IMAGE_NAME ?= openstack-api-simulator
|
IMAGE_NAME ?= openstack-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
|
||||||
|
|
||||||
@@ -16,12 +16,14 @@ OVERRIDE_EXAMPLE ?= docker-compose.override.example.yml
|
|||||||
OVERRIDE_FILE ?= docker-compose.override.yml
|
OVERRIDE_FILE ?= docker-compose.override.yml
|
||||||
COMPOSE_LOCAL ?= $(COMPOSE) -f docker-compose.yml -f $(OVERRIDE_FILE)
|
COMPOSE_LOCAL ?= $(COMPOSE) -f docker-compose.yml -f $(OVERRIDE_FILE)
|
||||||
|
|
||||||
.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 seed-demo smoke 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 seed-demo smoke clean ci ci-all shell push release release-build release-up release-down release-seed helm-deps helm-template \
|
||||||
test-pulumi-smoke test-pulumi pulumi-tests test-smoke-all-lab test-all-lab clean-test-resources \
|
test-pulumi-smoke test-pulumi pulumi-tests test-smoke-all-lab test-all-lab clean-test-resources \
|
||||||
request-bodies-generate request-bodies-import request-bodies-coverage
|
request-bodies-generate request-bodies-import request-bodies-coverage
|
||||||
|
|
||||||
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
|
||||||
@@ -169,29 +171,41 @@ smoke: ## Keystone → multi-service OpenStack smoke
|
|||||||
shell: ## Open an interactive shell in the development container
|
shell: ## Open an interactive shell in the development container
|
||||||
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) bash
|
$(COMPOSE) run --rm --no-deps $(SERVICE_DEV) bash
|
||||||
|
|
||||||
push: ## git add ., multiline commit message (Ctrl-D), push origin + antropoff
|
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/openstack-api-simulator/Chart.yaml helm/openstack-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; \
|
@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 \
|
||||||
if [ -n "$(MSG)" ]; then \
|
echo "Enter commit message for remaining changes, then Ctrl-D:"; \
|
||||||
msg="$(MSG)"; \
|
msg=$$(cat </dev/tty); \
|
||||||
else \
|
|
||||||
echo "Enter commit message, then Ctrl-D:"; \
|
|
||||||
msg=$$(cat </dev/tty); \
|
|
||||||
fi; \
|
|
||||||
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"; \
|
||||||
fi; \
|
fi; \
|
||||||
echo "Pushing to origin and antropoff:"; \
|
echo "Pushing to both remotes:"; \
|
||||||
git remote get-url --push origin | sed 's/^/ - /'; \
|
git remote get-url --push --all origin | sed 's/^/ - /'; \
|
||||||
git remote get-url --push antropoff | sed 's/^/ - /'; \
|
git push origin HEAD
|
||||||
git push origin HEAD; \
|
|
||||||
git push antropoff HEAD
|
|
||||||
|
|
||||||
clean: ## Remove generated local artifacts
|
clean: ## Remove generated local artifacts
|
||||||
rm -rf .coverage coverage.xml htmlcov .mypy_cache .pytest_cache .ruff_cache
|
rm -rf .coverage coverage.xml htmlcov .mypy_cache .pytest_cache .ruff_cache
|
||||||
|
|||||||
+2
-1
@@ -26,6 +26,7 @@ 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.openstack.mount import mount_openstack_routes
|
from app.openstack.mount import mount_openstack_routes
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -92,7 +93,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
@@ -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" / "openstack-api-simulator" / "Chart.yaml"
|
||||||
|
_VALUES_FILE = _PROJECT_ROOT / "helm" / "openstack-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("openstack-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
|
||||||
@@ -122,8 +122,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": [
|
||||||
{
|
{
|
||||||
|
|||||||
+28
-1
@@ -798,7 +798,12 @@
|
|||||||
stroke: none;
|
stroke: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.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;
|
||||||
@@ -812,6 +817,22 @@
|
|||||||
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;
|
||||||
@@ -3888,6 +3909,7 @@
|
|||||||
major: 9,
|
major: 9,
|
||||||
majors: [],
|
majors: [],
|
||||||
runtimeVersion: null,
|
runtimeVersion: null,
|
||||||
|
appVersion: null,
|
||||||
osPack: null,
|
osPack: null,
|
||||||
pendingMicroversion: {},
|
pendingMicroversion: {},
|
||||||
catalog: null,
|
catalog: null,
|
||||||
@@ -6051,6 +6073,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">
|
||||||
@@ -6060,7 +6083,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">
|
||||||
Runtime <code>${escapeHtml(version)}</code> · catalog <code>${escapeHtml(seriesLabel(catalogMajor))}</code>
|
<span class="help-about-version" title="Simulator package version">${escapeHtml(appVersion)}</span>
|
||||||
|
<span>
|
||||||
|
Runtime <code>${escapeHtml(version)}</code> · catalog <code>${escapeHtml(String(catalogMajor))}</code>
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="help-report-head">
|
<div class="help-report-head">
|
||||||
@@ -7344,6 +7370,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();
|
||||||
|
|||||||
+6
-2
@@ -19,6 +19,7 @@ from app.openstack.demo_cloud import openstack_demo_summary, seed_openstack_demo
|
|||||||
from app.openstack.seed import seed_openstack
|
from app.openstack.seed import seed_openstack
|
||||||
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.version import get_app_version
|
||||||
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
|
||||||
|
|
||||||
router = APIRouter(tags=["Simulator"])
|
router = APIRouter(tags=["Simulator"])
|
||||||
@@ -41,10 +42,13 @@ async def ui_versions(request: Request) -> JSONResponse:
|
|||||||
runtime_version = _runtime_version(request)
|
runtime_version = _runtime_version(request)
|
||||||
# Prefer OpenStack contract packs when present.
|
# Prefer OpenStack contract packs when present.
|
||||||
try:
|
try:
|
||||||
return JSONResponse(openstack_series_majors(runtime_version))
|
payload = openstack_series_majors(runtime_version)
|
||||||
except Exception:
|
except Exception:
|
||||||
settings = _settings(request)
|
settings = _settings(request)
|
||||||
return JSONResponse(list_majors(runtime_version=runtime_version, settings=settings))
|
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)
|
@router.get("/ui/api/catalog", include_in_schema=False)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ header on each page. Russian mirrors live under [`ru/`](ru/README.md).
|
|||||||
| [Seed profiles](seed-profiles.md) | `minimal` / `demo` |
|
| [Seed profiles](seed-profiles.md) | `minimal` / `demo` |
|
||||||
| [Clients](clients.md) | SDK / CLI |
|
| [Clients](clients.md) | SDK / CLI |
|
||||||
| [Web UI](web-ui.md) | Console drawers |
|
| [Web UI](web-ui.md) | Console drawers |
|
||||||
|
| [CI/CD (Jenkins)](ci-cd.md) | VERSION, Harbor/Hub push, deploy |
|
||||||
| [Operations](operations.md) | Day-2, release, reseed, **testing** |
|
| [Operations](operations.md) | Day-2, release, reseed, **testing** |
|
||||||
| [Architecture](architecture.md) | Components & request path |
|
| [Architecture](architecture.md) | Components & request path |
|
||||||
| [Security](security.md) | Lab threat model |
|
| [Security](security.md) | Lab threat model |
|
||||||
|
|||||||
@@ -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://openstack.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-openstack` (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:
|
||||||
|
openstack.devops.org.ru`).
|
||||||
|
|
||||||
|
### Images
|
||||||
|
|
||||||
|
| Registry | Repository |
|
||||||
|
|---|---|
|
||||||
|
| Harbor | `hub.antropoff.ru/devops-tools/openstack-api-simulator` |
|
||||||
|
| Docker Hub | `inecs/openstack-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@…:…/openstack_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
|
||||||
|
`openstack-api-simulator` (create the repository on first push or pre-create).
|
||||||
|
5. **Docker Hub** — `inecs/openstack-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-openstack`.
|
||||||
|
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://openstack.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/openstack-api-simulator
|
||||||
|
```
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
| [Seed-профили](seed-profiles.md) | `minimal` / `demo` |
|
| [Seed-профили](seed-profiles.md) | `minimal` / `demo` |
|
||||||
| [Клиенты](clients.md) | SDK / CLI |
|
| [Клиенты](clients.md) | SDK / CLI |
|
||||||
| [Web UI](web-ui.md) | Консоль и drawers |
|
| [Web UI](web-ui.md) | Консоль и drawers |
|
||||||
|
| [CI/CD (Jenkins)](ci-cd.md) | VERSION, push Harbor/Hub, деплой |
|
||||||
| [Эксплуатация](operations.md) | Day-2, релиз, reseed, **тесты** |
|
| [Эксплуатация](operations.md) | Day-2, релиз, reseed, **тесты** |
|
||||||
| [Архитектура](architecture.md) | Компоненты и путь запроса |
|
| [Архитектура](architecture.md) | Компоненты и путь запроса |
|
||||||
| [Безопасность](security.md) | Threat model лаборатории |
|
| [Безопасность](security.md) | Threat model лаборатории |
|
||||||
|
|||||||
@@ -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://openstack.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-openstack`
|
||||||
|
(контейнер `simulator` + initContainer `migrate`), затем rollout status
|
||||||
|
(`--timeout=1200s`).
|
||||||
|
|
||||||
|
Первый install в кластере (Ingress, Postgres, секреты) — **не** этот job, а
|
||||||
|
`make addon-simulators` в DevOpsTools/K3S (`simulators_proxmox_host:
|
||||||
|
openstack.devops.org.ru`).
|
||||||
|
|
||||||
|
### Образы
|
||||||
|
|
||||||
|
| Registry | Repository |
|
||||||
|
|---|---|
|
||||||
|
| Harbor | `hub.antropoff.ru/devops-tools/openstack-api-simulator` |
|
||||||
|
| Docker Hub | `inecs/openstack-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@…:…/openstack_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
|
||||||
|
`openstack-api-simulator`.
|
||||||
|
5. **Docker Hub** — репозиторий `inecs/openstack-api-simulator`, права у
|
||||||
|
`docker-hub`.
|
||||||
|
6. **Кластер** — релиз `simulators` в ns `simulators` уже установлен
|
||||||
|
(`make addon-simulators`). Deployment: `simulators-openstack`.
|
||||||
|
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://openstack.devops.org.ru/
|
||||||
|
|
||||||
|
## Локальный релиз без Jenkins
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make bump-patch # или bump-minor
|
||||||
|
make version-commit
|
||||||
|
git push origin HEAD
|
||||||
|
# или только Hub вручную:
|
||||||
|
make release
|
||||||
|
```
|
||||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
|||||||
name: openstack-api-simulator
|
name: openstack-api-simulator
|
||||||
description: Stateful OpenStack API simulator (PostgreSQL + multi-port nginx gateway) for labs and CI
|
description: Stateful OpenStack API simulator (PostgreSQL + multi-port nginx gateway) 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/inecs/openstack-api-simulator
|
home: https://github.com/inecs/openstack-api-simulator
|
||||||
keywords:
|
keywords:
|
||||||
- openstack
|
- openstack
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ image:
|
|||||||
repository: inecs/openstack-api-simulator
|
repository: inecs/openstack-api-simulator
|
||||||
pullPolicy: IfNotPresent
|
pullPolicy: IfNotPresent
|
||||||
# Overrides the image tag whose default is the chart appVersion.
|
# Overrides the image tag whose default is the chart appVersion.
|
||||||
tag: ""
|
tag: "0.1.1"
|
||||||
|
|
||||||
imagePullSecrets: []
|
imagePullSecrets: []
|
||||||
nameOverride: ""
|
nameOverride: ""
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "openstack-api-simulator"
|
name = "openstack-api-simulator"
|
||||||
version = "0.1.0"
|
version = "0.1.1"
|
||||||
description = "Stateful asynchronous OpenStack API laboratory simulator"
|
description = "Stateful asynchronous OpenStack API laboratory simulator"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13,<3.14"
|
requires-python = ">=3.13,<3.14"
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -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}"
|
||||||
Reference in New Issue
Block a user