From e52245fba5a7b22bc72d4f6401c34dd332616805 Mon Sep 17 00:00:00 2001 From: gitea-actions-bot Date: Mon, 28 Sep 2026 17:44:20 +0000 Subject: [PATCH 1/2] chore: update badge URLs to commit 5574c266 [skip ci] --- docs/specs/DEVX-181.md | 48 ++++++++++++ src/devx/molecule/lease.py | 88 ++++++++++++++++++++++ src/devx/molecule/start_docker.py | 6 ++ tests/unit/test_molecule_lease.py | 121 ++++++++++++++++++++++++++++++ 4 files changed, 263 insertions(+) create mode 100644 docs/specs/DEVX-181.md create mode 100644 src/devx/molecule/lease.py create mode 100644 tests/unit/test_molecule_lease.py diff --git a/docs/specs/DEVX-181.md b/docs/specs/DEVX-181.md new file mode 100644 index 0000000..51a4e81 --- /dev/null +++ b/docs/specs/DEVX-181.md @@ -0,0 +1,48 @@ +# DEVX-181: Emit runner resource leases for CI molecule jobs (S08 producer side) + +## Problem +GRM-173 (S08) shipped consumer-side runner leases: `runner-cleanup.sh` +honors `org.oblachno.lease-until` / `org.oblachno.owner` labels on +containers, volumes, and networks. But no CI producer emits them: +molecule containers/networks created on the runner's host rootless Docker +socket carry only `owner=molecule`, so a pressure/critical cleanup can +still wipe a molecule object that is stopped for a moment mid-run. +Docker labels cannot be added post-creation — producers must emit them +at create time. + +## Approach +REQ-1: New `devx.molecule.lease` module computing `MOLECULE_LEASE_UNTIL` +(epoch = now + TTL, default 4h) and `MOLECULE_OWNER` (`run--` +from GITEA_*/GITHUB_* env, else `local-`), with +`emit_github_env()` appending them to `$GITHUB_ENV` and a Click CLI +printing `KEY=value` lines. +REQ-2: `start_docker.main()` emits the lease env after a successful +Docker start — every molecule CI job already calls it, so no workflow +edits are needed. +REQ-3: Consumers (infra/grm `molecule.yml`) add platform labels +`org.oblachno.lease-until: "${MOLECULE_LEASE_UNTIL:-0}"` and +`org.oblachno.owner: "${MOLECULE_OWNER:-molecule-local}"` — `0` is +already expired, so local/unset runs keep today's unleased behavior. +Downstream changes land in the infra and grm repos. + +## Test Plan +- `tests/unit/test_molecule_lease.py` covers `lease_owner` env precedence + and fallbacks, `lease_env` arithmetic, `emit_github_env` write/no-op, + and the CLI — 100% coverage enforced by `make pytest-cov`. +- Existing `test_start_docker.py` still green (emission is additive). + +## Deploy Plan +- Merge via auto-merge → post-merge releases and publishes devx; infra + and grm pin bumps consume it via their molecule.yml label additions. + +## Rollback Plan +- Revert the merge; `start_docker` stops exporting the vars and molecule + interpolation falls back to expired leases (`:-0`). + +## Acceptance Criteria +- [x] REQ-1: `devx.molecule.lease` computes both env vars and writes them + to `$GITHUB_ENV` when set. +- [x] REQ-2: `start_docker` exports lease env on success without changing + its existing behavior contract. +- [x] REQ-3: the label contract (`org.oblachno.lease-until`, + `org.oblachno.owner`) is exposed as module constants for consumers. diff --git a/src/devx/molecule/lease.py b/src/devx/molecule/lease.py new file mode 100644 index 0000000..c890cf7 --- /dev/null +++ b/src/devx/molecule/lease.py @@ -0,0 +1,88 @@ +"""Emit bounded runner-resource lease env for CI molecule jobs. + +Producer side of the S08 lease contract (consumer: GRM-173 +``runner-cleanup.sh``). The runner's scoped cleanup honors +``org.oblachno.lease-until`` (epoch seconds) and ``org.oblachno.owner`` +labels on containers, volumes, and networks. Molecule runs on the runner +host's rootless Docker socket, so objects it creates must carry the lease +labels at creation time — Docker labels cannot be added post-hoc. + +This module computes ``MOLECULE_LEASE_UNTIL`` and ``MOLECULE_OWNER``, +which ``molecule.yml`` platform ``labels:`` entries consume via +``${MOLECULE_LEASE_UNTIL:-0}`` / ``${MOLECULE_OWNER:-molecule-local}`` +interpolation, and appends them to ``$GITHUB_ENV`` so every later CI step +inherits them. A lease-until of ``0`` (the interpolation default for +local runs) is already expired, keeping today's unleased behavior. +""" + +from __future__ import annotations + +import os +import socket +import time + +import click + +#: Docker label contract consumed by runner-cleanup.sh (GRM-173). +LEASE_UNTIL_LABEL = "org.oblachno.lease-until" +OWNER_LABEL = "org.oblachno.owner" + +#: Env vars consumed by molecule.yml platform ``labels:`` entries. +LEASE_UNTIL_ENV = "MOLECULE_LEASE_UNTIL" +OWNER_ENV = "MOLECULE_OWNER" + +#: Default lease duration — covers the longest molecule job window. +DEFAULT_TTL_SECONDS = 4 * 3600 + + +def lease_owner() -> str: + """Identify the owning CI run for the ``org.oblachno.owner`` label. + + act_runner exposes GitHub-compatible ``GITHUB_*`` env vars; + ``GITEA_*`` names are checked first for forward compatibility. + """ + run_id = os.environ.get("GITEA_RUN_ID") or os.environ.get("GITHUB_RUN_ID") + job = os.environ.get("GITEA_JOB") or os.environ.get("GITHUB_JOB") + if run_id: + return f"run-{run_id}-{job or 'job'}" + return f"local-{socket.gethostname()}" + + +def lease_env(ttl: int = DEFAULT_TTL_SECONDS, now: int | None = None) -> dict[str, str]: + """Return the lease env dict for the current run.""" + base = int(time.time()) if now is None else now + return { + LEASE_UNTIL_ENV: str(base + ttl), + OWNER_ENV: lease_owner(), + } + + +def emit_github_env(env: dict[str, str]) -> int: + """Append lease env to ``$GITHUB_ENV``; return lines written (0 if unset).""" + github_env = os.environ.get("GITHUB_ENV") + if not github_env: + return 0 + with open(github_env, "a", encoding="utf-8") as f: + for key, value in env.items(): + f.write(f"{key}={value}\n") + return len(env) + + +@click.command() +@click.option( + "--ttl", + default=DEFAULT_TTL_SECONDS, + type=int, + help=f"Lease duration in seconds (default: {DEFAULT_TTL_SECONDS}).", +) +def main(ttl: int) -> None: + """Print lease env vars and export them to $GITHUB_ENV when set.""" + env = lease_env(ttl) + for key, value in env.items(): + click.echo(f"{key}={value}") + if emit_github_env(env): + click.echo(f"Exported {len(env)} lease vars to GITHUB_ENV") + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/src/devx/molecule/start_docker.py b/src/devx/molecule/start_docker.py index 289d4c1..298d94c 100644 --- a/src/devx/molecule/start_docker.py +++ b/src/devx/molecule/start_docker.py @@ -35,6 +35,7 @@ import time import click from devx.i18n import _ +from devx.molecule.lease import emit_github_env, lease_env DEFAULT_TIMEOUT = 30 DOCKER_SOCK = "/var/run/docker.sock" @@ -384,6 +385,11 @@ def main(timeout: int) -> None: with open(github_env, "a", encoding="utf-8") as f: f.write(f"DOCKER_HOST={os.environ['DOCKER_HOST']}\n") click.echo(f"Exported DOCKER_HOST={os.environ['DOCKER_HOST']} to GITHUB_ENV") + # Implements: REQ-2 (DEVX-181) — emit the S08 producer lease env so + # molecule-created objects carry org.oblachno.lease-until/owner + # labels that runner-cleanup.sh honors. + if emit_github_env(lease_env()): + click.echo(f"Exported {len(lease_env())} lease vars to GITHUB_ENV") sys.exit(0) sys.exit(1) diff --git a/tests/unit/test_molecule_lease.py b/tests/unit/test_molecule_lease.py new file mode 100644 index 0000000..1b75840 --- /dev/null +++ b/tests/unit/test_molecule_lease.py @@ -0,0 +1,121 @@ +"""Unit tests for devx.molecule.lease.""" + +import os +from unittest.mock import MagicMock, mock_open, patch + +from click.testing import CliRunner + +from devx.molecule.lease import ( + DEFAULT_TTL_SECONDS, + LEASE_UNTIL_ENV, + OWNER_ENV, + emit_github_env, + lease_env, + lease_owner, + main, +) + + +class TestLeaseOwner: + def test_gitea_env_preferred(self) -> None: + with patch.dict( + os.environ, + {"GITEA_RUN_ID": "123", "GITEA_JOB": "tests", "GITHUB_RUN_ID": "999"}, + ): + assert lease_owner() == "run-123-tests" + + def test_github_env_fallback(self) -> None: + env = {"GITHUB_RUN_ID": "777", "GITHUB_JOB": "molecule"} + with patch.dict(os.environ, env, clear=True): + assert lease_owner() == "run-777-molecule" + + def test_job_defaults_when_missing(self) -> None: + with patch.dict(os.environ, {"GITHUB_RUN_ID": "5"}, clear=True): + assert lease_owner() == "run-5-job" + + def test_local_fallback(self) -> None: + with ( + patch.dict(os.environ, {}, clear=True), + patch("devx.molecule.lease.socket.gethostname", return_value="devbox"), + ): + assert lease_owner() == "local-devbox" + + +class TestLeaseEnv: + def test_arithmetic(self) -> None: + env = lease_env(ttl=3600, now=1000) + assert env[LEASE_UNTIL_ENV] == str(1000 + 3600) + assert OWNER_ENV in env + + def test_default_now_uses_time(self) -> None: + with patch("devx.molecule.lease.time.time", return_value=2000.9): + env = lease_env(ttl=60) + assert env[LEASE_UNTIL_ENV] == str(2000 + 60) + + def test_default_ttl(self) -> None: + env = lease_env(now=0) + assert env[LEASE_UNTIL_ENV] == str(DEFAULT_TTL_SECONDS) + + +class TestEmitGithubEnv: + def test_writes_lines(self) -> None: + m = mock_open() + with ( + patch.dict(os.environ, {"GITHUB_ENV": "/tmp/env"}, clear=False), + patch("builtins.open", m), + ): + written = emit_github_env({"A": "1", "B": "2"}) + assert written == 2 + m.assert_called_once_with("/tmp/env", "a", encoding="utf-8") + m().write.assert_any_call("A=1\n") + m().write.assert_any_call("B=2\n") + + def test_noop_without_env(self) -> None: + with patch.dict(os.environ, {}, clear=True): + assert emit_github_env({"A": "1"}) == 0 + + +class TestCli: + def test_prints_and_exports(self) -> None: + runner = CliRunner() + m = mock_open() + with ( + patch.dict(os.environ, {"GITHUB_ENV": "/tmp/env", "GITHUB_RUN_ID": "42"}, clear=True), + patch("builtins.open", m), + patch("devx.molecule.lease.time.time", return_value=100.0), + ): + result = runner.invoke(main, ["--ttl", "600"]) + assert result.exit_code == 0 + assert f"{LEASE_UNTIL_ENV}=700" in result.output + assert f"{OWNER_ENV}=run-42-job" in result.output + assert "Exported 2 lease vars" in result.output + + def test_prints_without_github_env(self) -> None: + runner = CliRunner() + with ( + patch.dict(os.environ, {"GITHUB_RUN_ID": "9", "GITHUB_JOB": "tests"}, clear=True), + patch("devx.molecule.lease.time.time", return_value=50.0), + ): + result = runner.invoke(main) + assert result.exit_code == 0 + assert f"{LEASE_UNTIL_ENV}={50 + DEFAULT_TTL_SECONDS}" in result.output + assert f"{OWNER_ENV}=run-9-tests" in result.output + assert "GITHUB_ENV" not in result.output + + +class TestStartDockerIntegration: + """start_docker.main() must emit lease env on success (REQ-2).""" + + @patch("devx.molecule.start_docker.start_docker_daemon", return_value=True) + def test_emits_lease_on_success(self, _mock: MagicMock) -> None: + from devx.molecule.start_docker import main as start_docker_main + + runner = CliRunner() + m = mock_open() + with ( + patch.dict(os.environ, {"GITHUB_ENV": "/tmp/env", "GITHUB_RUN_ID": "7"}, clear=True), + patch("builtins.open", m), + ): + result = runner.invoke(start_docker_main, ["--timeout", "1"]) + assert result.exit_code == 0 + assert "Exported 2 lease vars" in result.output -- 2.54.0 From 932e9eea5ca7888be41b59dc5021d13e6f52a8e7 Mon Sep 17 00:00:00 2001 From: Emil Simeonov Date: Wed, 30 Sep 2026 17:56:22 +0200 Subject: [PATCH 2/2] ci: retrigger validation -- 2.54.0