Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f21b01dce2 | ||
|
|
ff80745eea | ||
|
|
319807f41c | ||
|
|
e652d3bb75 | ||
|
|
d59de06652 | ||
|
|
ae37a8e3e4 | ||
|
|
c63e85923a | ||
|
|
77c1af8ed3 | ||
|
|
a48fb46c52 | ||
|
|
2392a13afc | ||
|
|
32315b1d5d |
@@ -34,12 +34,13 @@ permissions:
|
||||
|
||||
You are a CI failure investigator for the devx repo.
|
||||
|
||||
## Working Directory
|
||||
## Working Directory & Virtual Environment
|
||||
|
||||
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first:
|
||||
```bash
|
||||
cd /home/emo/dev/ideas/oblachno/devx
|
||||
```
|
||||
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
|
||||
|
||||
All Python tools run inside `.venv`. `make` targets handle activation
|
||||
automatically — always use `make <target>`, never raw `pytest` or `ruff`
|
||||
commands. If `.venv` doesn't exist, run `make setup` first.
|
||||
|
||||
## CI Job Dependency Graph
|
||||
|
||||
@@ -131,8 +132,8 @@ make build-images-dry-run # dry-run build
|
||||
|
||||
For doc coverage issues:
|
||||
```bash
|
||||
python3 -m devx.ci.doc_coverage --fail-on-missing
|
||||
python3 -m devx.ci.lint_docs --root .
|
||||
.venv/bin/python -m devx.ci.doc_coverage --fail-on-missing
|
||||
.venv/bin/python -m devx.ci.lint_docs --root .
|
||||
```
|
||||
|
||||
### Step 5: Check for related Vikunja tasks
|
||||
|
||||
@@ -30,10 +30,14 @@ permissions:
|
||||
|
||||
You are a dependency upgrade specialist for the devx repo.
|
||||
|
||||
## Working Directory
|
||||
## Working Directory & Virtual Environment
|
||||
|
||||
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
|
||||
|
||||
All Python tools run inside `.venv`. `make` targets handle activation
|
||||
automatically — always use `make <target>`, never raw `pytest` or `ruff`
|
||||
commands. If `.venv` doesn't exist, run `make setup` first.
|
||||
|
||||
## Dependency Reference Locations
|
||||
|
||||
- **Primary**: `pyproject.toml` — `[project] dependencies` and `[project.optional-dependencies]`
|
||||
@@ -81,8 +85,8 @@ If adding a new dependency without a comment, `check_pyproject_deps` will fail.
|
||||
pip install -e .[dev] # reinstall with new deps
|
||||
make pytest-cov # 100% coverage required
|
||||
make lint-all # ruff + pyright + bandit + actionlint + hadolint
|
||||
python3 -m devx.tools.check_pyproject_deps # verify dep docs
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
|
||||
.venv/bin/python -m devx.tools.check_pyproject_deps # verify dep docs
|
||||
.venv/bin/python -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
|
||||
```
|
||||
|
||||
All must pass. If `check_pyproject_deps` fails, add the missing comment.
|
||||
|
||||
@@ -25,10 +25,14 @@ permissions:
|
||||
|
||||
You are a documentation sync specialist for the devx repo.
|
||||
|
||||
## Working Directory
|
||||
## Working Directory & Virtual Environment
|
||||
|
||||
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
|
||||
|
||||
All Python tools run inside `.venv`. `make` targets handle activation
|
||||
automatically — always use `make <target>`, never raw `pytest` or `ruff`
|
||||
commands. If `.venv` doesn't exist, run `make setup` first.
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
```
|
||||
@@ -56,7 +60,7 @@ docs/
|
||||
|
||||
### Step 1: Check documentation coverage
|
||||
```bash
|
||||
python3 -m devx.ci.doc_coverage --fail-on-missing
|
||||
.venv/bin/python -m devx.ci.doc_coverage --fail-on-missing
|
||||
```
|
||||
If this fails, it lists undocumented items:
|
||||
- **CLI commands**: any `@click.command()` or `@click.group()` without a docs entry
|
||||
@@ -69,7 +73,7 @@ for modules.
|
||||
|
||||
### Step 2: Lint documentation structure
|
||||
```bash
|
||||
python3 -m devx.ci.lint_docs --root .
|
||||
.venv/bin/python -m devx.ci.lint_docs --root .
|
||||
```
|
||||
Common issues:
|
||||
- **Broken internal links**: `[text](page.md)` where `page.md` doesn't exist
|
||||
@@ -88,7 +92,7 @@ exist. If a script/module was renamed or deleted, update all doc references.
|
||||
|
||||
### Step 4: Verify wiki sync (if investigating a sync failure)
|
||||
```bash
|
||||
python3 -m devx.ci.sync_wiki --repo oblachno-oss/devx --strict
|
||||
.venv/bin/python -m devx.ci.sync_wiki --repo oblachno-oss/devx --strict
|
||||
```
|
||||
Common sync failures:
|
||||
- **Content mismatch**: wiki page content doesn't match local docs — usually means a previous sync was interrupted
|
||||
|
||||
@@ -34,10 +34,14 @@ permissions:
|
||||
|
||||
You are a Docker image build specialist for the devx repo.
|
||||
|
||||
## Working Directory
|
||||
## Working Directory & Virtual Environment
|
||||
|
||||
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
|
||||
|
||||
All Python tools run inside `.venv`. `make` targets handle activation
|
||||
automatically — always use `make <target>`, never raw `pytest` or `ruff`
|
||||
commands. If `.venv` doesn't exist, run `make setup` first.
|
||||
|
||||
## Image Architecture
|
||||
|
||||
Three tier images built sequentially (each FROM the previous):
|
||||
@@ -87,7 +91,7 @@ This builds all 3 tiers sequentially and pushes to the Gitea registry.
|
||||
|
||||
If only one tier needs rebuilding:
|
||||
```bash
|
||||
python3 -m devx.tools.build_image \
|
||||
.venv/bin/python -m devx.tools.build_image \
|
||||
--dockerfile docker/ci-quality/Dockerfile \
|
||||
--name oblachno-oss/runner-images/ci-quality \
|
||||
--tag latest \
|
||||
|
||||
@@ -27,10 +27,14 @@ permissions:
|
||||
|
||||
You are a Gitea Actions workflow validator for the devx repo.
|
||||
|
||||
## Working Directory
|
||||
## Working Directory & Virtual Environment
|
||||
|
||||
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
|
||||
|
||||
All Python tools run inside `.venv`. `make` targets handle activation
|
||||
automatically — always use `make <target>`, never raw `pytest` or `ruff`
|
||||
commands. If `.venv` doesn't exist, run `make setup` first.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `.gitea/workflows/ci.yml` — PR pipeline (quality, detect-changes, release-dry-run, pr-review, auto-merge)
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5
|
||||
- name: Documentation coverage check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
|
||||
@@ -49,6 +49,44 @@ repos:
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: checkmake
|
||||
name: checkmake Makefile linter
|
||||
entry: make checkmake
|
||||
language: system
|
||||
files: (Makefile|\.mak)$
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: check-test-speed
|
||||
name: unit test speed check
|
||||
entry: .venv/bin/python -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5
|
||||
language: system
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: check-translations
|
||||
name: translation completeness check
|
||||
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.check_translations
|
||||
language: system
|
||||
files: ^src/devx/translations\.json$
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: doc-coverage
|
||||
name: documentation coverage check
|
||||
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.doc_coverage --fail-on-missing
|
||||
language: system
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: lint-docs
|
||||
name: documentation lint check
|
||||
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.lint_docs --root .
|
||||
language: system
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: pytest-cov
|
||||
name: pytest with 100% coverage
|
||||
entry: make pytest-cov
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# AGENTS.md — Project Conventions for devx
|
||||
|
||||
## Virtual Environment
|
||||
|
||||
All Python tools, tests, and scripts run inside a standard `.venv` directory.
|
||||
Activate it before running any non-`make` command:
|
||||
|
||||
```bash
|
||||
source activate.sh # bash/zsh
|
||||
source activate.fish # fish
|
||||
source activate.zsh # zsh
|
||||
```
|
||||
|
||||
If `.venv` doesn't exist, run `make setup` first. The `make` targets handle
|
||||
venv activation automatically — always prefer `make <target>` over raw commands.
|
||||
|
||||
## Build & Test Commands
|
||||
|
||||
```bash
|
||||
@@ -522,6 +536,33 @@ create-task: devx-create-task
|
||||
- Secrets are passed via environment variables, never on the command line
|
||||
- All user-facing strings wrapped in `_()` for i18n
|
||||
|
||||
### Container-Level Fix Verification (Mandatory)
|
||||
|
||||
**Rule:** Before pushing any fix that modifies container state (CA certs,
|
||||
config files, installed packages, daemon restarts), reproduce the exact
|
||||
sequence locally with the actual Docker image. Do not push to CI as the
|
||||
first test.
|
||||
|
||||
This is a hard rule, not a suggestion. CI cycles take 20+ minutes and
|
||||
ephemeral staging VMs are destroyed after each run, making interactive
|
||||
debugging impossible. A local reproduction takes 30 seconds and catches
|
||||
silent failures immediately.
|
||||
|
||||
**Procedure:**
|
||||
1. `docker pull <actual_image>`
|
||||
2. `docker run -d --name <test> ...` and wait for it to start
|
||||
3. Run the exact commands from the Ansible task or script
|
||||
4. Verify the state change took effect
|
||||
5. Clean up: `docker rm -f <test>`
|
||||
|
||||
### Verified State Modification (Mandatory)
|
||||
|
||||
Ansible tasks that modify container state with `changed_when: false`
|
||||
MUST include a post-task verification step that confirms the state
|
||||
change took effect. `changed_when: false` suppresses both change
|
||||
detection AND failure visibility — a task can silently do nothing and
|
||||
report `ok`.
|
||||
|
||||
## Subagent Delegation Policy
|
||||
|
||||
Custom subagent profiles are defined in `.devin/agents/` (project-specific)
|
||||
|
||||
@@ -2,6 +2,24 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.32.1] - 2026-07-01
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add missing i18n translations for new tools
|
||||
|
||||
## [0.32.0] - 2026-07-01
|
||||
|
||||
### Features
|
||||
|
||||
- Extract docker-login, tofu-ops, check-deps, install-tofu to Python tools
|
||||
|
||||
## [0.31.0] - 2026-07-01
|
||||
|
||||
### Features
|
||||
|
||||
- Centralize venv management in devx.mak
|
||||
|
||||
## [0.30.0] - 2026-07-01
|
||||
|
||||
### Features
|
||||
|
||||
@@ -6,6 +6,36 @@ BIN := $(VENV)/bin
|
||||
|
||||
all: setup
|
||||
|
||||
# --- devx.mak integration ----------------------------------------------------
|
||||
# Include shared targets from the devx package itself (venv management,
|
||||
# workflow-lint, notify-failure, checkmake, lint targets, quality checks, etc.)
|
||||
# Since devx IS the package, we can include its own devx.mak.
|
||||
DEVX_PYTHON := $(BIN)/python
|
||||
DEVX_VENV := $(VENV)
|
||||
DEVX_BIN := $(BIN)
|
||||
DEVX_LINT_PATHS := src/ tests/
|
||||
DEVX_COV_PKG := src/devx
|
||||
DEVX_TEST_PATHS := tests/
|
||||
|
||||
DEVX_MAK := $(shell $(BIN)/python -c \
|
||||
"from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
|
||||
2>/dev/null)
|
||||
# Fallback: when the venv doesn't exist yet (chicken-and-egg), use the
|
||||
# source tree copy directly. devx IS the package, so src/devx/make/devx.mak
|
||||
# is always available in this repo.
|
||||
ifeq ($(strip $(DEVX_MAK)),)
|
||||
DEVX_MAK := $(CURDIR)/src/devx/make/devx.mak
|
||||
endif
|
||||
-include $(DEVX_MAK)
|
||||
|
||||
# venv, .env, and activate-scripts are provided by devx.mak
|
||||
# (devx-venv, devx-env, devx-activate-scripts, $(DEVX_VENV)/bin/activate rule)
|
||||
# Aliases for convenience and backward compatibility:
|
||||
.PHONY: venv activate-scripts
|
||||
venv: devx-venv
|
||||
.env: devx-env
|
||||
activate-scripts: devx-activate-scripts
|
||||
|
||||
# Full setup for local development
|
||||
setup: $(VENV)/bin/activate .env activate-scripts install-tools
|
||||
@$(BIN)/pip install -e '.[dev]' 2>/dev/null; \
|
||||
@@ -35,22 +65,9 @@ setup-release: $(VENV)/bin/activate .env
|
||||
# an older devx.mak that doesn't yet define devx-setup-image. Consumer repos
|
||||
# (grm, infra) can safely alias to devx-setup-image since they install devx from PyPI.
|
||||
setup-image:
|
||||
@if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate && pip install --no-cache-dir -e . 2>/dev/null; \
|
||||
@if [ -d /opt/venv ]; then ln -sf /opt/venv $(VENV); . $(VENV)/bin/activate && pip install --no-cache-dir -e . 2>/dev/null; \
|
||||
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
|
||||
|
||||
.env:
|
||||
@if [ ! -f .env ]; then cp .env.example .env; echo "Created .env from .env.example — please edit it."; fi
|
||||
|
||||
$(VENV)/bin/activate:
|
||||
@python3 -c "import sys; v=sys.version_info; assert v >= (3, 12), f'Python 3.12+ required, found {v.major}.{v.minor}'; print(f'Python {v.major}.{v.minor}.{v.micro} OK')"
|
||||
$(PYTHON) -m venv $(VENV)
|
||||
$(BIN)/pip install --upgrade pip setuptools wheel
|
||||
|
||||
activate-scripts: $(VENV)/bin/activate
|
||||
@test -f activate.sh || (echo '#!/usr/bin/env bash' > activate.sh && echo 'source "$$(cd "$$(dirname "$${BASH_SOURCE[0]}")" && pwd)/.venv/bin/activate"' >> activate.sh && chmod +x activate.sh)
|
||||
@test -f activate.fish || (echo '#!/usr/bin/env fish' > activate.fish && echo 'set -l script_dir (dirname (status --current-filename))' >> activate.fish && echo 'source "$$script_dir/.venv/bin/activate.fish"' >> activate.fish && chmod +x activate.fish)
|
||||
@test -f activate.zsh || (echo '#!/usr/bin/env zsh' > activate.zsh && echo '0="$${ZERO:-$${0:#$$ZSH_ARGZERO}}"' >> activate.zsh && echo '0="$${$${(M)0:#/*}:-$$PWD/$$0}"' >> activate.zsh && echo 'source "$${0:A:h}/.venv/bin/activate"' >> activate.zsh && chmod +x activate.zsh)
|
||||
|
||||
install-hooks:
|
||||
@cp hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
|
||||
@cp hooks/pre-push .git/hooks/pre-push && chmod +x .git/hooks/pre-push
|
||||
@@ -60,23 +77,13 @@ install-tools: $(VENV)/bin/activate
|
||||
@$(BIN)/pip install -e '.' 2>/dev/null; \
|
||||
$(BIN)/python -m devx.tools.install_tools
|
||||
|
||||
# --- devx.mak integration ----------------------------------------------------
|
||||
# Include shared targets from the devx package itself (workflow-lint,
|
||||
# notify-failure, checkmake, lint targets, quality checks, etc.)
|
||||
# Since devx IS the package, we can include its own devx.mak.
|
||||
DEVX_PYTHON := $(BIN)/python
|
||||
DEVX_VENV := $(VENV)
|
||||
DEVX_BIN := $(BIN)
|
||||
DEVX_LINT_PATHS := src/ tests/
|
||||
DEVX_COV_PKG := src/devx
|
||||
DEVX_TEST_PATHS := tests/
|
||||
|
||||
DEVX_MAK := $(shell $(BIN)/python -c \
|
||||
"from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
|
||||
2>/dev/null)
|
||||
-include $(DEVX_MAK)
|
||||
|
||||
# Aliases — project-specific names map to devx.mak targets
|
||||
.PHONY: lint-ruff lint-format typecheck lint-bandit lint-deps lint
|
||||
.PHONY: workflow-lint workflow-dryrun workflow-dryrun-safe workflow-check
|
||||
.PHONY: notify-failure checkmake check-mutable-globals check-dep-docs
|
||||
.PHONY: check-test-speed check-test-coverage check-docs
|
||||
.PHONY: create-task create-pr push-with-pr git-push rebase pr-rebase
|
||||
.PHONY: lint-all lint-dockerfiles
|
||||
lint-ruff: devx-lint-ruff
|
||||
lint-format: devx-lint-format
|
||||
typecheck: devx-typecheck
|
||||
@@ -108,10 +115,7 @@ lint-all: lint workflow-lint lint-dockerfiles
|
||||
# devx's own CI images may have an older devx.mak. Consumer repos can safely alias.
|
||||
lint-dockerfiles:
|
||||
@echo "[lint-dockerfiles] Linting Dockerfiles with hadolint..."
|
||||
@if ! command -v hadolint >/dev/null 2>&1; then \
|
||||
echo "[lint-dockerfiles] ERROR: hadolint not found. Install from https://github.com/hadolint/hadolint/releases" >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
@command -v hadolint >/dev/null 2>&1 || { echo "hadolint not found" >&2; exit 1; }
|
||||
@find docker -name 'Dockerfile*' -exec hadolint {} +
|
||||
@echo "[lint-dockerfiles] All Dockerfiles passed."
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
+6
-6
@@ -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
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||
|
||||
__version__ = "0.30.0"
|
||||
__version__ = "0.32.1"
|
||||
|
||||
+44
-14
@@ -56,17 +56,57 @@ DEVX_DOCKERFILE_PATHS ?= docker
|
||||
# PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured.
|
||||
# Usage: $(DEVX_PIP_INSTALL) install -e '.[ci,lint]'
|
||||
# CI_GITEA_USERNAME can be set in .env, as an env var, or as a Make variable.
|
||||
# Projects can alias: PIP_INSTALL = $(DEVX_PIP_INSTALL)
|
||||
DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
|
||||
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
|
||||
_PYPI_USER="$${CI_GITEA_USERNAME:-emil}"; \
|
||||
if [ -n "$$CI_GITEA_TOKEN" ] && [ -n "$$_PYPI_USER" ]; then export PIP_EXTRA_INDEX_URL="https://$$_PYPI_USER:$$CI_GITEA_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
|
||||
$(DEVX_BIN)/pip
|
||||
|
||||
# ── Virtual environment management ────────────────────────────────────────────
|
||||
#
|
||||
# These targets provide a single, consistent venv setup across all
|
||||
# devx-integrated projects (infra, grm, devx). Each project includes
|
||||
# devx.mak and aliases its local targets to these.
|
||||
#
|
||||
# The venv is a standard .venv directory (no pyenv virtualenv dependency).
|
||||
# pyenv can still be used to install Python 3.12+ but the venv itself
|
||||
# is created with `python3 -m venv .venv`.
|
||||
#
|
||||
# Projects should set these variables BEFORE including devx.mak:
|
||||
# DEVX_VENV — venv directory (default: .venv)
|
||||
# DEVX_BIN — venv bin directory (default: $(DEVX_VENV)/bin)
|
||||
# DEVX_PYTHON — Python executable (default: python3; should be $(DEVX_BIN)/python after setup)
|
||||
#
|
||||
# Common aliases in project Makefiles:
|
||||
# PIP_INSTALL = $(DEVX_PIP_INSTALL)
|
||||
# venv: devx-venv
|
||||
# activate-scripts: devx-activate-scripts
|
||||
# .env: devx-env
|
||||
|
||||
# Create .venv with Python version check (3.12+ required)
|
||||
$(DEVX_VENV)/bin/activate:
|
||||
@python3 -c "import sys; v=sys.version_info; assert v >= (3, 12), f'Python 3.12+ required, found {v.major}.{v.minor}'; print(f'Python {v.major}.{v.minor}.{v.micro} OK')"
|
||||
python3 -m venv $(DEVX_VENV)
|
||||
$(DEVX_BIN)/pip install --upgrade pip setuptools wheel
|
||||
|
||||
# Alias: devx-venv creates the venv (delegates to the activate rule)
|
||||
devx-venv: $(DEVX_VENV)/bin/activate
|
||||
|
||||
# Ensure a venv exists — in CI (no pyenv), creates .venv if missing.
|
||||
# Locally, uses the existing .venv (created by `make setup` or `make devx-venv`).
|
||||
devx-ensure-venv:
|
||||
@if [ ! -f $(DEVX_BIN)/python ]; then \
|
||||
echo "[ensure-venv] Creating $(DEVX_VENV) (no venv found)..."; \
|
||||
python3 -m venv $(DEVX_VENV); \
|
||||
$(DEVX_BIN)/pip install --upgrade pip setuptools wheel; \
|
||||
fi
|
||||
|
||||
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config
|
||||
.PHONY: devx-pr-status devx-pr-logs devx-pr-label devx-pr-review devx-rebase devx-pr-rebase
|
||||
.PHONY: devx-configure-gitea-pypi devx-install-tools devx-install-checkmake devx-checkmake
|
||||
.PHONY: devx-workflow-lint devx-workflow-dryrun devx-workflow-dryrun-safe devx-workflow-check
|
||||
.PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts
|
||||
.PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts devx-venv devx-ensure-venv
|
||||
.PHONY: devx-lint-ruff devx-lint-format devx-typecheck devx-lint-bandit devx-lint-deps devx-lint
|
||||
.PHONY: devx-clean devx-pre-push
|
||||
.PHONY: devx-check-mutable-globals devx-check-dep-docs devx-check-test-coverage devx-check-docs devx-check-test-speed
|
||||
@@ -165,12 +205,6 @@ devx-env:
|
||||
echo "Created .env from .env.example — please edit it with your credentials."; \
|
||||
fi
|
||||
|
||||
# Create Python venv with version check
|
||||
devx-venv:
|
||||
@python3 -c "import sys; v=sys.version_info; assert v >= (3, 12), f'Python 3.12+ required, found {v.major}.{v.minor}'; print(f'Python {v.major}.{v.minor}.{v.micro} OK')"
|
||||
$(DEVX_PYTHON) -m venv $(DEVX_VENV)
|
||||
$(DEVX_BIN)/pip install --upgrade pip setuptools wheel
|
||||
|
||||
# Create activate scripts for shell/fish/zsh
|
||||
devx-activate-scripts:
|
||||
@test -f activate.sh || (echo '#!/usr/bin/env bash' > activate.sh && echo 'source "$$(cd "$$(dirname "$${BASH_SOURCE[0]}")" && pwd)/.venv/bin/activate"' >> activate.sh && chmod +x activate.sh)
|
||||
@@ -266,7 +300,7 @@ devx-lint: devx-lint-ruff devx-lint-format devx-typecheck devx-lint-bandit
|
||||
# ── Testing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
devx-test-unit:
|
||||
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -v --no-cov
|
||||
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -q --no-cov
|
||||
|
||||
devx-pytest-cov:
|
||||
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -v --cov=$(DEVX_COV_PKG) --cov-report=term-missing --cov-fail-under=100
|
||||
@@ -341,12 +375,8 @@ devx-lint-dockerfiles:
|
||||
# devx-setup-ci) — each project defines its own setup-ci target.
|
||||
|
||||
devx-setup-image:
|
||||
@if [ -d /opt/venv ]; then ln -sf /opt/venv $(DEVX_VENV); . $(DEVX_BIN)/activate; \
|
||||
_U="$${CI_GITEA_USERNAME:-emil}"; \
|
||||
if [ -n "$$CI_GITEA_TOKEN" ]; then export PIP_EXTRA_INDEX_URL="https://$$_U:$$CI_GITEA_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
|
||||
pip install --no-cache-dir -e .$(if $(EXTRAS),[$(EXTRAS)],); \
|
||||
echo "[devx-setup-image] Linked /opt/venv$(if $(EXTRAS), with [$(EXTRAS)],)."; \
|
||||
else echo "[devx-setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
|
||||
@/opt/venv/bin/python -m devx.tools.setup_image --venv $(DEVX_VENV) --extras "$(EXTRAS)" \
|
||||
--gitea-host $(DEVX_GITEA_PYPI_HOST) --gitea-org $(DEVX_GITEA_PYPI_ORG)
|
||||
|
||||
# ── Docker image build / push / cleanup ───────────────────────────────────────
|
||||
#
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check that required development tools are present.
|
||||
|
||||
Verifies the availability of core tools (tofu, docker, checkmake, Python
|
||||
3.12+ in the venv) and prints warnings or errors for missing ones.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.tools.check_deps
|
||||
python3 -m devx.tools.check_deps --venv .venv
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
REQUIRED_TOOLS = ["tofu", "docker"]
|
||||
OPTIONAL_TOOLS = ["checkmake"]
|
||||
PYTHON_MIN_VERSION = (3, 12)
|
||||
|
||||
|
||||
def _check_tool(name: str, *, optional: bool = False) -> bool:
|
||||
"""Check if a tool is on PATH. Returns True if found."""
|
||||
found = shutil.which(name) is not None
|
||||
if found:
|
||||
return True
|
||||
level = "WARN" if optional else "ERROR"
|
||||
click.echo(
|
||||
_("{level}: {tool} not found.{hint}", level=level, tool=name, hint=""),
|
||||
err=True,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _check_python_version(venv_bin: Path) -> None:
|
||||
"""Check that the venv Python is >= 3.12."""
|
||||
python_bin = venv_bin / "python"
|
||||
if not python_bin.exists():
|
||||
click.echo(
|
||||
_("WARN: .venv not found. Run 'make setup-venv' to create it."),
|
||||
err=True,
|
||||
)
|
||||
return
|
||||
result = subprocess.run( # nosec B603
|
||||
[str(python_bin), "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
click.echo(_("WARN: Could not determine Python version in .venv."), err=True)
|
||||
return
|
||||
version_str = result.stdout.strip().split()[-1] if result.stdout else ""
|
||||
try:
|
||||
major, minor = int(version_str.split(".")[0]), int(version_str.split(".")[1])
|
||||
except (IndexError, ValueError):
|
||||
click.echo(_("WARN: Could not parse Python version '{version}'.", version=version_str), err=True)
|
||||
return
|
||||
if (major, minor) < PYTHON_MIN_VERSION:
|
||||
click.echo(
|
||||
_(
|
||||
"WARN: .venv has Python {version}, but >={req} is required.",
|
||||
version=version_str,
|
||||
req=f"{PYTHON_MIN_VERSION[0]}.{PYTHON_MIN_VERSION[1]}",
|
||||
),
|
||||
err=True,
|
||||
)
|
||||
return
|
||||
click.echo(_("[check-deps] Virtualenv .venv ready (Python {version}).", version=version_str))
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--venv", default=".venv", show_default=True, help="Path to the virtual environment.")
|
||||
@click.option("--checkmake-bin", default=None, help="Path to checkmake binary (fallback if not on PATH).")
|
||||
def cli(venv: str, checkmake_bin: str | None) -> None:
|
||||
"""Verify that required development tools are present."""
|
||||
click.echo(_("[check-deps] Verifying tools..."))
|
||||
|
||||
all_required = True
|
||||
for tool in REQUIRED_TOOLS:
|
||||
if not _check_tool(tool):
|
||||
all_required = False
|
||||
|
||||
for tool in OPTIONAL_TOOLS:
|
||||
if not _check_tool(tool, optional=True):
|
||||
if checkmake_bin and Path(checkmake_bin).exists():
|
||||
click.echo(_(" {tool}: found at {path}", tool=tool, path=checkmake_bin))
|
||||
else:
|
||||
click.echo(_(" Run 'make install-checkmake' to install the Makefile linter."))
|
||||
|
||||
_check_python_version(Path(venv) / "bin")
|
||||
|
||||
if not all_required:
|
||||
raise click.ClickException(_("Required tools missing."))
|
||||
click.echo(_("[check-deps] All core tools present."))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Docker registry login helper.
|
||||
|
||||
Handles login to Docker registries (Gitea, Docker Hub) with credential
|
||||
loading from environment variables. Supports required and optional modes.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.tools.docker_login --registry git.oblachno.oblachno.fyi \\
|
||||
--token-env CI_GITEA_TOKEN --username-env CI_GITEA_USERNAME \\
|
||||
--default-username emil
|
||||
|
||||
python3 -m devx.tools.docker_login --registry docker.io \\
|
||||
--token-env DOCKER_HUB_TOKEN --username-env DOCKER_HUB_USERNAME --optional
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
|
||||
def docker_login(
|
||||
registry: str,
|
||||
username: str,
|
||||
token: str,
|
||||
*,
|
||||
suppress_failure: bool = False,
|
||||
) -> bool:
|
||||
"""Log in to a Docker registry.
|
||||
|
||||
Returns True on success, False on failure.
|
||||
If ``suppress_failure`` is True, prints a warning instead of raising.
|
||||
"""
|
||||
cmd = ["docker", "login", registry, "-u", username, "-p", token]
|
||||
result = subprocess.run( # nosec B603
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
if suppress_failure:
|
||||
click.echo(
|
||||
_("[docker-login] Login to {registry} failed (continuing).", registry=registry),
|
||||
err=True,
|
||||
)
|
||||
return False
|
||||
raise click.ClickException(
|
||||
_("Login to {registry} failed: {error}", registry=registry, error=result.stderr.strip()),
|
||||
)
|
||||
click.echo(_("[docker-login] Logged in to {registry}.", registry=registry))
|
||||
return True
|
||||
|
||||
|
||||
def _resolve_credentials(
|
||||
token_env: str,
|
||||
username_env: str,
|
||||
default_username: str | None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Resolve credentials from environment variables.
|
||||
|
||||
Returns (username, token) or (None, None) if token is not set.
|
||||
"""
|
||||
import os
|
||||
|
||||
token = os.environ.get(token_env, "")
|
||||
if not token:
|
||||
return None, None
|
||||
username = os.environ.get(username_env, "") or (default_username or "")
|
||||
return username, token
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--registry", required=True, help="Docker registry URL (e.g. docker.io, git.example.com).")
|
||||
@click.option("--token-env", required=True, help="Environment variable name for the auth token.")
|
||||
@click.option("--username-env", required=True, help="Environment variable name for the username.")
|
||||
@click.option(
|
||||
"--default-username",
|
||||
default=None,
|
||||
help="Default username if the env var is not set.",
|
||||
)
|
||||
@click.option(
|
||||
"--optional",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Skip silently if token is not set instead of raising.",
|
||||
)
|
||||
@click.option(
|
||||
"--suppress-failure",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Continue on login failure instead of raising (prints warning).",
|
||||
)
|
||||
def cli(
|
||||
registry: str,
|
||||
token_env: str,
|
||||
username_env: str,
|
||||
default_username: str | None,
|
||||
optional: bool,
|
||||
suppress_failure: bool,
|
||||
) -> None:
|
||||
"""Log in to a Docker registry using credentials from environment variables."""
|
||||
username, token = _resolve_credentials(token_env, username_env, default_username)
|
||||
if token is None:
|
||||
if optional:
|
||||
click.echo(_("[docker-login] Skipping {registry} (token {env} not set).", registry=registry, env=token_env))
|
||||
return
|
||||
raise click.ClickException(
|
||||
_("{env} is not set. Set it in your .env file or pass it as an environment variable.", env=token_env),
|
||||
)
|
||||
if not username:
|
||||
raise click.ClickException(
|
||||
_("{env} is not set. Set it in your .env file.", env=username_env),
|
||||
)
|
||||
docker_login(registry, username, token, suppress_failure=suppress_failure)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -42,6 +42,8 @@ TEA_VERSION = "0.14.1"
|
||||
|
||||
HADOLINT_VERSION = "2.12.0"
|
||||
|
||||
TOFU_VERSION = "1.12.3"
|
||||
|
||||
|
||||
def _arch() -> str:
|
||||
"""Return the architecture string used by release assets (delegates to shared utility)."""
|
||||
@@ -174,7 +176,27 @@ def install_hadolint() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea", "hadolint"]
|
||||
def install_tofu() -> bool:
|
||||
"""Install OpenTofu if not already present. Returns True if installed/skipped.
|
||||
|
||||
Downloads the official release tarball from GitHub and extracts the
|
||||
``tofu`` binary to ``~/.local/bin``.
|
||||
"""
|
||||
if _is_installed("tofu"):
|
||||
click.echo("tofu: already installed")
|
||||
return True
|
||||
arch = _arch()
|
||||
os_name = platform.system().lower()
|
||||
url = (
|
||||
f"https://github.com/opentofu/opentofu/releases/download/"
|
||||
f"v{TOFU_VERSION}/tofu_{TOFU_VERSION}_{os_name}_{arch}.tar.gz"
|
||||
)
|
||||
dest = _download_and_extract_tarball(url, "tofu")
|
||||
click.echo(f"tofu: installed to {dest}")
|
||||
return True
|
||||
|
||||
|
||||
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea", "hadolint", "tofu"]
|
||||
|
||||
|
||||
def _install_tool(name: str) -> bool:
|
||||
@@ -189,6 +211,8 @@ def _install_tool(name: str) -> bool:
|
||||
return install_tea()
|
||||
if name == "hadolint":
|
||||
return install_hadolint()
|
||||
if name == "tofu":
|
||||
return install_tofu()
|
||||
raise click.ClickException(f"Unknown tool: {name}")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Set up the project inside a pre-built CI image.
|
||||
|
||||
CI images (e.g. ``ci-quality:latest``) ship with a Python virtualenv at
|
||||
``/opt/venv`` that already contains the runtime dependencies. This tool
|
||||
links that venv to ``.venv`` in the project root and installs the project
|
||||
itself in editable mode, optionally with extras.
|
||||
|
||||
If ``/opt/venv`` does not exist (local development), falls back to
|
||||
``make setup-ci`` via ``subprocess``.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.tools.setup_image # runtime deps only
|
||||
python3 -m devx.tools.setup_image --extras lint # runtime + lint deps
|
||||
python3 -m devx.tools.setup_image --extras ci,lint
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
DEFAULT_VENV = ".venv"
|
||||
OPT_VENV = "/opt/venv"
|
||||
FALLBACK_TARGET = "setup-ci"
|
||||
|
||||
|
||||
def _build_pip_extra_index_url(
|
||||
gitea_host: str,
|
||||
gitea_org: str,
|
||||
username: str,
|
||||
token: str,
|
||||
) -> str:
|
||||
"""Build the PIP_EXTRA_INDEX_URL for the Gitea PyPI registry.
|
||||
|
||||
Returns a URL of the form:
|
||||
https://<user>:<token>@<host>/api/packages/<org>/pypi/simple/
|
||||
"""
|
||||
return f"https://{username}:{token}@{gitea_host}/api/packages/{gitea_org}/pypi/simple/"
|
||||
|
||||
|
||||
def _install_in_image(
|
||||
venv_link: str,
|
||||
opt_venv: str,
|
||||
extras: str,
|
||||
gitea_host: str,
|
||||
gitea_org: str,
|
||||
) -> None:
|
||||
"""Link /opt/venv to .venv, activate it, and pip install the project.
|
||||
|
||||
Sets ``PIP_EXTRA_INDEX_URL`` when ``CI_GITEA_TOKEN`` is available so
|
||||
that private packages from the Gitea PyPI registry can be installed.
|
||||
"""
|
||||
# Symlink /opt/venv → .venv
|
||||
link = Path(venv_link)
|
||||
if link.exists() or link.is_symlink():
|
||||
link.unlink()
|
||||
link.symlink_to(opt_venv)
|
||||
|
||||
# Build pip install command
|
||||
spec = f".[{extras}]" if extras else "."
|
||||
pip_bin = str(Path(venv_link) / "bin" / "pip")
|
||||
cmd = [pip_bin, "install", "--no-cache-dir", "-e", spec]
|
||||
|
||||
env = os.environ.copy()
|
||||
token = env.get("CI_GITEA_TOKEN", "")
|
||||
if token:
|
||||
username = env.get("CI_GITEA_USERNAME", "emil")
|
||||
env["PIP_EXTRA_INDEX_URL"] = _build_pip_extra_index_url(
|
||||
gitea_host,
|
||||
gitea_org,
|
||||
username,
|
||||
token,
|
||||
)
|
||||
|
||||
click.echo(f"[setup-image] Linked {opt_venv}" + (f" with [{extras}]" if extras else "") + ".")
|
||||
subprocess.run(cmd, check=True, env=env) # nosec B603
|
||||
|
||||
|
||||
def _fallback_to_setup_ci() -> None:
|
||||
"""Fall back to ``make setup-ci`` when /opt/venv is not present."""
|
||||
click.echo(f"[setup-image] {OPT_VENV} not found — falling back to {FALLBACK_TARGET}")
|
||||
subprocess.run( # nosec B603, B607
|
||||
["make", FALLBACK_TARGET],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--venv",
|
||||
default=DEFAULT_VENV,
|
||||
show_default=True,
|
||||
help="Path to the local venv symlink (e.g. .venv).",
|
||||
)
|
||||
@click.option(
|
||||
"--opt-venv",
|
||||
default=OPT_VENV,
|
||||
show_default=True,
|
||||
help="Path to the pre-built venv inside the CI image.",
|
||||
)
|
||||
@click.option(
|
||||
"--extras",
|
||||
default="",
|
||||
help="Comma-separated dependency extras (e.g. 'ci,lint'). Empty for runtime only.",
|
||||
)
|
||||
@click.option(
|
||||
"--gitea-host",
|
||||
default="git.oblachno.oblachno.fyi",
|
||||
show_default=True,
|
||||
help="Gitea host for the PyPI registry.",
|
||||
)
|
||||
@click.option(
|
||||
"--gitea-org",
|
||||
default="oblachno-oss",
|
||||
show_default=True,
|
||||
help="Gitea org for the PyPI registry.",
|
||||
)
|
||||
def cli(
|
||||
venv: str,
|
||||
opt_venv: str,
|
||||
extras: str,
|
||||
gitea_host: str,
|
||||
gitea_org: str,
|
||||
) -> None:
|
||||
"""Set up the project using a pre-built CI image venv."""
|
||||
if Path(opt_venv).is_dir():
|
||||
_install_in_image(venv, opt_venv, extras, gitea_host, gitea_org)
|
||||
else:
|
||||
_fallback_to_setup_ci()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""OpenTofu operations: init and validate across directories.
|
||||
|
||||
Handles initialization and validation of OpenTofu configurations across
|
||||
multiple directories (modules + environments). Supports CI mode with
|
||||
``-backend=false`` to avoid state backend access.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.tools.tofu_ops init --env staging
|
||||
python3 -m devx.tools.tofu_ops validate
|
||||
python3 -m devx.tools.tofu_ops validate --ci
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess # nosec B404
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
DEFAULT_ENV_DIRS = ["tofu/environments/{env}", "tofu/environments/dns"]
|
||||
DEFAULT_VALIDATE_DIRS = [
|
||||
"tofu/modules/hetzner-vm",
|
||||
"tofu/modules/hetzner-network",
|
||||
"tofu/environments/staging",
|
||||
"tofu/environments/production",
|
||||
"tofu/environments/dns",
|
||||
]
|
||||
|
||||
|
||||
def _run_tofu(cmd: list[str], cwd: Path) -> None:
|
||||
"""Run a tofu command in the given directory, raising on failure."""
|
||||
click.echo(_(" -> {dir}", dir=cwd))
|
||||
result = subprocess.run( # nosec B603, B607
|
||||
cmd,
|
||||
cwd=str(cwd),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_("tofu command failed in {dir}: {error}", dir=cwd, error=result.stderr.strip()),
|
||||
)
|
||||
|
||||
|
||||
def tofu_init(env: str, root: str = ".", extra_dirs: list[str] | None = None) -> None:
|
||||
"""Run ``tofu init`` in the environment directory and DNS directory.
|
||||
|
||||
Args:
|
||||
env: Environment name (e.g. staging, production).
|
||||
root: Repository root directory.
|
||||
extra_dirs: Additional directory patterns to initialize.
|
||||
"""
|
||||
root_path = Path(root)
|
||||
dirs = [d.format(env=env) for d in (extra_dirs or DEFAULT_ENV_DIRS)]
|
||||
for dir_pattern in dirs:
|
||||
dir_path = root_path / dir_pattern
|
||||
if dir_path.is_dir():
|
||||
click.echo(_("[tofu-init] Initializing {dir}...", dir=dir_path))
|
||||
_run_tofu(["tofu", "init"], dir_path)
|
||||
click.echo(_("[tofu-init] Done."))
|
||||
|
||||
|
||||
def tofu_validate(
|
||||
root: str = ".",
|
||||
dirs: list[str] | None = None,
|
||||
ci: bool = False,
|
||||
) -> None:
|
||||
"""Run ``tofu validate`` in all OpenTofu directories.
|
||||
|
||||
In CI mode, runs ``tofu init -backend=false`` before validate to avoid
|
||||
state backend access.
|
||||
|
||||
Args:
|
||||
root: Repository root directory.
|
||||
dirs: List of directory paths to validate (relative to root).
|
||||
ci: If True, use CI mode with -backend=false.
|
||||
"""
|
||||
root_path = Path(root)
|
||||
target_dirs = dirs or DEFAULT_VALIDATE_DIRS
|
||||
mode = "ci" if ci else "validate"
|
||||
click.echo(_("[tofu-{mode}] Validating OpenTofu configurations...", mode=mode))
|
||||
for dir_rel in target_dirs:
|
||||
dir_path = root_path / dir_rel
|
||||
if not dir_path.is_dir():
|
||||
continue
|
||||
if ci:
|
||||
_run_tofu(["tofu", "init", "-backend=false", "-input=false"], dir_path)
|
||||
_run_tofu(["tofu", "validate"], dir_path)
|
||||
click.echo(_("[tofu-{mode}] All configurations valid.", mode=mode))
|
||||
|
||||
|
||||
@click.group()
|
||||
def cli() -> None:
|
||||
"""OpenTofu operations."""
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--env", required=True, help="Environment name (staging, production).")
|
||||
@click.option("--root", default=".", help="Repository root directory.")
|
||||
def init(env: str, root: str) -> None:
|
||||
"""Initialize OpenTofu in an environment."""
|
||||
tofu_init(env, root)
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--root", default=".", help="Repository root directory.")
|
||||
@click.option("--ci", is_flag=True, default=False, help="CI mode: use -backend=false.")
|
||||
def validate(root: str, ci: bool) -> None:
|
||||
"""Validate OpenTofu configurations."""
|
||||
tofu_validate(root, ci=ci)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -3094,5 +3094,189 @@
|
||||
"pl": " - {count} standard labels verified",
|
||||
"ru": " - {count} standard labels verified",
|
||||
"zh": " - {count} standard labels verified"
|
||||
},
|
||||
"[check-deps] Virtualenv .venv ready (Python {version}).": {
|
||||
"en": "[check-deps] Virtualenv .venv ready (Python {version}).",
|
||||
"bg": "[check-deps] Виртуална среда .venv готова (Python {version}).",
|
||||
"de": "[check-deps] Virtuelle Umgebung .venv bereit (Python {version}).",
|
||||
"pl": "[check-deps] Środowisko wirtualne .venv gotowe (Python {version}).",
|
||||
"ru": "[check-deps] Виртуальное окружение .venv готово (Python {version}).",
|
||||
"zh": "[check-deps] 虚拟环境 .venv 已就绪 (Python {version})。"
|
||||
},
|
||||
"{level}: {tool} not found.{hint}": {
|
||||
"en": "{level}: {tool} not found.{hint}",
|
||||
"bg": "{level}: {tool} не е намерен.{hint}",
|
||||
"de": "{level}: {tool} nicht gefunden.{hint}",
|
||||
"pl": "{level}: {tool} nie znaleziono.{hint}",
|
||||
"ru": "{level}: {tool} не найден.{hint}",
|
||||
"zh": "{level}: 未找到 {tool}。{hint}"
|
||||
},
|
||||
"WARN: Could not determine Python version in .venv.": {
|
||||
"en": "WARN: Could not determine Python version in .venv.",
|
||||
"bg": "ПРЕДУПРЕЖДЕНИЕ: Не може да се определи версията на Python в .venv.",
|
||||
"de": "WARNUNG: Python-Version in .venv konnte nicht bestimmt werden.",
|
||||
"pl": "OSTRZEŻENIE: Nie można określić wersji Python w .venv.",
|
||||
"ru": "ПРЕДУПРЕЖДЕНИЕ: Не удалось определить версию Python в .venv.",
|
||||
"zh": "警告: 无法确定 .venv 中的 Python 版本。"
|
||||
},
|
||||
"WARN: Could not parse Python version '{version}'.": {
|
||||
"en": "WARN: Could not parse Python version '{version}'.",
|
||||
"bg": "ПРЕДУПРЕЖДЕНИЕ: Не може да се анализира версията на Python '{version}'.",
|
||||
"de": "WARNUNG: Python-Version '{version}' konnte nicht analysiert werden.",
|
||||
"pl": "OSTRZEŻENIE: Nie można przeanalizować wersji Python '{version}'.",
|
||||
"ru": "ПРЕДУПРЕЖДЕНИЕ: Не удалось разобрать версию Python '{version}'.",
|
||||
"zh": "警告: 无法解析 Python 版本 '{version}'。"
|
||||
},
|
||||
"WARN: .venv not found. Run 'make setup-venv' to create it.": {
|
||||
"en": "WARN: .venv not found. Run 'make setup-venv' to create it.",
|
||||
"bg": "ПРЕДУПРЕЖДЕНИЕ: .venv не е намерен. Изпълнете 'make setup-venv' за създаване.",
|
||||
"de": "WARNUNG: .venv nicht gefunden. Führen Sie 'make setup-venv' aus, um es zu erstellen.",
|
||||
"pl": "OSTRZEŻENIE: Nie znaleziono .venv. Uruchom 'make setup-venv', aby utworzyć.",
|
||||
"ru": "ПРЕДУПРЕЖДЕНИЕ: .venv не найден. Выполните 'make setup-venv' для создания.",
|
||||
"zh": "警告: 未找到 .venv。运行 'make setup-venv' 来创建。"
|
||||
},
|
||||
"[docker-login] Logged in to {registry}.": {
|
||||
"en": "[docker-login] Logged in to {registry}.",
|
||||
"bg": "[docker-login] Влязъл в {registry}.",
|
||||
"de": "[docker-login] Angemeldet bei {registry}.",
|
||||
"pl": "[docker-login] Zalogowano do {registry}.",
|
||||
"ru": "[docker-login] Выполнен вход в {registry}.",
|
||||
"zh": "[docker-login] 已登录到 {registry}。"
|
||||
},
|
||||
"[docker-login] Login to {registry} failed (continuing).": {
|
||||
"en": "[docker-login] Login to {registry} failed (continuing).",
|
||||
"bg": "[docker-login] Влизането в {registry} не успя (продължава).",
|
||||
"de": "[docker-login] Anmeldung bei {registry} fehlgeschlagen (wird fortgesetzt).",
|
||||
"pl": "[docker-login] Logowanie do {registry} nie powiodło się (kontynuowanie).",
|
||||
"ru": "[docker-login] Ошибка входа в {registry} (продолжаем).",
|
||||
"zh": "[docker-login] 登录 {registry} 失败(继续)。"
|
||||
},
|
||||
"[docker-login] Skipping {registry} (token {env} not set).": {
|
||||
"en": "[docker-login] Skipping {registry} (token {env} not set).",
|
||||
"bg": "[docker-login] Пропускане на {registry} (токен {env} не е зададен).",
|
||||
"de": "[docker-login] {registry} übersprungen (Token {env} nicht gesetzt).",
|
||||
"pl": "[docker-login] Pomijanie {registry} (token {env} nie ustawiony).",
|
||||
"ru": "[docker-login] Пропуск {registry} (токен {env} не задан).",
|
||||
"zh": "[docker-login] 跳过 {registry}(未设置令牌 {env})。"
|
||||
},
|
||||
"{env} is not set. Set it in your .env file.": {
|
||||
"en": "{env} is not set. Set it in your .env file.",
|
||||
"bg": "{env} не е зададен. Задайте го във вашия .env файл.",
|
||||
"de": "{env} ist nicht gesetzt. Setzen Sie es in Ihrer .env-Datei.",
|
||||
"pl": "{env} nie jest ustawiony. Ustaw go w pliku .env.",
|
||||
"ru": "{env} не задан. Установите его в файле .env.",
|
||||
"zh": "{env} 未设置。请在 .env 文件中设置。"
|
||||
},
|
||||
"{env} is not set. Set it in your .env file or pass it as an environment variable.": {
|
||||
"en": "{env} is not set. Set it in your .env file or pass it as an environment variable.",
|
||||
"bg": "{env} не е зададен. Задайте го във вашия .env файл или го подайте като променлива на средата.",
|
||||
"de": "{env} ist nicht gesetzt. Setzen Sie es in Ihrer .env-Datei oder übergeben Sie es als Umgebungsvariable.",
|
||||
"pl": "{env} nie jest ustawiony. Ustaw go w pliku .env lub przekaż jako zmienną środowiskową.",
|
||||
"ru": "{env} не задан. Установите его в файле .env или передайте как переменную окружения.",
|
||||
"zh": "{env} 未设置。请在 .env 文件中设置或作为环境变量传递。"
|
||||
},
|
||||
"Login to {registry} failed: {error}": {
|
||||
"en": "Login to {registry} failed: {error}",
|
||||
"bg": "Влизането в {registry} не успя: {error}",
|
||||
"de": "Anmeldung bei {registry} fehlgeschlagen: {error}",
|
||||
"pl": "Logowanie do {registry} nie powiodło się: {error}",
|
||||
"ru": "Ошибка входа в {registry}: {error}",
|
||||
"zh": "登录 {registry} 失败: {error}"
|
||||
},
|
||||
"tofu command failed in {dir}: {error}": {
|
||||
"en": "tofu command failed in {dir}: {error}",
|
||||
"bg": "командата tofu не успя в {dir}: {error}",
|
||||
"de": "tofu-Befehl fehlgeschlagen in {dir}: {error}",
|
||||
"pl": "polecenie tofu nie powiodło się w {dir}: {error}",
|
||||
"ru": "команда tofu не удалась в {dir}: {error}",
|
||||
"zh": "tofu 命令在 {dir} 中失败: {error}"
|
||||
},
|
||||
"WARN: .venv has Python {version}, but >={req} is required.": {
|
||||
"en": "WARN: .venv has Python {version}, but >={req} is required.",
|
||||
"bg": "ПРЕДУПРЕЖДЕНИЕ: .venv има Python {version}, но се изисква >={req}.",
|
||||
"de": "WARNUNG: .venv hat Python {version}, aber >={req} ist erforderlich.",
|
||||
"pl": "OSTRZEŻENIE: .venv ma Python {version}, ale wymagane jest >={req}.",
|
||||
"ru": "ПРЕДУПРЕЖДЕНИЕ: в .venv установлен Python {version}, но требуется >={req}.",
|
||||
"zh": "警告: .venv 的 Python 版本为 {version},但要求 >={req}。"
|
||||
},
|
||||
" -> {dir}": {
|
||||
"en": " -> {dir}",
|
||||
"bg": " -> {dir}",
|
||||
"de": " -> {dir}",
|
||||
"pl": " -> {dir}",
|
||||
"ru": " -> {dir}",
|
||||
"zh": " -> {dir}"
|
||||
},
|
||||
"[tofu-init] Initializing {dir}...": {
|
||||
"en": "[tofu-init] Initializing {dir}...",
|
||||
"bg": "[tofu-init] Инициализиране на {dir}...",
|
||||
"de": "[tofu-init] Initialisiere {dir}...",
|
||||
"pl": "[tofu-init] Inicjalizacja {dir}...",
|
||||
"ru": "[tofu-init] Инициализация {dir}...",
|
||||
"zh": "[tofu-init] 正在初始化 {dir}..."
|
||||
},
|
||||
"[tofu-init] Done.": {
|
||||
"en": "[tofu-init] Done.",
|
||||
"bg": "[tofu-init] Готово.",
|
||||
"de": "[tofu-init] Fertig.",
|
||||
"pl": "[tofu-init] Gotowe.",
|
||||
"ru": "[tofu-init] Готово.",
|
||||
"zh": "[tofu-init] 完成。"
|
||||
},
|
||||
"[tofu-{mode}] Validating OpenTofu configurations...": {
|
||||
"en": "[tofu-{mode}] Validating OpenTofu configurations...",
|
||||
"bg": "[tofu-{mode}] Проверка на OpenTofu конфигурациите...",
|
||||
"de": "[tofu-{mode}] Validiere OpenTofu-Konfigurationen...",
|
||||
"pl": "[tofu-{mode}] Sprawdzanie konfiguracji OpenTofu...",
|
||||
"ru": "[tofu-{mode}] Проверка конфигураций OpenTofu...",
|
||||
"zh": "[tofu-{mode}] 正在验证 OpenTofu 配置..."
|
||||
},
|
||||
"[tofu-{mode}] All configurations valid.": {
|
||||
"en": "[tofu-{mode}] All configurations valid.",
|
||||
"bg": "[tofu-{mode}] Всички конфигурации са валидни.",
|
||||
"de": "[tofu-{mode}] Alle Konfigurationen gültig.",
|
||||
"pl": "[tofu-{mode}] Wszystkie konfiguracje są poprawne.",
|
||||
"ru": "[tofu-{mode}] Все конфигурации валидны.",
|
||||
"zh": "[tofu-{mode}] 所有配置有效。"
|
||||
},
|
||||
"[check-deps] Verifying tools...": {
|
||||
"en": "[check-deps] Verifying tools...",
|
||||
"bg": "[check-deps] Проверка на инструментите...",
|
||||
"de": "[check-deps] Werkzeuge werden überprüft...",
|
||||
"pl": "[check-deps] Sprawdzanie narzędzi...",
|
||||
"ru": "[check-deps] Проверка инструментов...",
|
||||
"zh": "[check-deps] 正在验证工具..."
|
||||
},
|
||||
" {tool}: found at {path}": {
|
||||
"en": " {tool}: found at {path}",
|
||||
"bg": " {tool}: намерен на {path}",
|
||||
"de": " {tool}: gefunden unter {path}",
|
||||
"pl": " {tool}: znaleziono w {path}",
|
||||
"ru": " {tool}: найден в {path}",
|
||||
"zh": " {tool}: 在 {path} 找到"
|
||||
},
|
||||
" Run 'make install-checkmake' to install the Makefile linter.": {
|
||||
"en": " Run 'make install-checkmake' to install the Makefile linter.",
|
||||
"bg": " Изпълнете 'make install-checkmake' за инсталиране на Makefile линтера.",
|
||||
"de": " Führen Sie 'make install-checkmake' aus, um den Makefile-Linter zu installieren.",
|
||||
"pl": " Uruchom 'make install-checkmake', aby zainstalować linter Makefile.",
|
||||
"ru": " Выполните 'make install-checkmake' для установки линтера Makefile.",
|
||||
"zh": " 运行 'make install-checkmake' 来安装 Makefile 检查器。"
|
||||
},
|
||||
"Required tools missing.": {
|
||||
"en": "Required tools missing.",
|
||||
"bg": "Липсват задължителни инструменти.",
|
||||
"de": "Erforderliche Werkzeuge fehlen.",
|
||||
"pl": "Brak wymaganych narzędzi.",
|
||||
"ru": "Отсутствуют обязательные инструменты.",
|
||||
"zh": "缺少必需的工具。"
|
||||
},
|
||||
"[check-deps] All core tools present.": {
|
||||
"en": "[check-deps] All core tools present.",
|
||||
"bg": "[check-deps] Всички основни инструменти са налични.",
|
||||
"de": "[check-deps] Alle Kernwerkzeuge vorhanden.",
|
||||
"pl": "[check-deps] Wszystkie podstawowe narzędzia są dostępne.",
|
||||
"ru": "[check-deps] Все основные инструменты доступны.",
|
||||
"zh": "[check-deps] 所有核心工具均已就绪。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Unit tests for devx.tools.check_deps."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.check_deps import (
|
||||
_check_python_version,
|
||||
_check_tool,
|
||||
cli,
|
||||
)
|
||||
|
||||
|
||||
class TestCheckTool:
|
||||
@patch("devx.tools.check_deps.shutil.which", return_value="/usr/bin/tofu")
|
||||
def test_found(self, mock_which: MagicMock) -> None:
|
||||
assert _check_tool("tofu") is True
|
||||
|
||||
@patch("devx.tools.check_deps.shutil.which", return_value=None)
|
||||
def test_not_found_required(self, mock_which: MagicMock) -> None:
|
||||
assert _check_tool("tofu") is False
|
||||
|
||||
@patch("devx.tools.check_deps.shutil.which", return_value=None)
|
||||
def test_not_found_optional(self, mock_which: MagicMock) -> None:
|
||||
assert _check_tool("checkmake", optional=True) is False
|
||||
|
||||
|
||||
class TestCheckPythonVersion:
|
||||
@patch("devx.tools.check_deps.subprocess.run")
|
||||
def test_valid_version(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
venv_bin = tmp_path / "bin"
|
||||
venv_bin.mkdir()
|
||||
(venv_bin / "python").touch()
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="Python 3.12.3\n", stderr="")
|
||||
_check_python_version(venv_bin)
|
||||
|
||||
@patch("devx.tools.check_deps.subprocess.run")
|
||||
def test_old_version(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
venv_bin = tmp_path / "bin"
|
||||
venv_bin.mkdir()
|
||||
(venv_bin / "python").touch()
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="Python 3.11.0\n", stderr="")
|
||||
_check_python_version(venv_bin)
|
||||
|
||||
def test_no_venv(self, tmp_path: Path) -> None:
|
||||
venv_bin = tmp_path / "bin"
|
||||
_check_python_version(venv_bin)
|
||||
|
||||
@patch("devx.tools.check_deps.subprocess.run")
|
||||
def test_command_fails(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
venv_bin = tmp_path / "bin"
|
||||
venv_bin.mkdir()
|
||||
(venv_bin / "python").touch()
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
|
||||
_check_python_version(venv_bin)
|
||||
|
||||
@patch("devx.tools.check_deps.subprocess.run")
|
||||
def test_unparseable_version(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
venv_bin = tmp_path / "bin"
|
||||
venv_bin.mkdir()
|
||||
(venv_bin / "python").touch()
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="garbage\n", stderr="")
|
||||
_check_python_version(venv_bin)
|
||||
|
||||
|
||||
class TestCli:
|
||||
@patch("devx.tools.check_deps._check_python_version")
|
||||
@patch("devx.tools.check_deps._check_tool")
|
||||
def test_all_present(self, mock_check: MagicMock, mock_py: MagicMock) -> None:
|
||||
mock_check.return_value = True
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
assert "All core tools present" in result.output
|
||||
|
||||
@patch("devx.tools.check_deps._check_python_version")
|
||||
@patch("devx.tools.check_deps._check_tool")
|
||||
def test_missing_required(self, mock_check: MagicMock, mock_py: MagicMock) -> None:
|
||||
mock_check.side_effect = lambda name, optional=False: name != "tofu"
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code != 0
|
||||
|
||||
@patch("devx.tools.check_deps._check_python_version")
|
||||
@patch("devx.tools.check_deps._check_tool")
|
||||
def test_missing_optional_with_fallback(self, mock_check: MagicMock, mock_py: MagicMock, tmp_path: Path) -> None:
|
||||
checkmake_bin = tmp_path / "checkmake"
|
||||
checkmake_bin.touch()
|
||||
|
||||
def _side(name: str, optional: bool = False) -> bool:
|
||||
return name != "checkmake"
|
||||
|
||||
mock_check.side_effect = _side
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--checkmake-bin", str(checkmake_bin)])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch("devx.tools.check_deps._check_python_version")
|
||||
@patch("devx.tools.check_deps._check_tool")
|
||||
def test_missing_optional_no_fallback(self, mock_check: MagicMock, mock_py: MagicMock) -> None:
|
||||
def _side(name: str, optional: bool = False) -> bool:
|
||||
return name != "checkmake"
|
||||
|
||||
mock_check.side_effect = _side
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Unit tests for devx.tools.docker_login."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.docker_login import (
|
||||
_resolve_credentials,
|
||||
cli,
|
||||
docker_login,
|
||||
)
|
||||
|
||||
|
||||
class TestDockerLogin:
|
||||
@patch("devx.tools.docker_login.subprocess.run")
|
||||
def test_success(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
assert docker_login("registry.io", "user", "tok") is True
|
||||
|
||||
@patch("devx.tools.docker_login.subprocess.run")
|
||||
def test_failure_raises(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="auth failed")
|
||||
with pytest.raises(Exception, match="auth failed"):
|
||||
docker_login("registry.io", "user", "tok")
|
||||
|
||||
@patch("devx.tools.docker_login.subprocess.run")
|
||||
def test_failure_suppressed(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="auth failed")
|
||||
assert docker_login("registry.io", "user", "tok", suppress_failure=True) is False
|
||||
|
||||
|
||||
class TestResolveCredentials:
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "CI_GITEA_USERNAME": "emil"}, clear=True)
|
||||
def test_both_set(self) -> None:
|
||||
user, token = _resolve_credentials("CI_GITEA_TOKEN", "CI_GITEA_USERNAME", None)
|
||||
assert user == "emil"
|
||||
assert token == "tok"
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
def test_token_only_with_default(self) -> None:
|
||||
user, token = _resolve_credentials("CI_GITEA_TOKEN", "CI_GITEA_USERNAME", "emil")
|
||||
assert user == "emil"
|
||||
assert token == "tok"
|
||||
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_no_token(self) -> None:
|
||||
user, token = _resolve_credentials("CI_GITEA_TOKEN", "CI_GITEA_USERNAME", "emil")
|
||||
assert user is None
|
||||
assert token is None
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
def test_no_username_no_default(self) -> None:
|
||||
user, token = _resolve_credentials("CI_GITEA_TOKEN", "CI_GITEA_USERNAME", None)
|
||||
assert user == ""
|
||||
assert token == "tok"
|
||||
|
||||
|
||||
class TestCli:
|
||||
@patch("devx.tools.docker_login.docker_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "CI_GITEA_USERNAME": "emil"}, clear=True)
|
||||
def test_required_login(self, mock_login: MagicMock) -> None:
|
||||
mock_login.return_value = True
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--registry", "reg.io", "--token-env", "CI_GITEA_TOKEN", "--username-env", "CI_GITEA_USERNAME"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_login.assert_called_once()
|
||||
|
||||
@patch("devx.tools.docker_login.docker_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
def test_default_username(self, mock_login: MagicMock) -> None:
|
||||
mock_login.return_value = True
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"--registry",
|
||||
"reg.io",
|
||||
"--token-env",
|
||||
"CI_GITEA_TOKEN",
|
||||
"--username-env",
|
||||
"CI_GITEA_USERNAME",
|
||||
"--default-username",
|
||||
"emil",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_login.assert_called_once_with("reg.io", "emil", "tok", suppress_failure=False)
|
||||
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_required_no_token_raises(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--registry", "reg.io", "--token-env", "CI_GITEA_TOKEN", "--username-env", "CI_GITEA_USERNAME"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_optional_no_token_skips(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"--registry",
|
||||
"reg.io",
|
||||
"--token-env",
|
||||
"CI_GITEA_TOKEN",
|
||||
"--username-env",
|
||||
"CI_GITEA_USERNAME",
|
||||
"--optional",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Skipping" in result.output
|
||||
|
||||
@patch("devx.tools.docker_login.docker_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
def test_no_username_raises(self, mock_login: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--registry", "reg.io", "--token-env", "CI_GITEA_TOKEN", "--username-env", "CI_GITEA_USERNAME"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
mock_login.assert_not_called()
|
||||
|
||||
@patch("devx.tools.docker_login.docker_login")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "CI_GITEA_USERNAME": "emil"}, clear=True)
|
||||
def test_suppress_failure(self, mock_login: MagicMock) -> None:
|
||||
mock_login.return_value = False
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"--registry",
|
||||
"reg.io",
|
||||
"--token-env",
|
||||
"CI_GITEA_TOKEN",
|
||||
"--username-env",
|
||||
"CI_GITEA_USERNAME",
|
||||
"--suppress-failure",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_login.assert_called_once_with("reg.io", "emil", "tok", suppress_failure=True)
|
||||
@@ -240,6 +240,35 @@ class TestInstallHadolint:
|
||||
assert (tmp_path / "hadolint").exists()
|
||||
|
||||
|
||||
class TestInstallTofu:
|
||||
def test_already_installed(self) -> None:
|
||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
||||
assert install_tools.install_tofu() is True
|
||||
|
||||
def test_install(self, tmp_path: Path) -> None:
|
||||
import io
|
||||
import tarfile
|
||||
|
||||
tarball_path = tmp_path / "archive.tar.gz"
|
||||
binary_content = b"fake tofu"
|
||||
with tarfile.open(tarball_path, "w:gz") as tar:
|
||||
info = tarfile.TarInfo(name="tofu")
|
||||
info.size = len(binary_content)
|
||||
tar.addfile(info, io.BytesIO(binary_content))
|
||||
|
||||
with patch.object(install_tools, "_is_installed", return_value=False):
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
with patch.object(platform, "system", return_value="Linux"):
|
||||
with patch.object(
|
||||
install_tools,
|
||||
"_download",
|
||||
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
|
||||
):
|
||||
assert install_tools.install_tofu() is True
|
||||
assert (tmp_path / "tofu").exists()
|
||||
|
||||
|
||||
class TestListTools:
|
||||
def test_list(self, tmp_path: Path) -> None:
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
@@ -274,6 +303,11 @@ class TestInstallTool:
|
||||
assert install_tools._install_tool("hadolint") is True
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_tofu(self) -> None:
|
||||
with patch.object(install_tools, "install_tofu", return_value=True) as mock:
|
||||
assert install_tools._install_tool("tofu") is True
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_unknown_tool(self) -> None:
|
||||
with pytest.raises(ClickException, match="Unknown tool"):
|
||||
install_tools._install_tool("unknown")
|
||||
@@ -292,7 +326,7 @@ class TestMain:
|
||||
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
|
||||
result = runner.invoke(install_tools.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert mock_install.call_count == 5
|
||||
assert mock_install.call_count == 6
|
||||
|
||||
def test_install_specific_tool(self) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Unit tests for devx.tools.setup_image."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.setup_image import (
|
||||
_build_pip_extra_index_url,
|
||||
_fallback_to_setup_ci,
|
||||
_install_in_image,
|
||||
cli,
|
||||
)
|
||||
|
||||
|
||||
class TestBuildPipExtraIndexUrl:
|
||||
def test_basic_url(self) -> None:
|
||||
url = _build_pip_extra_index_url(
|
||||
"git.oblachno.oblachno.fyi",
|
||||
"oblachno-oss",
|
||||
"emil",
|
||||
"tok123",
|
||||
)
|
||||
assert url == "https://emil:tok123@git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple/"
|
||||
|
||||
def test_custom_host_org(self) -> None:
|
||||
url = _build_pip_extra_index_url(
|
||||
"gitea.example.com",
|
||||
"my-org",
|
||||
"user",
|
||||
"secret",
|
||||
)
|
||||
assert url == "https://user:secret@gitea.example.com/api/packages/my-org/pypi/simple/"
|
||||
|
||||
|
||||
class TestInstallInImage:
|
||||
@patch("devx.tools.setup_image.subprocess.run")
|
||||
@patch("devx.tools.setup_image.Path")
|
||||
def test_link_and_install_no_token(self, mock_path: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
venv_link = tmp_path / ".venv"
|
||||
mock_path.return_value.exists.return_value = False
|
||||
mock_path.return_value.is_symlink.return_value = False
|
||||
mock_path.return_value.symlink_to = MagicMock()
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
_install_in_image(str(venv_link), "/opt/venv", "", "host", "org")
|
||||
|
||||
mock_path.return_value.symlink_to.assert_called_once_with("/opt/venv")
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--no-cache-dir" in cmd
|
||||
assert "-e" in cmd
|
||||
assert "." in cmd
|
||||
# No extras → spec is "."
|
||||
assert ".[]" not in " ".join(cmd)
|
||||
|
||||
@patch("devx.tools.setup_image.subprocess.run")
|
||||
@patch("devx.tools.setup_image.Path")
|
||||
def test_link_and_install_with_extras(
|
||||
self,
|
||||
mock_path: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
venv_link = tmp_path / ".venv"
|
||||
mock_path.return_value.exists.return_value = False
|
||||
mock_path.return_value.is_symlink.return_value = False
|
||||
mock_path.return_value.symlink_to = MagicMock()
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
_install_in_image(str(venv_link), "/opt/venv", "ci,lint", "host", "org")
|
||||
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert ".[ci,lint]" in cmd
|
||||
|
||||
@patch("devx.tools.setup_image.subprocess.run")
|
||||
@patch("devx.tools.setup_image.Path")
|
||||
def test_install_with_token_sets_pip_extra_index_url(
|
||||
self,
|
||||
mock_path: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
venv_link = tmp_path / ".venv"
|
||||
mock_path.return_value.exists.return_value = False
|
||||
mock_path.return_value.is_symlink.return_value = False
|
||||
mock_path.return_value.symlink_to = MagicMock()
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"CI_GITEA_TOKEN": "tok123", "CI_GITEA_USERNAME": "emil"},
|
||||
clear=True,
|
||||
):
|
||||
_install_in_image(str(venv_link), "/opt/venv", "lint", "git.host", "org")
|
||||
|
||||
env = mock_run.call_args[1]["env"]
|
||||
assert "PIP_EXTRA_INDEX_URL" in env
|
||||
assert "emil:tok123@git.host" in env["PIP_EXTRA_INDEX_URL"]
|
||||
|
||||
@patch("devx.tools.setup_image.subprocess.run")
|
||||
@patch("devx.tools.setup_image.Path")
|
||||
def test_install_with_token_defaults_username(
|
||||
self,
|
||||
mock_path: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
venv_link = tmp_path / ".venv"
|
||||
mock_path.return_value.exists.return_value = False
|
||||
mock_path.return_value.is_symlink.return_value = False
|
||||
mock_path.return_value.symlink_to = MagicMock()
|
||||
|
||||
with patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True):
|
||||
_install_in_image(str(venv_link), "/opt/venv", "", "host", "org")
|
||||
|
||||
env = mock_run.call_args[1]["env"]
|
||||
assert "emil:tok123@host" in env["PIP_EXTRA_INDEX_URL"]
|
||||
|
||||
@patch("devx.tools.setup_image.subprocess.run")
|
||||
@patch("devx.tools.setup_image.Path")
|
||||
def test_install_removes_existing_link(
|
||||
self,
|
||||
mock_path: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
venv_link = tmp_path / ".venv"
|
||||
mock_path.return_value.exists.return_value = True
|
||||
mock_path.return_value.is_symlink.return_value = False
|
||||
mock_path.return_value.unlink = MagicMock()
|
||||
mock_path.return_value.symlink_to = MagicMock()
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
_install_in_image(str(venv_link), "/opt/venv", "", "host", "org")
|
||||
|
||||
mock_path.return_value.unlink.assert_called_once()
|
||||
|
||||
@patch("devx.tools.setup_image.subprocess.run")
|
||||
@patch("devx.tools.setup_image.Path")
|
||||
def test_install_removes_existing_symlink(
|
||||
self,
|
||||
mock_path: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
venv_link = tmp_path / ".venv"
|
||||
mock_path.return_value.exists.return_value = False
|
||||
mock_path.return_value.is_symlink.return_value = True
|
||||
mock_path.return_value.unlink = MagicMock()
|
||||
mock_path.return_value.symlink_to = MagicMock()
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
_install_in_image(str(venv_link), "/opt/venv", "", "host", "org")
|
||||
|
||||
mock_path.return_value.unlink.assert_called_once()
|
||||
|
||||
@patch("devx.tools.setup_image.subprocess.run")
|
||||
@patch("devx.tools.setup_image.Path")
|
||||
def test_install_failure_raises(self, mock_path: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
venv_link = tmp_path / ".venv"
|
||||
mock_path.return_value.exists.return_value = False
|
||||
mock_path.return_value.is_symlink.return_value = False
|
||||
mock_path.return_value.symlink_to = MagicMock()
|
||||
mock_run.side_effect = subprocess.CalledProcessError(1, ["pip"])
|
||||
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
_install_in_image(str(venv_link), "/opt/venv", "", "host", "org")
|
||||
|
||||
|
||||
class TestFallbackToSetupCi:
|
||||
@patch("devx.tools.setup_image.subprocess.run")
|
||||
def test_fallback_runs_make_setup_ci(self, mock_run: MagicMock) -> None:
|
||||
_fallback_to_setup_ci()
|
||||
mock_run.assert_called_once_with(["make", "setup-ci"], check=True)
|
||||
|
||||
@patch("devx.tools.setup_image.subprocess.run")
|
||||
def test_fallback_failure_raises(self, mock_run: MagicMock) -> None:
|
||||
mock_run.side_effect = subprocess.CalledProcessError(1, ["make"])
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
_fallback_to_setup_ci()
|
||||
|
||||
|
||||
class TestCli:
|
||||
@patch("devx.tools.setup_image._install_in_image")
|
||||
@patch("devx.tools.setup_image.Path")
|
||||
def test_cli_with_opt_venv_present(
|
||||
self,
|
||||
mock_path: MagicMock,
|
||||
mock_install: MagicMock,
|
||||
) -> None:
|
||||
mock_path.return_value.is_dir.return_value = True
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--extras", "ci,lint"])
|
||||
assert result.exit_code == 0
|
||||
mock_install.assert_called_once()
|
||||
|
||||
@patch("devx.tools.setup_image._fallback_to_setup_ci")
|
||||
@patch("devx.tools.setup_image.Path")
|
||||
def test_cli_falls_back_when_no_opt_venv(
|
||||
self,
|
||||
mock_path: MagicMock,
|
||||
mock_fallback: MagicMock,
|
||||
) -> None:
|
||||
mock_path.return_value.is_dir.return_value = False
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
mock_fallback.assert_called_once()
|
||||
|
||||
@patch("devx.tools.setup_image._install_in_image")
|
||||
@patch("devx.tools.setup_image.Path")
|
||||
def test_cli_default_values(
|
||||
self,
|
||||
mock_path: MagicMock,
|
||||
mock_install: MagicMock,
|
||||
) -> None:
|
||||
mock_path.return_value.is_dir.return_value = True
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
call_args = mock_install.call_args[0]
|
||||
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[4] == "oblachno-oss"
|
||||
|
||||
@patch("devx.tools.setup_image._install_in_image")
|
||||
@patch("devx.tools.setup_image.Path")
|
||||
def test_cli_custom_venv_and_gitea(
|
||||
self,
|
||||
mock_path: MagicMock,
|
||||
mock_install: MagicMock,
|
||||
) -> None:
|
||||
mock_path.return_value.is_dir.return_value = True
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
["--venv", ".custom-venv", "--gitea-host", "gitea.io", "--gitea-org", "myorg"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
call_args = mock_install.call_args[0]
|
||||
assert call_args[0] == ".custom-venv"
|
||||
assert call_args[3] == "gitea.io"
|
||||
assert call_args[4] == "myorg"
|
||||
|
||||
@patch("devx.tools.setup_image._install_in_image")
|
||||
@patch("devx.tools.setup_image.Path")
|
||||
def test_cli_with_extras(
|
||||
self,
|
||||
mock_path: MagicMock,
|
||||
mock_install: MagicMock,
|
||||
) -> None:
|
||||
mock_path.return_value.is_dir.return_value = True
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--extras", "lint"])
|
||||
assert result.exit_code == 0
|
||||
assert mock_install.call_args[0][2] == "lint"
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Unit tests for devx.tools.tofu_ops."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.tofu_ops import (
|
||||
_run_tofu,
|
||||
cli,
|
||||
tofu_init,
|
||||
tofu_validate,
|
||||
)
|
||||
|
||||
|
||||
class TestRunTofu:
|
||||
@patch("devx.tools.tofu_ops.subprocess.run")
|
||||
def test_success(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
_run_tofu(["tofu", "init"], tmp_path)
|
||||
mock_run.assert_called_once()
|
||||
|
||||
@patch("devx.tools.tofu_ops.subprocess.run")
|
||||
def test_failure_raises(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
|
||||
with pytest.raises(Exception, match="error"):
|
||||
_run_tofu(["tofu", "validate"], tmp_path)
|
||||
|
||||
|
||||
class TestTofuInit:
|
||||
@patch("devx.tools.tofu_ops._run_tofu")
|
||||
def test_init_existing_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
(tmp_path / "tofu/environments/staging").mkdir(parents=True)
|
||||
(tmp_path / "tofu/environments/dns").mkdir(parents=True)
|
||||
tofu_init("staging", root=str(tmp_path))
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
@patch("devx.tools.tofu_ops._run_tofu")
|
||||
def test_init_skips_missing_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
(tmp_path / "tofu/environments/staging").mkdir(parents=True)
|
||||
# dns dir doesn't exist
|
||||
tofu_init("staging", root=str(tmp_path))
|
||||
assert mock_run.call_count == 1
|
||||
|
||||
@patch("devx.tools.tofu_ops._run_tofu")
|
||||
def test_init_no_dirs_exist(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
tofu_init("staging", root=str(tmp_path))
|
||||
mock_run.assert_not_called()
|
||||
|
||||
@patch("devx.tools.tofu_ops._run_tofu")
|
||||
def test_init_custom_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
(tmp_path / "custom/dir").mkdir(parents=True)
|
||||
tofu_init("staging", root=str(tmp_path), extra_dirs=["custom/dir"])
|
||||
assert mock_run.call_count == 1
|
||||
|
||||
|
||||
class TestTofuValidate:
|
||||
@patch("devx.tools.tofu_ops._run_tofu")
|
||||
def test_validate_all_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
for d in [
|
||||
"tofu/modules/hetzner-vm",
|
||||
"tofu/modules/hetzner-network",
|
||||
"tofu/environments/staging",
|
||||
"tofu/environments/production",
|
||||
"tofu/environments/dns",
|
||||
]:
|
||||
(tmp_path / d).mkdir(parents=True)
|
||||
tofu_validate(root=str(tmp_path))
|
||||
assert mock_run.call_count == 5
|
||||
|
||||
@patch("devx.tools.tofu_ops._run_tofu")
|
||||
def test_validate_skips_missing(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
(tmp_path / "tofu/environments/staging").mkdir(parents=True)
|
||||
tofu_validate(root=str(tmp_path))
|
||||
assert mock_run.call_count == 1
|
||||
|
||||
@patch("devx.tools.tofu_ops._run_tofu")
|
||||
def test_validate_ci_mode(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
(tmp_path / "tofu/environments/staging").mkdir(parents=True)
|
||||
tofu_validate(root=str(tmp_path), ci=True)
|
||||
# CI mode runs init + validate = 2 calls per dir
|
||||
assert mock_run.call_count == 2
|
||||
first_call = mock_run.call_args_list[0][0][0]
|
||||
assert "init" in first_call
|
||||
assert "-backend=false" in first_call
|
||||
|
||||
@patch("devx.tools.tofu_ops._run_tofu")
|
||||
def test_validate_custom_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||
(tmp_path / "custom").mkdir()
|
||||
tofu_validate(root=str(tmp_path), dirs=["custom"])
|
||||
assert mock_run.call_count == 1
|
||||
|
||||
|
||||
class TestCli:
|
||||
@patch("devx.tools.tofu_ops.tofu_init")
|
||||
def test_init_command(self, mock_init: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["init", "--env", "staging"])
|
||||
assert result.exit_code == 0
|
||||
mock_init.assert_called_once_with("staging", ".")
|
||||
|
||||
@patch("devx.tools.tofu_ops.tofu_validate")
|
||||
def test_validate_command(self, mock_validate: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["validate"])
|
||||
assert result.exit_code == 0
|
||||
mock_validate.assert_called_once_with(".", ci=False)
|
||||
|
||||
@patch("devx.tools.tofu_ops.tofu_validate")
|
||||
def test_validate_ci_command(self, mock_validate: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["validate", "--ci"])
|
||||
assert result.exit_code == 0
|
||||
mock_validate.assert_called_once_with(".", ci=True)
|
||||
Reference in New Issue
Block a user