From 639966fd027dc9782831b6819f962e89ad12204e Mon Sep 17 00:00:00 2001 From: Victor Bona Date: Mon, 27 Jul 2026 15:47:39 -0300 Subject: [PATCH 1/2] release: prepare v0.6.0 --- CHANGELOG.md | 5 ++++- README.md | 2 +- charts/devboxes/Chart.yaml | 14 +++++++------- cli/Cargo.lock | 2 +- cli/Cargo.toml | 2 +- controller/pyproject.toml | 2 +- controller/src/devboxes_controller/__init__.py | 2 +- .../templates/cli_authorize.html | 4 ++-- .../src/devboxes_controller/templates/login.html | 4 ++-- controller/tests/test_app.py | 2 +- controller/uv.lock | 2 +- docs/configuration.md | 4 ++-- docs/golden-path.md | 2 +- package-lock.json | 4 ++-- package.json | 2 +- scripts/install.sh | 2 +- 16 files changed, 29 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f64dad2..99618d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ All notable changes to Devboxes are documented here. The project follows [Keep a ## [Unreleased] +## [0.6.0] - 2026-07-27 + ### Added - Added opt-in namespace-per-tenant isolation across Helm, the controller API, CLI, @@ -167,7 +169,8 @@ All notable changes to Devboxes are documented here. The project follows [Keep a - Portable Helm chart with values schema, namespace-scoped RBAC, configurable storage, ingress, LoadBalancer or NodePort SSH, ServiceMonitor, and disruption budget. - macOS and Linux CLI releases, SHA-256 verification installer, GHCR images, OCI chart publishing, image provenance attestations, and clean Kind install CI. -[Unreleased]: https://github.com/vicotrbb/devboxes/compare/v0.5.1...HEAD +[Unreleased]: https://github.com/vicotrbb/devboxes/compare/v0.6.0...HEAD +[0.6.0]: https://github.com/vicotrbb/devboxes/compare/v0.5.1...v0.6.0 [0.5.1]: https://github.com/vicotrbb/devboxes/compare/v0.5.0...v0.5.1 [0.5.0]: https://github.com/vicotrbb/devboxes/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/vicotrbb/devboxes/compare/v0.3.0...v0.4.0 diff --git a/README.md b/README.md index 607af3e..0c20733 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ kubectl -n devboxes create secret generic devboxes-workspace \ --from-file=SSH_AUTHORIZED_KEYS="$HOME/.ssh/id_ed25519.pub" helm install devboxes oci://ghcr.io/vicotrbb/charts/devboxes \ - --version 0.5.1 \ + --version 0.6.0 \ --namespace devboxes ``` diff --git a/charts/devboxes/Chart.yaml b/charts/devboxes/Chart.yaml index 4645a0a..2a67a76 100644 --- a/charts/devboxes/Chart.yaml +++ b/charts/devboxes/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: devboxes description: Self-hosted, ephemeral development environments on Kubernetes type: application -version: 0.5.1 -appVersion: "0.5.1" +version: 0.6.0 +appVersion: "0.6.0" kubeVersion: ">=1.29.0-0" home: https://github.com/vicotrbb/devboxes icon: https://raw.githubusercontent.com/vicotrbb/devboxes/main/docs/assets/devboxes-mark.svg @@ -16,9 +16,9 @@ annotations: artifacthub.io/license: Apache-2.0 artifacthub.io/category: integration-delivery artifacthub.io/changes: | - - kind: changed - description: Update JavaScript, Rust, Python, container, and GitHub Actions dependencies - - kind: fixed - description: Adapt PKCE verifier generation to the Rand 0.10 API + - kind: added + description: Add opt-in namespace-per-tenant isolation across Helm, API, CLI, and dashboard + - kind: added + description: Add tenant-specific RBAC, quotas, network policies, presets, GPU profiles, and images - kind: security - description: Update vulnerable transitive JavaScript parsing and expansion dependencies + description: Add Secret-backed principals, role enforcement, and tenant-scoped Insights data diff --git a/cli/Cargo.lock b/cli/Cargo.lock index 513ddbe..4cb20b2 100644 --- a/cli/Cargo.lock +++ b/cli/Cargo.lock @@ -309,7 +309,7 @@ dependencies = [ [[package]] name = "devbox-cli" -version = "0.5.1" +version = "0.6.0" dependencies = [ "anyhow", "axum", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 5e1559f..0f4527e 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "devbox-cli" -version = "0.5.1" +version = "0.6.0" edition = "2024" rust-version = "1.96" description = "Terminal client for self-hosted Kubernetes development environments" diff --git a/controller/pyproject.toml b/controller/pyproject.toml index 343763d..a1aa619 100644 --- a/controller/pyproject.toml +++ b/controller/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "devboxes-controller" -version = "0.5.1" +version = "0.6.0" description = "Controller and dashboard for self-hosted Kubernetes development environments" readme = "README.md" requires-python = ">=3.12" diff --git a/controller/src/devboxes_controller/__init__.py b/controller/src/devboxes_controller/__init__.py index db57a49..00776b0 100644 --- a/controller/src/devboxes_controller/__init__.py +++ b/controller/src/devboxes_controller/__init__.py @@ -1,3 +1,3 @@ """Devboxes controller package.""" -__version__ = "0.5.1" +__version__ = "0.6.0" diff --git a/controller/src/devboxes_controller/templates/cli_authorize.html b/controller/src/devboxes_controller/templates/cli_authorize.html index 9380db9..cdcefd6 100644 --- a/controller/src/devboxes_controller/templates/cli_authorize.html +++ b/controller/src/devboxes_controller/templates/cli_authorize.html @@ -5,8 +5,8 @@ Authorize Devbox CLI · Devboxes - - + +
diff --git a/controller/src/devboxes_controller/templates/login.html b/controller/src/devboxes_controller/templates/login.html index 381179f..a2a6492 100644 --- a/controller/src/devboxes_controller/templates/login.html +++ b/controller/src/devboxes_controller/templates/login.html @@ -5,8 +5,8 @@ Sign in · Devboxes - - + +
diff --git a/controller/tests/test_app.py b/controller/tests/test_app.py index f78ee27..32c235a 100644 --- a/controller/tests/test_app.py +++ b/controller/tests/test_app.py @@ -80,7 +80,7 @@ def test_browser_login_and_dashboard_session() -> None: assert dashboard.headers["x-content-type-options"] == "nosniff" assert "Kubernetes connected" in dashboard.text assert "cluster default storage" in dashboard.text - styles = client.get("/static/styles.css?v=0.5.1") + styles = client.get("/static/styles.css?v=0.6.0") assert "[hidden]" in styles.text assert "display: none !important" in styles.text payload = client.get("/api/v1/devboxes").json() diff --git a/controller/uv.lock b/controller/uv.lock index aa531bc..47f4c9e 100644 --- a/controller/uv.lock +++ b/controller/uv.lock @@ -423,7 +423,7 @@ wheels = [ [[package]] name = "devboxes-controller" -version = "0.5.1" +version = "0.6.0" source = { editable = "." } dependencies = [ { name = "fastapi" }, diff --git a/docs/configuration.md b/docs/configuration.md index b1cd1bf..bbc95ae 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -3,9 +3,9 @@ Use a values file for durable installations: ```bash -helm show values oci://ghcr.io/vicotrbb/charts/devboxes --version 0.5.1 > values.yaml +helm show values oci://ghcr.io/vicotrbb/charts/devboxes --version 0.6.0 > values.yaml helm upgrade --install devboxes oci://ghcr.io/vicotrbb/charts/devboxes \ - --version 0.5.1 \ + --version 0.6.0 \ --namespace devboxes \ --create-namespace \ --values values.yaml diff --git a/docs/golden-path.md b/docs/golden-path.md index 83841ad..f6a752f 100644 --- a/docs/golden-path.md +++ b/docs/golden-path.md @@ -47,7 +47,7 @@ kubectl create namespace devboxes # Create devboxes-auth and devboxes-workspace here, as described below. helm upgrade --install devboxes oci://ghcr.io/vicotrbb/charts/devboxes \ - --version 0.5.1 \ + --version 0.6.0 \ --namespace devboxes \ --values values.yaml ``` diff --git a/package-lock.json b/package-lock.json index 219e95a..849b815 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "devboxes-repository-tooling", - "version": "0.5.1", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devboxes-repository-tooling", - "version": "0.5.1", + "version": "0.6.0", "devDependencies": { "@eslint/js": "10.0.1", "eslint": "10.8.0", diff --git a/package.json b/package.json index 6ec2569..396df56 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devboxes-repository-tooling", - "version": "0.5.1", + "version": "0.6.0", "private": true, "type": "module", "description": "JavaScript and documentation quality gates for Devboxes", diff --git a/scripts/install.sh b/scripts/install.sh index b6e5bd5..35fe493 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3,7 +3,7 @@ set -eu release="${DEVBOXES_RELEASE:-devboxes}" namespace="${DEVBOXES_NAMESPACE:-devboxes}" -version="${DEVBOXES_VERSION:-0.5.1}" +version="${DEVBOXES_VERSION:-0.6.0}" repository="${DEVBOXES_CHART_REPOSITORY:-oci://ghcr.io/vicotrbb/charts/devboxes}" chart_source="${DEVBOXES_CHART_SOURCE:-auto}" controller_secret="${DEVBOXES_CONTROLLER_SECRET:-devboxes-auth}" From e2d887adb65429b29df85e7128a0fa81e28dd626 Mon Sep 17 00:00:00 2001 From: Victor Bona Date: Mon, 27 Jul 2026 16:03:30 -0300 Subject: [PATCH 2/2] fix: retry Kubernetes scale conflicts --- CHANGELOG.md | 6 + controller/src/devboxes_controller/app.py | 7 +- controller/src/devboxes_controller/manager.py | 112 ++++++++++-------- controller/tests/test_app.py | 23 ++++ controller/tests/test_manager.py | 104 ++++++++++++++++ 5 files changed, 200 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99618d7..02a3c10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,12 @@ All notable changes to Devboxes are documented here. The project follows [Keep a and CLI coverage for isolation, role enforcement, quota failures, migration, and configuration precedence. +### Fixed + +- Retry Kubernetes resourceVersion conflicts during workspace start and stop with bounded + backoff and fresh tenant ownership validation, while returning a stable conflict response + if concurrent updates persist. + ### Security - Keep principal token values out of Helm values by loading unique strong credentials from a diff --git a/controller/src/devboxes_controller/app.py b/controller/src/devboxes_controller/app.py index fe09146..6e9cdf3 100644 --- a/controller/src/devboxes_controller/app.py +++ b/controller/src/devboxes_controller/app.py @@ -597,13 +597,16 @@ async def start_devbox(name: DevboxName, auth: Auth) -> Devbox: _require_manage(auth) try: return await _not_found(manager.scale(name, 1, auth.tenant), name) - except TenantQuotaExceededError as error: + except (DevboxConflictError, TenantQuotaExceededError) as error: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error @app.post("/api/v1/devboxes/{name}/stop", tags=["devboxes"]) async def stop_devbox(name: DevboxName, auth: Auth) -> Devbox: _require_manage(auth) - return await _not_found(manager.scale(name, 0, auth.tenant), name) + try: + return await _not_found(manager.scale(name, 0, auth.tenant), name) + except DevboxConflictError as error: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(error)) from error @app.delete("/api/v1/devboxes/{name}", tags=["devboxes"]) async def delete_devbox( diff --git a/controller/src/devboxes_controller/manager.py b/controller/src/devboxes_controller/manager.py index daca5cb..44427b4 100644 --- a/controller/src/devboxes_controller/manager.py +++ b/controller/src/devboxes_controller/manager.py @@ -60,13 +60,16 @@ logger = logging.getLogger(__name__) +_SCALE_CONFLICT_ATTEMPTS = 5 +_SCALE_CONFLICT_BACKOFF_SECONDS = 0.05 + class DevboxNotFoundError(Exception): """Signal that a requested managed devbox does not exist.""" class DevboxConflictError(Exception): - """Signal that a requested devbox name is already active.""" + """Signal that a devbox operation conflicts with managed resource state.""" class TenantQuotaExceededError(Exception): @@ -440,58 +443,67 @@ async def _scale_locked( tenant: TenantConfig, ) -> Devbox: """Scale a devbox while holding its lifecycle lock.""" - try: - deployment = await asyncio.to_thread( - self.apps.read_namespaced_deployment, - resource, - tenant.namespace, - ) - if not self._belongs_to_tenant(deployment, tenant): - raise DevboxNotFoundError(name) - if replicas == 1: - deployment = await self._prepare_insights_template(deployment, tenant) - annotations = deployment.metadata.annotations or {} - ttl_hours = _ttl_hours( - annotations.get(ANNOTATION_TTL_HOURS), - self.settings.default_ttl_hours, - self.settings.tenant_max_ttl_hours(tenant), - ) - metadata_patch: dict[str, Any] = { - "annotations": { - ANNOTATION_AUTO_STOPPED_AT: None, - ANNOTATION_EXPIRES_AT: ( - datetime.now(UTC) + timedelta(hours=ttl_hours) - ).isoformat(), - } - } - resource_version = getattr(deployment.metadata, "resource_version", None) - if resource_version: - metadata_patch["resourceVersion"] = resource_version - await asyncio.to_thread( - self.apps.patch_namespaced_deployment, - resource, - tenant.namespace, - { - "metadata": metadata_patch, - "spec": {"replicas": 1}, - }, - ) - else: - scale_patch: dict[str, Any] = {"spec": {"replicas": replicas}} - resource_version = getattr(deployment.metadata, "resource_version", None) - if resource_version: - scale_patch["metadata"] = {"resourceVersion": resource_version} - await asyncio.to_thread( - self.apps.patch_namespaced_deployment_scale, + for attempt in range(_SCALE_CONFLICT_ATTEMPTS): + try: + deployment = await asyncio.to_thread( + self.apps.read_namespaced_deployment, resource, tenant.namespace, - scale_patch, ) - except ApiException as error: - if error.status == 404: - raise DevboxNotFoundError(name) from error - self._raise_quota(error, tenant) - raise + if not self._belongs_to_tenant(deployment, tenant): + raise DevboxNotFoundError(name) + if replicas == 1: + deployment = await self._prepare_insights_template(deployment, tenant) + annotations = deployment.metadata.annotations or {} + ttl_hours = _ttl_hours( + annotations.get(ANNOTATION_TTL_HOURS), + self.settings.default_ttl_hours, + self.settings.tenant_max_ttl_hours(tenant), + ) + metadata_patch: dict[str, Any] = { + "annotations": { + ANNOTATION_AUTO_STOPPED_AT: None, + ANNOTATION_EXPIRES_AT: ( + datetime.now(UTC) + timedelta(hours=ttl_hours) + ).isoformat(), + } + } + resource_version = getattr(deployment.metadata, "resource_version", None) + if resource_version: + metadata_patch["resourceVersion"] = resource_version + await asyncio.to_thread( + self.apps.patch_namespaced_deployment, + resource, + tenant.namespace, + { + "metadata": metadata_patch, + "spec": {"replicas": 1}, + }, + ) + else: + scale_patch: dict[str, Any] = {"spec": {"replicas": replicas}} + resource_version = getattr(deployment.metadata, "resource_version", None) + if resource_version: + scale_patch["metadata"] = {"resourceVersion": resource_version} + await asyncio.to_thread( + self.apps.patch_namespaced_deployment_scale, + resource, + tenant.namespace, + scale_patch, + ) + break + except ApiException as error: + if error.status == 404: + raise DevboxNotFoundError(name) from error + if error.status == 409: + if attempt + 1 == _SCALE_CONFLICT_ATTEMPTS: + raise DevboxConflictError( + f"devbox {name!r} changed concurrently; retry the request" + ) from error + await asyncio.sleep(_SCALE_CONFLICT_BACKOFF_SECONDS * (2**attempt)) + continue + self._raise_quota(error, tenant) + raise return await self.get(name, tenant) async def reconcile_insights(self) -> builtins.list[str]: diff --git a/controller/tests/test_app.py b/controller/tests/test_app.py index 32c235a..862f521 100644 --- a/controller/tests/test_app.py +++ b/controller/tests/test_app.py @@ -1,4 +1,5 @@ from pathlib import Path +from unittest.mock import AsyncMock from urllib.parse import parse_qs, urlencode, urlsplit from fastapi.testclient import TestClient @@ -6,6 +7,7 @@ from devboxes_controller.app import create_app from devboxes_controller.auth import pkce_s256 from devboxes_controller.config import CustomImagePort, CustomImageProfile, GpuProfile, Settings +from devboxes_controller.manager import DevboxConflictError from .fakes import FakeManager @@ -200,6 +202,27 @@ def test_api_maps_conflicts_and_missing_names() -> None: assert "was not found" in missing.json()["detail"] +def test_api_maps_exhausted_lifecycle_retries_to_conflicts() -> None: + settings = Settings( + access_token="test-access-token-at-least-32-characters", + cookie_secure=False, + cleanup_interval_seconds=3600, + ) + manager = FakeManager(settings) + manager.scale = AsyncMock( # type: ignore[method-assign] + side_effect=DevboxConflictError("devbox 'atlas' changed concurrently; retry the request") + ) + headers = {"Authorization": "Bearer test-access-token-at-least-32-characters"} + + with TestClient(create_app(settings, manager)) as client: # type: ignore[arg-type] + for action in ("start", "stop"): + response = client.post(f"/api/v1/devboxes/atlas/{action}", headers=headers) + assert response.status_code == 409 + assert response.json() == { + "detail": "devbox 'atlas' changed concurrently; retry the request" + } + + def test_api_requires_authentication() -> None: with app_client() as client: assert client.get("/api/v1/devboxes").status_code == 401 diff --git a/controller/tests/test_manager.py b/controller/tests/test_manager.py index 8a698f0..5a176d1 100644 --- a/controller/tests/test_manager.py +++ b/controller/tests/test_manager.py @@ -297,6 +297,110 @@ def test_start_renews_the_original_ttl_atomically() -> None: assert result is expected +def test_start_retries_a_resource_version_conflict_with_fresh_ownership( + monkeypatch: pytest.MonkeyPatch, +) -> None: + apps = Mock() + first = SimpleNamespace( + metadata=SimpleNamespace( + annotations={ANNOTATION_TTL_HOURS: "24"}, + labels=_managed_labels(), + resource_version="101", + ) + ) + second = SimpleNamespace( + metadata=SimpleNamespace( + annotations={ANNOTATION_TTL_HOURS: "24"}, + labels=_managed_labels(), + resource_version="102", + ) + ) + apps.read_namespaced_deployment.side_effect = [first, second] + apps.patch_namespaced_deployment.side_effect = [ApiException(status=409), None] + manager = DevboxManager( + Settings(access_token="test-access-token-at-least-32-characters"), + apps_api=apps, + core_api=Mock(), + ) + expected = Mock() + manager.get = AsyncMock(return_value=expected) # type: ignore[method-assign] + sleep = AsyncMock() + monkeypatch.setattr("devboxes_controller.manager.asyncio.sleep", sleep) + + assert asyncio.run(manager.scale("atlas", 1)) is expected + + assert apps.read_namespaced_deployment.call_count == 2 + assert apps.patch_namespaced_deployment.call_count == 2 + patches = [call.args[2] for call in apps.patch_namespaced_deployment.call_args_list] + assert [patch["metadata"]["resourceVersion"] for patch in patches] == ["101", "102"] + sleep.assert_awaited_once_with(0.05) + + +def test_start_revalidates_ownership_after_a_resource_version_conflict( + monkeypatch: pytest.MonkeyPatch, +) -> None: + apps = Mock() + apps.read_namespaced_deployment.side_effect = [ + SimpleNamespace( + metadata=SimpleNamespace( + annotations={}, + labels=_managed_labels(), + resource_version="101", + ) + ), + SimpleNamespace( + metadata=SimpleNamespace( + annotations={}, + labels={"app": "replacement"}, + resource_version="102", + ) + ), + ] + apps.patch_namespaced_deployment.side_effect = ApiException(status=409) + manager = DevboxManager( + Settings(access_token="test-access-token-at-least-32-characters"), + apps_api=apps, + core_api=Mock(), + ) + sleep = AsyncMock() + monkeypatch.setattr("devboxes_controller.manager.asyncio.sleep", sleep) + + with pytest.raises(DevboxNotFoundError): + asyncio.run(manager.scale("atlas", 1)) + + assert apps.read_namespaced_deployment.call_count == 2 + apps.patch_namespaced_deployment.assert_called_once() + sleep.assert_awaited_once_with(0.05) + + +def test_start_returns_a_stable_conflict_after_bounded_retry_exhaustion( + monkeypatch: pytest.MonkeyPatch, +) -> None: + apps = Mock() + apps.read_namespaced_deployment.return_value = SimpleNamespace( + metadata=SimpleNamespace( + annotations={}, + labels=_managed_labels(), + resource_version="101", + ) + ) + apps.patch_namespaced_deployment.side_effect = ApiException(status=409) + manager = DevboxManager( + Settings(access_token="test-access-token-at-least-32-characters"), + apps_api=apps, + core_api=Mock(), + ) + sleep = AsyncMock() + monkeypatch.setattr("devboxes_controller.manager.asyncio.sleep", sleep) + + with pytest.raises(DevboxConflictError, match="changed concurrently"): + asyncio.run(manager.scale("atlas", 1)) + + assert apps.read_namespaced_deployment.call_count == 5 + assert apps.patch_namespaced_deployment.call_count == 5 + assert sleep.await_count == 4 + + def test_stop_uses_the_scale_subresource_without_renewing_ttl() -> None: apps = Mock() apps.read_namespaced_deployment.return_value = SimpleNamespace(