Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c53d9e2f5d | ||
|
|
694daac64c | ||
|
|
0c26a288c9 | ||
|
|
cc90aa0328 | ||
|
|
e47778473c | ||
|
|
02a977c53f | ||
|
|
cd5931a344 | ||
|
|
affd4d12ec |
@@ -33,7 +33,7 @@ jobs:
|
||||
build-and-push:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||
image: git.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||
credentials:
|
||||
username: ${{ vars.CI_GITEA_USERNAME }}
|
||||
password: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
@@ -68,7 +68,7 @@ jobs:
|
||||
[ -z "$_TOKEN" ] && _TOKEN="$DEVELOPER_GITEA_API_TOKEN"
|
||||
[ -z "$_TOKEN" ] && _TOKEN="$CI_GITEA_TOKEN"
|
||||
if [ -z "$_TOKEN" ]; then echo "Gitea API token not set — skipping Docker login"; exit 1; fi
|
||||
echo "$_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin
|
||||
echo "$_TOKEN" | docker login git.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin
|
||||
- name: Build and push tier images
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
@@ -88,21 +88,21 @@ jobs:
|
||||
--dockerfile docker/ci-base/Dockerfile \
|
||||
--name oblachno-oss/runner-images/ci-base \
|
||||
--tag latest \
|
||||
--registry git.oblachno.oblachno.fyi \
|
||||
--registry git.oblachno.fyi \
|
||||
--push --pull
|
||||
# Build ci-quality (FROM ci-base-latest)
|
||||
python3 -m devx.tools.build_image \
|
||||
--dockerfile docker/ci-quality/Dockerfile \
|
||||
--name oblachno-oss/runner-images/ci-quality \
|
||||
--tag latest \
|
||||
--registry git.oblachno.oblachno.fyi \
|
||||
--registry git.oblachno.fyi \
|
||||
--push
|
||||
# Build ci-full (FROM ci-quality-latest)
|
||||
python3 -m devx.tools.build_image \
|
||||
--dockerfile docker/ci-full/Dockerfile \
|
||||
--name oblachno-oss/runner-images/ci-full \
|
||||
--tag latest \
|
||||
--registry git.oblachno.oblachno.fyi \
|
||||
--registry git.oblachno.fyi \
|
||||
--push
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
@@ -124,7 +124,7 @@ jobs:
|
||||
if: always() && needs.build-and-push.result == 'success'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
image: git.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
credentials:
|
||||
username: ${{ vars.CI_GITEA_USERNAME }}
|
||||
password: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
validate:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||
image: git.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||
credentials:
|
||||
username: ${{ vars.CI_GITEA_USERNAME }}
|
||||
password: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
needs.validate.result == 'success'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
image: git.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
credentials:
|
||||
username: ${{ vars.CI_GITEA_USERNAME }}
|
||||
password: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
detect-and-configure:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
image: git.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
credentials:
|
||||
username: ${{ vars.CI_GITEA_USERNAME }}
|
||||
password: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
@@ -109,7 +109,7 @@ jobs:
|
||||
if: always() && needs.detect-and-configure.result == 'success'
|
||||
runs-on: docker
|
||||
container:
|
||||
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||
image: git.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||
credentials:
|
||||
username: ${{ vars.CI_GITEA_USERNAME }}
|
||||
password: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,12 +16,12 @@ quality badges.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](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
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
# Layers on top of ci-quality: adds release tools, molecule, deploy deps,
|
||||
# git-cliff, and OpenTofu.
|
||||
|
||||
FROM git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
|
||||
FROM git.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
|
||||
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
#
|
||||
# Layers on top of ci-base: adds lint tools + actionlint + checkmake.
|
||||
|
||||
FROM git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
FROM git.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
|
||||
# Install devx[lint] from local source (adds ruff, pyright, bandit, etc.)
|
||||
COPY . /tmp/devx
|
||||
|
||||
+8
-8
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -117,7 +117,7 @@ def main() -> int:
|
||||
parser.add_argument("--dry-run", action="store_true", help="List without cancelling")
|
||||
parser.add_argument(
|
||||
"--base-url",
|
||||
default=os.environ.get("GITEA_API_URL", "https://git.oblachno.oblachno.fyi"),
|
||||
default=os.environ.get("GITEA_API_URL", "https://git.oblachno.fyi"),
|
||||
help="Gitea base URL",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
+2
-2
@@ -68,8 +68,8 @@ def _get_int(key: str, env_var: str, default: int) -> int:
|
||||
|
||||
|
||||
# API endpoints — override via env vars for different Gitea/Vikunja instances
|
||||
GITEA_API_URL = _get("gitea_api_url", "DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
|
||||
VIKUNJA_API_URL = _get("vikunja_api_url", "DEVX_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1")
|
||||
GITEA_API_URL = _get("gitea_api_url", "DEVX_GITEA_API_URL", "https://git.oblachno.fyi/api/v1")
|
||||
VIKUNJA_API_URL = _get("vikunja_api_url", "DEVX_VIKUNJA_API_URL", "https://tasks.oblachno.fyi/api/v1")
|
||||
|
||||
# Organization defaults — each project MUST set DEVX_REPO_OWNER explicitly.
|
||||
# No default: prevents silent 404s when the wrong owner is used.
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
# DEVX_TYPECHECK_PATHS — paths for pyright (default: empty — uses pyright config)
|
||||
# DEVX_COV_PKG — coverage package name (default: src/devx)
|
||||
# DEVX_TEST_PATHS — pytest paths (default: tests/)
|
||||
# DEVX_GITEA_PYPI_HOST — Gitea PyPI host (default: git.oblachno.oblachno.fyi)
|
||||
# DEVX_GITEA_PYPI_HOST — Gitea PyPI host (default: git.oblachno.fyi)
|
||||
# DEVX_GITEA_PYPI_ORG — Gitea PyPI org (default: oblachno-oss)
|
||||
# DEVX_ACTIONLINT_CFG — actionlint config file (default: .gitea/actionlint.yaml)
|
||||
# DEVX_WORKFLOW_DIR — workflow directory (default: .gitea/workflows)
|
||||
@@ -50,7 +50,7 @@ DEVX_BIN ?= $(DEVX_VENV)/bin
|
||||
DEVX_LINT_PATHS ?= src/ tests/
|
||||
DEVX_COV_PKG ?= src/devx
|
||||
DEVX_TEST_PATHS ?= tests/
|
||||
DEVX_GITEA_PYPI_HOST ?= git.oblachno.oblachno.fyi
|
||||
DEVX_GITEA_PYPI_HOST ?= git.oblachno.fyi
|
||||
DEVX_GITEA_PYPI_ORG ?= oblachno-oss
|
||||
DEVX_ACTIONLINT_CFG ?= .gitea/actionlint.yaml
|
||||
DEVX_WORKFLOW_DIR ?= .gitea/workflows
|
||||
@@ -445,11 +445,11 @@ devx-setup-image:
|
||||
# ── Docker image build / push / cleanup ───────────────────────────────────────
|
||||
#
|
||||
# Variables:
|
||||
# DEVX_GITEA_REGISTRY — registry URL (default: git.oblachno.oblachno.fyi)
|
||||
# DEVX_GITEA_REGISTRY — registry URL (default: git.oblachno.fyi)
|
||||
# DEVX_IMAGE_MANIFEST — path to JSON manifest (default: docker/images.json)
|
||||
# DEVX_IMAGE_OWNER — package owner for cleanup (default: oblachno-oss)
|
||||
|
||||
DEVX_GITEA_REGISTRY ?= git.oblachno.oblachno.fyi
|
||||
DEVX_GITEA_REGISTRY ?= git.oblachno.fyi
|
||||
DEVX_IMAGE_MANIFEST ?= docker/images.json
|
||||
DEVX_IMAGE_OWNER ?= oblachno-oss
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -20,7 +20,7 @@ from pathlib import Path
|
||||
PLATFORMS: list[dict[str, str]] = [
|
||||
{
|
||||
"name": "ubuntu-2604",
|
||||
"image": "git.oblachno.oblachno.fyi/oblachno/molecule-test-base:latest",
|
||||
"image": "git.oblachno.fyi/oblachno/molecule-test-base:latest",
|
||||
"command": "sleep infinity",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ Usage::
|
||||
--dockerfile docker/ci-base/Dockerfile \\
|
||||
--tag ci-base:latest \\
|
||||
--tag ci-base:0.19.3 \\
|
||||
--registry git.oblachno.oblachno.fyi \\
|
||||
--registry git.oblachno.fyi \\
|
||||
--push
|
||||
|
||||
# Build multiple images (from a manifest file)
|
||||
|
||||
@@ -6,7 +6,7 @@ loading from environment variables. Supports required and optional modes.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.tools.docker_login --registry git.oblachno.oblachno.fyi \\
|
||||
python3 -m devx.tools.docker_login --registry git.oblachno.fyi \\
|
||||
--token-env CI_GITEA_TOKEN --username-env CI_GITEA_USERNAME \\
|
||||
--default-username emil
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ def _configure_tea_login() -> None:
|
||||
click.echo("tea: Gitea API token not set — skipping login configuration.")
|
||||
return
|
||||
|
||||
api_url = os.environ.get("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
|
||||
api_url = os.environ.get("DEVX_GITEA_API_URL", "https://git.oblachno.fyi/api/v1")
|
||||
gitea_url = api_url.replace("/api/v1", "")
|
||||
login_name = "devx"
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ def _fallback_to_setup_ci() -> None:
|
||||
)
|
||||
@click.option(
|
||||
"--gitea-host",
|
||||
default="git.oblachno.oblachno.fyi",
|
||||
default="git.oblachno.fyi",
|
||||
show_default=True,
|
||||
help="Gitea host for the PyPI registry.",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -86,7 +86,7 @@ class TestPushToBadgesBranch:
|
||||
|
||||
class TestUpdateBadgeUrls:
|
||||
def test_replaces_branch_url(self) -> None:
|
||||
content = "[]"
|
||||
content = "[]"
|
||||
result = push_badges.update_badge_urls(content, "abc123def456")
|
||||
assert "raw/commit/abc123def456/tests.svg" in result
|
||||
assert "raw/branch/badges" not in result
|
||||
@@ -95,7 +95,7 @@ class TestUpdateBadgeUrls:
|
||||
"""Old commit SHA URLs should be replaced with the new one."""
|
||||
old_sha = "aabb123456789012345678901234567890123456" # 40 hex chars
|
||||
new_sha = "ccdd123456789012345678901234567890123456" # 40 hex chars
|
||||
content = f"[]"
|
||||
content = f"[]"
|
||||
result = push_badges.update_badge_urls(content, new_sha)
|
||||
assert f"raw/commit/{new_sha}/tests.svg" in result
|
||||
assert old_sha not in result
|
||||
@@ -107,16 +107,16 @@ class TestUpdateBadgeUrls:
|
||||
|
||||
def test_multiple_badges(self) -> None:
|
||||
content = (
|
||||
"[]\n"
|
||||
"[]\n"
|
||||
"[]"
|
||||
"[]\n"
|
||||
"[]\n"
|
||||
"[]"
|
||||
)
|
||||
result = push_badges.update_badge_urls(content, "abc123def456")
|
||||
assert result.count("raw/commit/abc123def456/") == 3
|
||||
assert "raw/branch/badges" not in result
|
||||
|
||||
def test_preserves_non_badge_urls(self) -> None:
|
||||
content = "[]"
|
||||
content = "[]"
|
||||
result = push_badges.update_badge_urls(content, "abc123")
|
||||
assert result == content
|
||||
|
||||
@@ -124,7 +124,7 @@ class TestUpdateBadgeUrls:
|
||||
class TestUpdateReadmeWithBadgeSha:
|
||||
def test_updates_readme(self, tmp_path: Path) -> None:
|
||||
readme = tmp_path / "README.md"
|
||||
readme.write_text("[]")
|
||||
readme.write_text("[]")
|
||||
with patch("subprocess.run"):
|
||||
push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path)
|
||||
content = readme.read_text()
|
||||
@@ -147,7 +147,7 @@ class TestUpdateReadmeWithBadgeSha:
|
||||
|
||||
def test_version_verification_stale(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
readme = tmp_path / "README.md"
|
||||
readme.write_text("[]")
|
||||
readme.write_text("[]")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
badges_dir = tmp_path / ".badges"
|
||||
badges_dir.mkdir(exist_ok=True)
|
||||
@@ -159,7 +159,7 @@ class TestUpdateReadmeWithBadgeSha:
|
||||
|
||||
def test_version_verification_current(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
readme = tmp_path / "README.md"
|
||||
readme.write_text("[]")
|
||||
readme.write_text("[]")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
badges_dir = tmp_path / ".badges"
|
||||
badges_dir.mkdir(exist_ok=True)
|
||||
@@ -171,7 +171,7 @@ class TestUpdateReadmeWithBadgeSha:
|
||||
|
||||
def test_version_verification_no_badges_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
readme = tmp_path / "README.md"
|
||||
readme.write_text("[]")
|
||||
readme.write_text("[]")
|
||||
monkeypatch.chdir(tmp_path)
|
||||
# No .badges/version.svg exists — should skip verification gracefully
|
||||
with patch("subprocess.run"):
|
||||
|
||||
@@ -19,12 +19,12 @@ from devx.tools.setup_image import (
|
||||
class TestBuildPipExtraIndexUrl:
|
||||
def test_basic_url(self) -> None:
|
||||
url = _build_pip_extra_index_url(
|
||||
"git.oblachno.oblachno.fyi",
|
||||
"git.oblachno.fyi",
|
||||
"oblachno-oss",
|
||||
"emil",
|
||||
"tok123",
|
||||
)
|
||||
assert url == "https://emil:tok123@git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple/"
|
||||
assert url == "https://emil:tok123@git.oblachno.fyi/api/packages/oblachno-oss/pypi/simple/"
|
||||
|
||||
def test_custom_host_org(self) -> None:
|
||||
url = _build_pip_extra_index_url(
|
||||
@@ -233,7 +233,7 @@ class TestCli:
|
||||
assert call_args[0] == ".venv"
|
||||
assert call_args[1] == "/opt/venv"
|
||||
assert call_args[2] == "" # no extras
|
||||
assert call_args[3] == "git.oblachno.oblachno.fyi"
|
||||
assert call_args[3] == "git.oblachno.fyi"
|
||||
assert call_args[4] == "oblachno-oss"
|
||||
|
||||
@patch("devx.tools.setup_image._fallback_to_setup_ci")
|
||||
|
||||
Reference in New Issue
Block a user