Compare commits

..
6 Commits
Author SHA1 Message Date
gitea-actions-bot 0c26a288c9 chore: update badge URLs to commit d547d1fa [skip ci] 2026-09-30 16:20:23 +00:00
devx-ci-bot cc90aa0328 release: v0.56.0 [skip ci] 2026-09-30 16:18:59 +00:00
kireto e47778473c DEVX-182: feat(molecule): log lease owner and UTC expiry on emission
Post-merge / detect-and-configure (push) Successful in 1m58s
Post-merge / release-and-maintain (push) Successful in 2m45s
2026-09-30 16:15:40 +00:00
gitea-actions-bot 02a977c53f chore: update badge URLs to commit 93ce4062 [skip ci] 2026-09-30 16:00:32 +00:00
kireto cd5931a344 DEVX-181: ci: retrigger validation
Post-merge / detect-and-configure (push) Successful in 13s
Post-merge / release-and-maintain (push) Successful in 59s
2026-09-30 15:59:16 +00:00
gitea-actions-bot affd4d12ec chore: update badge URLs to commit 5574c266 [skip ci] 2026-09-28 17:44:20 +00:00
10 changed files with 345 additions and 20 deletions
+6
View File
@@ -2,6 +2,12 @@
All notable changes to this project will be documented in this file.
## [0.56.0] - 2026-09-30
### Features
- *(molecule)* Log lease owner and UTC expiry on emission
## [0.55.5] - 2026-09-28
### Bug Fixes
+9 -9
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/32e239ca6b5c1d1a1918c31ae536917b9914f847/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/32e239ca6b5c1d1a1918c31ae536917b9914f847/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/32e239ca6b5c1d1a1918c31ae536917b9914f847/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/32e239ca6b5c1d1a1918c31ae536917b9914f847/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/32e239ca6b5c1d1a1918c31ae536917b9914f847/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/32e239ca6b5c1d1a1918c31ae536917b9914f847/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d547d1fa4d0c2508a5ce794ba7747f6935ffca65/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d547d1fa4d0c2508a5ce794ba7747f6935ffca65/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d547d1fa4d0c2508a5ce794ba7747f6935ffca65/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d547d1fa4d0c2508a5ce794ba7747f6935ffca65/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d547d1fa4d0c2508a5ce794ba7747f6935ffca65/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d547d1fa4d0c2508a5ce794ba7747f6935ffca65/python.svg)](https://www.python.org/downloads/)
## Why devx?
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
```toml
[project]
dependencies = [
"devx>=0.55.5",
"devx>=0.56.0",
]
[tool.pip]
@@ -101,8 +101,8 @@ pip install -e .
```
> **Note:** If your project requires a specific devx version, pin it in
> `dependencies` (for example, `"devx==0.55.5"`) or use a version constraint
> (for example, `"devx>=0.55.5,<0.56"`).
> `dependencies` (for example, `"devx==0.56.0"`) or use a version constraint
> (for example, `"devx>=0.56.0,<0.57"`).
### Optional extras
+8 -8
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/32e239ca6b5c1d1a1918c31ae536917b9914f847/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/32e239ca6b5c1d1a1918c31ae536917b9914f847/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/32e239ca6b5c1d1a1918c31ae536917b9914f847/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/32e239ca6b5c1d1a1918c31ae536917b9914f847/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/32e239ca6b5c1d1a1918c31ae536917b9914f847/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/32e239ca6b5c1d1a1918c31ae536917b9914f847/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d547d1fa4d0c2508a5ce794ba7747f6935ffca65/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d547d1fa4d0c2508a5ce794ba7747f6935ffca65/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d547d1fa4d0c2508a5ce794ba7747f6935ffca65/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d547d1fa4d0c2508a5ce794ba7747f6935ffca65/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d547d1fa4d0c2508a5ce794ba7747f6935ffca65/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d547d1fa4d0c2508a5ce794ba7747f6935ffca65/python.svg)](https://www.python.org/downloads/)
## Overview
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml
[project]
dependencies = [
"devx>=0.55.5",
"devx>=0.56.0",
]
[tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
```
Pin a specific version if needed: `"devx==0.55.5"` or `"devx>=0.55.5,<0.56"`.
Pin a specific version if needed: `"devx==0.56.0"` or `"devx>=0.56.0,<0.57"`.
### Optional extras
+48
View File
@@ -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-<id>-<job>`
from GITEA_*/GITHUB_* env, else `local-<hostname>`), 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.
+49
View File
@@ -0,0 +1,49 @@
# DEVX-182: feat(molecule): release lease emission (ships DEVX-181)
- Task: DEVX-182
- Status: draft
## Problem
DEVX-181 landed on master but was never released: the squash subject
came out `DEVX-181: ci: retrigger validation` because the branch's last
commit was an empty `ci:` retrigger. git-cliff found no bump-worthy
commit, so `v0.55.5` is still latest and the S08 lease producer is
unshipped. A `feat:` merge is needed to cut the release — and while
shipping, the emission log line is upgraded from a bare count to the
actual lease owner and UTC expiry, which is what an operator needs when
debugging a cleanup race.
## Approach
REQ-1: `start_docker.main()` computes `lease_env()` once and logs
`owner=` + ISO-8601 `until=` alongside the export confirmation. The
single call also removes a latent second-boundary inconsistency where
`emit_github_env` and the count message each re-derived `now`.
REQ-2: Merge squash commit carries `feat(molecule):` so git-cliff cuts
the next minor release containing DEVX-181's emitter and this change.
## Test Plan
- Update `test_emits_lease_on_success` + `test_prints_and_exports` for
the new log format; keep 100 % coverage.
- `make pre-push` — full devx suite green.
- Post-merge: verify a `v*` tag > v0.55.5 exists and
`pip index versions devx` (Gitea PyPI) lists it.
## Deploy Plan
- Auto-merge; post-merge release job tags + publishes.
- Infra (OBL-INFRA-658) and GRM (GRM-174) consume the new pin.
## Rollback Plan
- Revert merge; the unreleased module remains dormant regardless.
## Acceptance Criteria
- [x] REQ-1: emission log includes owner and ISO-8601 expiry; lease env
computed once — covered by updated unit tests.
- [x] REQ-2: merge commit uses `feat(molecule):` so the release job
cuts a version bump.
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml
[project]
dependencies = [
"devx>=0.55.5",
"devx>=0.56.0",
]
[project.optional-dependencies]
dev = [
"devx>=0.55.5",
"devx>=0.56.0",
]
```
+1 -1
View File
@@ -6,4 +6,4 @@ create_dependency_pr, auto_merge, release, publish), developer tooling
molecule testing helpers for Ansible projects.
"""
__version__ = "0.55.5"
__version__ = "0.56.0"
+88
View File
@@ -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()
+11
View File
@@ -31,10 +31,12 @@ import subprocess # nosec B404
import sys
import tempfile
import time
from datetime import UTC, datetime
import click
from devx.i18n import _
from devx.molecule.lease import LEASE_UNTIL_ENV, OWNER_ENV, emit_github_env, lease_env
DEFAULT_TIMEOUT = 30
DOCKER_SOCK = "/var/run/docker.sock"
@@ -384,6 +386,15 @@ 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.
# Implements: REQ-1 (DEVX-182) — one lease_env() call; log the
# owner and UTC expiry an operator needs for cleanup debugging.
env = lease_env()
if emit_github_env(env):
until = datetime.fromtimestamp(int(env[LEASE_UNTIL_ENV]), tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
click.echo(f"Exported lease vars to GITHUB_ENV (owner={env[OWNER_ENV]}, until={until})")
sys.exit(0)
sys.exit(1)
+123
View File
@@ -0,0 +1,123 @@
"""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 lease vars to GITHUB_ENV" in result.output
assert "owner=run-7-job" in result.output
assert "until=" in result.output