Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9513add63 | ||
|
|
3d4b4940ff | ||
|
|
d2aa4c6298 | ||
|
|
9cdbdde6da | ||
|
|
bd4530094e | ||
|
|
4d0aa326a1 |
@@ -32,6 +32,11 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||||
|
credentials:
|
||||||
|
username: ${{ vars.CI_GITEA_USERNAME }}
|
||||||
|
password: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
outputs:
|
outputs:
|
||||||
is-release: ${{ steps.check.outputs.is-release }}
|
is-release: ${{ steps.check.outputs.is-release }}
|
||||||
@@ -115,6 +120,11 @@ jobs:
|
|||||||
needs: [build-and-push]
|
needs: [build-and-push]
|
||||||
if: always() && needs.build-and-push.result == 'success'
|
if: always() && needs.build-and-push.result == 'success'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||||
|
credentials:
|
||||||
|
username: ${{ vars.CI_GITEA_USERNAME }}
|
||||||
|
password: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|||||||
+11
-3
@@ -18,7 +18,11 @@ jobs:
|
|||||||
# Saves ~4x checkout+setup overhead vs 5 separate jobs.
|
# Saves ~4x checkout+setup overhead vs 5 separate jobs.
|
||||||
validate:
|
validate:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
container:
|
||||||
|
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||||
|
credentials:
|
||||||
|
username: ${{ vars.CI_GITEA_USERNAME }}
|
||||||
|
password: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -46,7 +50,7 @@ jobs:
|
|||||||
- name: Check unit test speed
|
- name: Check unit test speed
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate 2>/dev/null || true
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
python3 -m devx.tools.check_test_speed --max-seconds 8 --max-single-seconds 0.5
|
python3 -m devx.tools.check_test_speed --max-seconds 15 --max-single-seconds 0.5
|
||||||
- name: Documentation gate (coverage + stale refs + lint + version refs + prose)
|
- name: Documentation gate (coverage + stale refs + lint + version refs + prose)
|
||||||
env:
|
env:
|
||||||
DEVX_DOC_COVERAGE_STRICT: "1"
|
DEVX_DOC_COVERAGE_STRICT: "1"
|
||||||
@@ -140,7 +144,11 @@ jobs:
|
|||||||
github.event_name == 'pull_request' &&
|
github.event_name == 'pull_request' &&
|
||||||
needs.validate.result == 'success'
|
needs.validate.result == 'success'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
container:
|
||||||
|
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||||
|
credentials:
|
||||||
|
username: ${{ vars.CI_GITEA_USERNAME }}
|
||||||
|
password: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
detect-and-configure:
|
detect-and-configure:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
container:
|
||||||
|
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||||
|
credentials:
|
||||||
|
username: ${{ vars.CI_GITEA_USERNAME }}
|
||||||
|
password: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
@@ -99,7 +103,11 @@ jobs:
|
|||||||
needs: [detect-and-configure]
|
needs: [detect-and-configure]
|
||||||
if: always() && needs.detect-and-configure.result == 'success'
|
if: always() && needs.detect-and-configure.result == 'success'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
container:
|
||||||
|
image: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||||
|
credentials:
|
||||||
|
username: ${{ vars.CI_GITEA_USERNAME }}
|
||||||
|
password: ${{ secrets.CI_GITEA_API_TOKEN }}
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
outputs:
|
outputs:
|
||||||
tag: ${{ steps.release-tag.outputs.tag }}
|
tag: ${{ steps.release-tag.outputs.tag }}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ repos:
|
|||||||
|
|
||||||
- id: check-test-speed
|
- id: check-test-speed
|
||||||
name: unit test speed check
|
name: unit test speed check
|
||||||
entry: .venv/bin/python -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5
|
entry: .venv/bin/python -m devx.tools.check_test_speed --max-seconds 15 --max-single-seconds 0.5
|
||||||
language: system
|
language: system
|
||||||
types: [python]
|
types: [python]
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "Use 'AM' or 'PM' (preceded by a space)."
|
||||||
|
link: "https://developers.google.com/style/word-list"
|
||||||
|
level: error
|
||||||
|
nonword: true
|
||||||
|
tokens:
|
||||||
|
- '\d{1,2}[AP]M\b'
|
||||||
|
- '\d{1,2} ?[ap]m\b'
|
||||||
|
- '\d{1,2} ?[aApP]\.[mM]\.'
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
extends: conditional
|
||||||
|
message: "Spell out '%s', if it's unfamiliar to the audience."
|
||||||
|
link: 'https://developers.google.com/style/abbreviations'
|
||||||
|
level: suggestion
|
||||||
|
ignorecase: false
|
||||||
|
# Ensures that the existence of 'first' implies the existence of 'second'.
|
||||||
|
first: '\b([A-Z]{3,5})\b'
|
||||||
|
second: '(?:\b[A-Z][a-z]+ )+\(([A-Z]{3,5})\)'
|
||||||
|
# ... with the exception of these:
|
||||||
|
exceptions:
|
||||||
|
- API
|
||||||
|
- ASP
|
||||||
|
- CLI
|
||||||
|
- CPU
|
||||||
|
- CSS
|
||||||
|
- CSV
|
||||||
|
- DEBUG
|
||||||
|
- DOM
|
||||||
|
- DPI
|
||||||
|
- FAQ
|
||||||
|
- GCC
|
||||||
|
- GDB
|
||||||
|
- GET
|
||||||
|
- GPU
|
||||||
|
- GTK
|
||||||
|
- GUI
|
||||||
|
- HTML
|
||||||
|
- HTTP
|
||||||
|
- HTTPS
|
||||||
|
- IDE
|
||||||
|
- JAR
|
||||||
|
- JSON
|
||||||
|
- JSX
|
||||||
|
- LESS
|
||||||
|
- LLDB
|
||||||
|
- NET
|
||||||
|
- NOTE
|
||||||
|
- NVDA
|
||||||
|
- OSS
|
||||||
|
- PATH
|
||||||
|
- PDF
|
||||||
|
- PHP
|
||||||
|
- POST
|
||||||
|
- RAM
|
||||||
|
- REPL
|
||||||
|
- RSA
|
||||||
|
- SCM
|
||||||
|
- SCSS
|
||||||
|
- SDK
|
||||||
|
- SQL
|
||||||
|
- SSH
|
||||||
|
- SSL
|
||||||
|
- SVG
|
||||||
|
- TBD
|
||||||
|
- TCP
|
||||||
|
- TODO
|
||||||
|
- URI
|
||||||
|
- URL
|
||||||
|
- USB
|
||||||
|
- UTF
|
||||||
|
- XML
|
||||||
|
- XSS
|
||||||
|
- YAML
|
||||||
|
- ZIP
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "Don't attribute human qualities to software or hardware ('%s')."
|
||||||
|
link: https://developers.google.com/style/anthropomorphism
|
||||||
|
level: suggestion
|
||||||
|
ignorecase: true
|
||||||
|
# Limited to the two verbs the guide itself names. Broader lists (wants, knows,
|
||||||
|
# thinks) can't tell a software subject from a human one: on a 950-file corpus
|
||||||
|
# they produced 8 false positives ('the customer wants', 'your audience knows')
|
||||||
|
# for every 2 real ones.
|
||||||
|
tokens:
|
||||||
|
- sees
|
||||||
|
- tells
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "'%s' should be in lowercase."
|
||||||
|
link: 'https://developers.google.com/style/colons'
|
||||||
|
level: warning
|
||||||
|
scope: sentence
|
||||||
|
# The match is the word itself, not ': X', and `nonword` is off. Both are
|
||||||
|
# required for a project Vocab to work: Vale compares accept.txt entries
|
||||||
|
# against the matched text, and `nonword: true` opts out of that entirely.
|
||||||
|
# So a proper noun after a colon can be exempted by adding it to accept.txt.
|
||||||
|
# The guide's other exemption, notice labels, is handled by the lookbehinds;
|
||||||
|
# headings are already excluded by `scope: sentence`. See issue #20.
|
||||||
|
tokens:
|
||||||
|
- '(?<!Note: )(?<!Caution: )(?<!Warning: )(?<!Success: )(?<=:\s)[A-Z]\w+'
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
extends: substitution
|
||||||
|
message: "Use '%s' instead of '%s'."
|
||||||
|
link: 'https://developers.google.com/style/contractions'
|
||||||
|
level: suggestion
|
||||||
|
ignorecase: true
|
||||||
|
action:
|
||||||
|
name: replace
|
||||||
|
swap:
|
||||||
|
are not: aren't
|
||||||
|
cannot: can't
|
||||||
|
could not: couldn't
|
||||||
|
did not: didn't
|
||||||
|
do not: don't
|
||||||
|
does not: doesn't
|
||||||
|
has not: hasn't
|
||||||
|
have not: haven't
|
||||||
|
how is: how's
|
||||||
|
is not: isn't
|
||||||
|
it is: it's
|
||||||
|
should not: shouldn't
|
||||||
|
that is: that's
|
||||||
|
they are: they're
|
||||||
|
was not: wasn't
|
||||||
|
we are: we're
|
||||||
|
we have: we've
|
||||||
|
were not: weren't
|
||||||
|
what is: what's
|
||||||
|
when is: when's
|
||||||
|
where is: where's
|
||||||
|
will not: won't
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "Use 'July 31, 2016' format, not '%s'."
|
||||||
|
link: 'https://developers.google.com/style/dates-times'
|
||||||
|
ignorecase: true
|
||||||
|
level: error
|
||||||
|
nonword: true
|
||||||
|
tokens:
|
||||||
|
- '\d{1,2}(?:\.|/)\d{1,2}(?:\.|/)\d{4}'
|
||||||
|
- '\d{1,2} (?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?) \d{4}'
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "In general, don't use an ellipsis."
|
||||||
|
link: 'https://developers.google.com/style/ellipses'
|
||||||
|
nonword: true
|
||||||
|
level: warning
|
||||||
|
action:
|
||||||
|
name: remove
|
||||||
|
tokens:
|
||||||
|
- '\.\.\.'
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "Don't put a space before or after a dash."
|
||||||
|
link: "https://developers.google.com/style/dashes"
|
||||||
|
nonword: true
|
||||||
|
level: error
|
||||||
|
action:
|
||||||
|
name: edit
|
||||||
|
params:
|
||||||
|
- trim
|
||||||
|
- " "
|
||||||
|
tokens:
|
||||||
|
- '\s[—–]\s'
|
||||||
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "Avoid the unverifiable claim '%s'."
|
||||||
|
link: https://developers.google.com/style/excessive-claims
|
||||||
|
level: suggestion
|
||||||
|
ignorecase: true
|
||||||
|
# The guide also names 'never', 'always', and 'ensure', but in technical writing
|
||||||
|
# those are usually legitimate instructions ('never commit secrets') rather than
|
||||||
|
# product claims: they accounted for 125 of 142 hits on a 950-file corpus.
|
||||||
|
# 'best practices' is a fixed term, not a superlative.
|
||||||
|
tokens:
|
||||||
|
- 'best(?! practices?)'
|
||||||
|
- simplest
|
||||||
|
- fastest
|
||||||
|
- guarantees?
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "Don't use exclamation points in text."
|
||||||
|
link: "https://developers.google.com/style/exclamation-points"
|
||||||
|
nonword: true
|
||||||
|
level: error
|
||||||
|
action:
|
||||||
|
name: edit
|
||||||
|
params:
|
||||||
|
- trim_right
|
||||||
|
- "!"
|
||||||
|
tokens:
|
||||||
|
- '\w+!(?:\s|$)'
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "Avoid first-person pronouns such as '%s'."
|
||||||
|
link: 'https://developers.google.com/style/pronouns#personal-pronouns'
|
||||||
|
ignorecase: true
|
||||||
|
level: warning
|
||||||
|
# The 'I' tokens use lookaround rather than consuming the surrounding
|
||||||
|
# whitespace. Matching ' I ' made the alert span cover both spaces, which shows
|
||||||
|
# up as a too-wide underline in editors, and read as "such as ' I '". Dropping
|
||||||
|
# `nonword` also lets a project Vocab apply, which it can't when set. See PR #50.
|
||||||
|
tokens:
|
||||||
|
- '(?<=^|\s)I(?=[\s,])'
|
||||||
|
- "\\bI'm\\b"
|
||||||
|
- \bme\b
|
||||||
|
- \bmy\b
|
||||||
|
- \bmine\b
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "Don't use '%s' as a gender-neutral pronoun."
|
||||||
|
link: 'https://developers.google.com/style/pronouns#gender-neutral-pronouns'
|
||||||
|
level: error
|
||||||
|
ignorecase: true
|
||||||
|
tokens:
|
||||||
|
- he/she
|
||||||
|
- s/he
|
||||||
|
- \(s\)he
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
extends: substitution
|
||||||
|
message: "Consider using '%s' instead of '%s'."
|
||||||
|
ignorecase: true
|
||||||
|
link: "https://developers.google.com/style/inclusive-documentation"
|
||||||
|
level: error
|
||||||
|
action:
|
||||||
|
name: replace
|
||||||
|
swap:
|
||||||
|
(?:alumna|alumnus): graduate
|
||||||
|
(?:alumnae|alumni): graduates
|
||||||
|
air(?:m[ae]n|wom[ae]n): pilot(s)
|
||||||
|
anchor(?:m[ae]n|wom[ae]n): anchor(s)
|
||||||
|
authoress: author
|
||||||
|
camera(?:m[ae]n|wom[ae]n): camera operator(s)
|
||||||
|
door(?:m[ae]|wom[ae]n): concierge(s)
|
||||||
|
draft(?:m[ae]n|wom[ae]n): drafter(s)
|
||||||
|
fire(?:m[ae]n|wom[ae]n): firefighter(s)
|
||||||
|
fisher(?:m[ae]n|wom[ae]n): fisher(s)
|
||||||
|
fresh(?:m[ae]n|wom[ae]n): first-year student(s)
|
||||||
|
garbage(?:m[ae]n|wom[ae]n): waste collector(s)
|
||||||
|
lady lawyer: lawyer
|
||||||
|
ladylike: courteous
|
||||||
|
mail(?:m[ae]n|wom[ae]n): mail carriers
|
||||||
|
man and wife: husband and wife
|
||||||
|
man enough: strong enough
|
||||||
|
mankind: human kind|humanity
|
||||||
|
manmade: manufactured
|
||||||
|
manpower: personnel
|
||||||
|
middle(?:m[ae]n|wom[ae]n): intermediary
|
||||||
|
news(?:m[ae]n|wom[ae]n): journalist(s)
|
||||||
|
ombuds(?:man|woman): ombuds
|
||||||
|
oneupmanship: upstaging
|
||||||
|
poetess: poet
|
||||||
|
police(?:m[ae]n|wom[ae]n): police officer(s)
|
||||||
|
repair(?:m[ae]n|wom[ae]n): technician(s)
|
||||||
|
sales(?:m[ae]n|wom[ae]n): salesperson or sales people
|
||||||
|
service(?:m[ae]n|wom[ae]n): soldier(s)
|
||||||
|
steward(?:ess)?: flight attendant
|
||||||
|
tribes(?:m[ae]n|wom[ae]n): tribe member(s)
|
||||||
|
waitress: waiter
|
||||||
|
woman doctor: doctor
|
||||||
|
woman scientist[s]?: scientist(s)
|
||||||
|
work(?:m[ae]n|wom[ae]n): worker(s)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "Don't put a period at the end of a heading."
|
||||||
|
link: "https://developers.google.com/style/capitalization#capitalization-in-titles-and-headings"
|
||||||
|
nonword: true
|
||||||
|
level: warning
|
||||||
|
scope: heading
|
||||||
|
action:
|
||||||
|
name: edit
|
||||||
|
params:
|
||||||
|
- trim_right
|
||||||
|
- "."
|
||||||
|
tokens:
|
||||||
|
- '[a-z0-9][.]\s*$'
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
extends: capitalization
|
||||||
|
message: "'%s' should use sentence-style capitalization."
|
||||||
|
link: "https://developers.google.com/style/capitalization#capitalization-in-titles-and-headings"
|
||||||
|
level: warning
|
||||||
|
scope: heading
|
||||||
|
match: $sentence
|
||||||
|
# No `indicators: [":"]` here. That makes Vale require a capital after a colon,
|
||||||
|
# which is the Microsoft convention this rule was originally copied from. This
|
||||||
|
# guide says the opposite: "the first word after a colon is generally
|
||||||
|
# lowercase" (developers.google.com/style/colons), and Colons.yml enforces
|
||||||
|
# exactly that. See issue #58.
|
||||||
|
exceptions:
|
||||||
|
- Azure
|
||||||
|
- CLI
|
||||||
|
- Cosmos
|
||||||
|
- Docker
|
||||||
|
- Emmet
|
||||||
|
- gRPC
|
||||||
|
- I
|
||||||
|
- Kubernetes
|
||||||
|
- Linux
|
||||||
|
- macOS
|
||||||
|
- Marketplace
|
||||||
|
- MongoDB
|
||||||
|
- REPL
|
||||||
|
- Studio
|
||||||
|
- TypeScript
|
||||||
|
- URLs
|
||||||
|
- Visual
|
||||||
|
- VS
|
||||||
|
- Windows
|
||||||
|
- JSON
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "Avoid the jargon '%s'."
|
||||||
|
link: https://developers.google.com/style/jargon
|
||||||
|
level: suggestion
|
||||||
|
ignorecase: true
|
||||||
|
# The guide also cites 'solution', 'support', and 'workload' as overloaded
|
||||||
|
# terms, but those have ordinary technical meanings and accounted for every hit
|
||||||
|
# on a 950-file corpus, so only the unambiguous figurative terms are listed.
|
||||||
|
tokens:
|
||||||
|
- break-glass
|
||||||
|
- camel ?case
|
||||||
|
- out-of-the-box
|
||||||
|
- swim ?lane
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
extends: substitution
|
||||||
|
message: "Use '%s' instead of '%s'."
|
||||||
|
link: 'https://developers.google.com/style/abbreviations'
|
||||||
|
ignorecase: true
|
||||||
|
level: error
|
||||||
|
nonword: true
|
||||||
|
action:
|
||||||
|
name: replace
|
||||||
|
# The delimiter is a lookahead so the replacement doesn't swallow the comma or
|
||||||
|
# space that follows (issue #18). `$` is included so the abbreviation is still
|
||||||
|
# caught at the end of a heading, table cell, or block, which accounted for 8
|
||||||
|
# of 10 occurrences on a 950-file corpus.
|
||||||
|
swap:
|
||||||
|
'\b(?:eg|e\.g\.)(?=[\s,;]|$)': for example
|
||||||
|
'\b(?:ie|i\.e\.)(?=[\s,;]|$)': that is
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "'%s' doesn't need a hyphen."
|
||||||
|
link: "https://developers.google.com/style/hyphens"
|
||||||
|
level: error
|
||||||
|
ignorecase: false
|
||||||
|
nonword: true
|
||||||
|
action:
|
||||||
|
name: edit
|
||||||
|
params:
|
||||||
|
- regex
|
||||||
|
- "-"
|
||||||
|
- " "
|
||||||
|
tokens:
|
||||||
|
- '\b[^\s-]+ly-\w+\b'
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "Don't use plurals in parentheses such as in '%s'."
|
||||||
|
link: "https://developers.google.com/style/plurals-parentheses"
|
||||||
|
level: error
|
||||||
|
nonword: true
|
||||||
|
action:
|
||||||
|
name: edit
|
||||||
|
params:
|
||||||
|
- trim_right
|
||||||
|
- "(s)"
|
||||||
|
tokens:
|
||||||
|
- '\b\w+\(s\)'
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "Spell out all ordinal numbers ('%s') in text."
|
||||||
|
link: 'https://developers.google.com/style/numbers'
|
||||||
|
level: error
|
||||||
|
nonword: true
|
||||||
|
tokens:
|
||||||
|
- \d+(?:st|nd|rd|th)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "Use the Oxford comma in '%s'."
|
||||||
|
link: 'https://developers.google.com/style/commas'
|
||||||
|
scope: sentence
|
||||||
|
level: warning
|
||||||
|
nonword: true
|
||||||
|
# List items may be several words long, not just one. Four guards keep the
|
||||||
|
# false-positive rate down:
|
||||||
|
#
|
||||||
|
# 1. The comma can't be the one closing a fronted subordinate clause
|
||||||
|
# ('When your alarm rings, you turn it off and tumble out of bed.') --
|
||||||
|
# that comma separates clauses, not list items. Only the first comma of
|
||||||
|
# such a sentence is exempt, so 'When it rains, apples, pears or bananas
|
||||||
|
# get wet.' is still caught.
|
||||||
|
# 2. The item can't open with a clause-introducer (', which ...',
|
||||||
|
# ', specifically ...').
|
||||||
|
# 3. The item can't open with a subject pronoun followed by a verb, which
|
||||||
|
# marks a compound predicate rather than a list ('..., you walk to the
|
||||||
|
# fridge and get a snack.'). A pronoun directly followed by 'and'/'or'
|
||||||
|
# is a real list item, so ', you and me.' still matches.
|
||||||
|
# 4. Neither item may contain an auxiliary verb, which is another compound
|
||||||
|
# predicate signal (', it has some downsides and is officially
|
||||||
|
# discouraged.').
|
||||||
|
#
|
||||||
|
# The trailing anchor allows end-of-scope so list fragments ('Apples, pears
|
||||||
|
# or bananas') are still caught.
|
||||||
|
tokens:
|
||||||
|
- '(?<!^(?i:when|whenever|while|if|unless|until|although|though|because|since|after|before|once|whereas|whether|as)\b[^,]{0,80}),\s(?!(?:which|who|whom|whose|that|where|when|while|because|since|although|though|if|unless|so|but|and|or|however|therefore|thus|specifically|especially|namely|then|take|see|note|consider|make|use|either|neither)\b)(?!(?i:i|you|we|they|he|she|it)\s+(?!(?:and|or)\b))(?:(?!\b(?:is|are|was|were|has|have|had|be|been|being|will|would|can|could|should|may|might|must|do|does|did)\b)\w+ ){0,4}\w+ (?:and|or) (?:(?!\b(?:is|are|was|were|has|have|had|be|been|being|will|would|can|could|should|may|might|must|do|does|did)\b)\w+ ){0,4}\w+(?:[.?!]|$)'
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "Use parentheses judiciously."
|
||||||
|
link: 'https://developers.google.com/style/parentheses'
|
||||||
|
nonword: true
|
||||||
|
level: suggestion
|
||||||
|
# `[^)]` rather than `.+`: a greedy match ran from the first '(' on a line to
|
||||||
|
# the last ')', so 'Text (one) and more (two).' produced a single alert
|
||||||
|
# covering everything between them. See issue #30.
|
||||||
|
# A bare 3-5 letter acronym is skipped: Acronyms.yml requires acronyms to be
|
||||||
|
# defined as 'Spelled Out Term (ACRONYM)', so flagging those parentheses would
|
||||||
|
# put the two rules in direct conflict. The acronym has to be the whole
|
||||||
|
# parenthetical — '(NASA rocket program)' is an ordinary aside and still
|
||||||
|
# flags. Length matches the {3,5} in Acronyms.yml. See PR #59.
|
||||||
|
tokens:
|
||||||
|
- '\((?![A-Z]{3,5}\))[^)]+\)'
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
extends: existence
|
||||||
|
link: 'https://developers.google.com/style/voice'
|
||||||
|
message: "In general, use active voice instead of passive voice ('%s')."
|
||||||
|
ignorecase: true
|
||||||
|
level: suggestion
|
||||||
|
raw:
|
||||||
|
- \b(am|are|were|being|is|been|was|be)\b\s*
|
||||||
|
tokens:
|
||||||
|
- '[\w]+ed'
|
||||||
|
- awoken
|
||||||
|
- beat
|
||||||
|
- become
|
||||||
|
- been
|
||||||
|
- begun
|
||||||
|
- bent
|
||||||
|
- beset
|
||||||
|
- bet
|
||||||
|
- bid
|
||||||
|
- bidden
|
||||||
|
- bitten
|
||||||
|
- bled
|
||||||
|
- blown
|
||||||
|
- born
|
||||||
|
- bought
|
||||||
|
- bound
|
||||||
|
- bred
|
||||||
|
- broadcast
|
||||||
|
- broken
|
||||||
|
- brought
|
||||||
|
- built
|
||||||
|
- burnt
|
||||||
|
- burst
|
||||||
|
- cast
|
||||||
|
- caught
|
||||||
|
- chosen
|
||||||
|
- clung
|
||||||
|
- come
|
||||||
|
- cost
|
||||||
|
- crept
|
||||||
|
- cut
|
||||||
|
- dealt
|
||||||
|
- dived
|
||||||
|
- done
|
||||||
|
- drawn
|
||||||
|
- dreamt
|
||||||
|
- driven
|
||||||
|
- drunk
|
||||||
|
- dug
|
||||||
|
- eaten
|
||||||
|
- fallen
|
||||||
|
- fed
|
||||||
|
- felt
|
||||||
|
- fit
|
||||||
|
- fled
|
||||||
|
- flown
|
||||||
|
- flung
|
||||||
|
- forbidden
|
||||||
|
- foregone
|
||||||
|
- forgiven
|
||||||
|
- forgotten
|
||||||
|
- forsaken
|
||||||
|
- fought
|
||||||
|
- found
|
||||||
|
- frozen
|
||||||
|
- given
|
||||||
|
- gone
|
||||||
|
- gotten
|
||||||
|
- ground
|
||||||
|
- grown
|
||||||
|
- heard
|
||||||
|
- held
|
||||||
|
- hidden
|
||||||
|
- hit
|
||||||
|
- hung
|
||||||
|
- hurt
|
||||||
|
- kept
|
||||||
|
- knelt
|
||||||
|
- knit
|
||||||
|
- known
|
||||||
|
- laid
|
||||||
|
- lain
|
||||||
|
- leapt
|
||||||
|
- learnt
|
||||||
|
- led
|
||||||
|
- left
|
||||||
|
- lent
|
||||||
|
- let
|
||||||
|
- lighted
|
||||||
|
- lost
|
||||||
|
- made
|
||||||
|
- meant
|
||||||
|
- met
|
||||||
|
- misspelt
|
||||||
|
- mistaken
|
||||||
|
- mown
|
||||||
|
- overcome
|
||||||
|
- overdone
|
||||||
|
- overtaken
|
||||||
|
- overthrown
|
||||||
|
- paid
|
||||||
|
- pled
|
||||||
|
- proven
|
||||||
|
- put
|
||||||
|
- quit
|
||||||
|
- read
|
||||||
|
- rid
|
||||||
|
- ridden
|
||||||
|
- risen
|
||||||
|
- run
|
||||||
|
- rung
|
||||||
|
- said
|
||||||
|
- sat
|
||||||
|
- sawn
|
||||||
|
- seen
|
||||||
|
- sent
|
||||||
|
- set
|
||||||
|
- sewn
|
||||||
|
- shaken
|
||||||
|
- shaven
|
||||||
|
- shed
|
||||||
|
- shod
|
||||||
|
- shone
|
||||||
|
- shorn
|
||||||
|
- shot
|
||||||
|
- shown
|
||||||
|
- shrunk
|
||||||
|
- shut
|
||||||
|
- slain
|
||||||
|
- slept
|
||||||
|
- slid
|
||||||
|
- slit
|
||||||
|
- slung
|
||||||
|
- smitten
|
||||||
|
- sold
|
||||||
|
- sought
|
||||||
|
- sown
|
||||||
|
- sped
|
||||||
|
- spent
|
||||||
|
- spilt
|
||||||
|
- spit
|
||||||
|
- split
|
||||||
|
- spoken
|
||||||
|
- spread
|
||||||
|
- sprung
|
||||||
|
- spun
|
||||||
|
- stolen
|
||||||
|
- stood
|
||||||
|
- stridden
|
||||||
|
- striven
|
||||||
|
- struck
|
||||||
|
- strung
|
||||||
|
- stuck
|
||||||
|
- stung
|
||||||
|
- stunk
|
||||||
|
- sung
|
||||||
|
- sunk
|
||||||
|
- swept
|
||||||
|
- swollen
|
||||||
|
- sworn
|
||||||
|
- swum
|
||||||
|
- swung
|
||||||
|
- taken
|
||||||
|
- taught
|
||||||
|
- thought
|
||||||
|
- thrived
|
||||||
|
- thrown
|
||||||
|
- thrust
|
||||||
|
- told
|
||||||
|
- torn
|
||||||
|
- trodden
|
||||||
|
- understood
|
||||||
|
- upheld
|
||||||
|
- upset
|
||||||
|
- wed
|
||||||
|
- wept
|
||||||
|
- withheld
|
||||||
|
- withstood
|
||||||
|
- woken
|
||||||
|
- won
|
||||||
|
- worn
|
||||||
|
- wound
|
||||||
|
- woven
|
||||||
|
- written
|
||||||
|
- wrung
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "Don't use periods with acronyms or initialisms such as '%s'."
|
||||||
|
link: 'https://developers.google.com/style/abbreviations'
|
||||||
|
level: error
|
||||||
|
nonword: true
|
||||||
|
tokens:
|
||||||
|
- '\b(?:[A-Z]\.){3,}'
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "Commas and periods go inside quotation marks."
|
||||||
|
link: 'https://developers.google.com/style/quotation-marks'
|
||||||
|
level: error
|
||||||
|
nonword: true
|
||||||
|
tokens:
|
||||||
|
- '"[^"]+"[.,?]'
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "Don't add words such as 'from' or 'between' to describe a range of numbers."
|
||||||
|
link: 'https://developers.google.com/style/hyphens'
|
||||||
|
nonword: true
|
||||||
|
level: warning
|
||||||
|
tokens:
|
||||||
|
- '(?:from|between)\s\d+\s?-\s?\d+'
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "Use semicolons judiciously."
|
||||||
|
link: 'https://developers.google.com/style/semicolons'
|
||||||
|
nonword: true
|
||||||
|
scope: sentence
|
||||||
|
level: suggestion
|
||||||
|
tokens:
|
||||||
|
- ';'
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "Don't use internet slang abbreviations such as '%s'."
|
||||||
|
link: 'https://developers.google.com/style/abbreviations'
|
||||||
|
ignorecase: true
|
||||||
|
level: error
|
||||||
|
tokens:
|
||||||
|
- 'tl;dr'
|
||||||
|
- ymmv
|
||||||
|
- rtfm
|
||||||
|
- imo
|
||||||
|
- fwiw
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "'%s' should have one space."
|
||||||
|
link: 'https://developers.google.com/style/sentence-spacing'
|
||||||
|
level: error
|
||||||
|
nonword: true
|
||||||
|
action:
|
||||||
|
name: remove
|
||||||
|
tokens:
|
||||||
|
- '[a-z][.?!] {2,}[A-Z]'
|
||||||
|
- '[a-z][.?!][A-Z]'
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "In general, use American spelling instead of '%s'."
|
||||||
|
link: 'https://developers.google.com/style/spelling'
|
||||||
|
ignorecase: true
|
||||||
|
level: warning
|
||||||
|
tokens:
|
||||||
|
- '(?:\w+)nised?'
|
||||||
|
- 'colour'
|
||||||
|
- 'labour'
|
||||||
|
- 'centre'
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "Avoid time-based words like '%s' in product documentation."
|
||||||
|
link: https://developers.google.com/style/timeless-documentation
|
||||||
|
level: suggestion
|
||||||
|
ignorecase: true
|
||||||
|
# The guide also names 'now' and 'new', but both have common senses that aren't
|
||||||
|
# time-anchored ('create a new project'): adding them took a 950-file corpus of
|
||||||
|
# technical documentation from 14 hits to 117. 'recently' is left out too — every
|
||||||
|
# hit in that corpus was the UI idiom 'recently used'.
|
||||||
|
tokens:
|
||||||
|
- currently
|
||||||
|
- latest
|
||||||
|
- soon
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "Put a nonbreaking space between the number and the unit in '%s'."
|
||||||
|
link: "https://developers.google.com/style/units-of-measure"
|
||||||
|
nonword: true
|
||||||
|
level: error
|
||||||
|
tokens:
|
||||||
|
- '\b\d+(?:B|kB|MB|GB|TB)\b'
|
||||||
|
- '\b\d+(?:ns|ms|min|h|d)\b'
|
||||||
|
# Seconds are split out so a decade ('1990s') isn't read as a unit.
|
||||||
|
- '\b\d+s\b(?<!\b(?:19|20)\d\ds\b)'
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "Try to avoid using first-person plural like '%s'."
|
||||||
|
link: 'https://developers.google.com/style/pronouns#personal-pronouns'
|
||||||
|
level: warning
|
||||||
|
ignorecase: true
|
||||||
|
tokens:
|
||||||
|
- we
|
||||||
|
- we'(?:ve|re)
|
||||||
|
- ours?
|
||||||
|
- us
|
||||||
|
- let's
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
extends: existence
|
||||||
|
message: "Avoid using '%s'."
|
||||||
|
link: 'https://developers.google.com/style/tense'
|
||||||
|
ignorecase: true
|
||||||
|
level: warning
|
||||||
|
tokens:
|
||||||
|
- will
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
extends: substitution
|
||||||
|
message: "Use '%s' instead of '%s'."
|
||||||
|
link: "https://developers.google.com/style/word-list"
|
||||||
|
level: warning
|
||||||
|
# Case matters here: each key's own capitalization is what's being corrected,
|
||||||
|
# so ignorecase would make these match their own replacements. The rest of the
|
||||||
|
# word list lives in WordListCase.yml.
|
||||||
|
ignorecase: false
|
||||||
|
action:
|
||||||
|
name: replace
|
||||||
|
swap:
|
||||||
|
Ajax: AJAX
|
||||||
|
Android device: Android-powered device
|
||||||
|
android: Android
|
||||||
|
API explorer: APIs Explorer
|
||||||
|
authN: authentication
|
||||||
|
authZ: authorization
|
||||||
|
CLI: command-line tool
|
||||||
|
Cloud: Google Cloud Platform|GCP
|
||||||
|
Container Engine: Kubernetes Engine
|
||||||
|
Developers Console: Google API Console|API Console
|
||||||
|
Google account: Google Account
|
||||||
|
Google accounts: Google Accounts
|
||||||
|
Googling: search with Google
|
||||||
|
HTTPs: HTTPS
|
||||||
|
k8s: Kubernetes
|
||||||
|
SHA1: SHA-1|HAS-SHA1
|
||||||
|
url: URL
|
||||||
|
World Wide Web: web
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
extends: substitution
|
||||||
|
message: "Use '%s' instead of '%s'."
|
||||||
|
link: "https://developers.google.com/style/word-list"
|
||||||
|
level: warning
|
||||||
|
# The case-insensitive half of the word list, so sentence-initial use is caught
|
||||||
|
# ('Touch the screen', not only 'touch the screen'). Entries that must stay
|
||||||
|
# case-sensitive are in WordList.yml.
|
||||||
|
ignorecase: true
|
||||||
|
action:
|
||||||
|
name: replace
|
||||||
|
swap:
|
||||||
|
"(?:API Console|dev|developer) key": API key
|
||||||
|
"(?:cell ?phone|smart ?phone)": phone|mobile phone
|
||||||
|
"(?:dev|developer|APIs) console": API console
|
||||||
|
"(?:e-mail|Email|E-mail)": email
|
||||||
|
"(?:file ?path|path ?name)": path
|
||||||
|
"(?:kill|terminate|abort)": stop|exit|cancel|end
|
||||||
|
# Longest form first: with the shortest alternative leading, 'OAuth 2' matched
|
||||||
|
# only 'OAuth', so applying the suggestion produced 'OAuth 2.0 2'. The rule is
|
||||||
|
# already case-insensitive, so the inline (?i) is redundant. See issue #41.
|
||||||
|
'\bOauth2\.0\b|\bOAuth ?2\b(?!\.0)|\bOauth\b(?! ?2)': OAuth 2.0
|
||||||
|
"(?:ok|Okay)": OK|okay
|
||||||
|
"(?:WiFi|wifi)": Wi-Fi
|
||||||
|
'[\.]+apk': APK
|
||||||
|
'3\-D': 3D
|
||||||
|
'Google (?:I\-O|IO)': Google I/O
|
||||||
|
"tap (?:&|and) hold": touch & hold
|
||||||
|
"un(?:check|select)": clear
|
||||||
|
above: preceding
|
||||||
|
account name: username
|
||||||
|
action bar: app bar
|
||||||
|
admin: administrator
|
||||||
|
a\.k\.a|aka: or|also known as
|
||||||
|
application: app
|
||||||
|
approx\.: approximately
|
||||||
|
autoupdate: automatically update
|
||||||
|
cellular data: mobile data
|
||||||
|
cellular network: mobile network
|
||||||
|
chapter: documents|pages|sections
|
||||||
|
check box: checkbox
|
||||||
|
click on: click|click in
|
||||||
|
content type: media type
|
||||||
|
curated roles: predefined roles
|
||||||
|
data are: data is
|
||||||
|
disabled?: turn off|off
|
||||||
|
ephemeral IP address: ephemeral external IP address
|
||||||
|
fewer data: less data
|
||||||
|
file name: filename
|
||||||
|
firewalls: firewall rules
|
||||||
|
functionality: capability|feature
|
||||||
|
grayed-out: unavailable
|
||||||
|
in order to: to
|
||||||
|
ingest: import|load
|
||||||
|
long press: touch & hold
|
||||||
|
network IP address: internal IP address
|
||||||
|
omnibox: address bar
|
||||||
|
open-source: open source
|
||||||
|
overview screen: recents screen
|
||||||
|
regex: regular expression
|
||||||
|
sign into: sign in to
|
||||||
|
'(?<!single )sign-?on': single sign-on
|
||||||
|
static IP address: static external IP address
|
||||||
|
stylesheet: style sheet
|
||||||
|
synch: sync
|
||||||
|
tablename: table name
|
||||||
|
tablet: device
|
||||||
|
'touch(?! ?(?:&|and) hold)': tap
|
||||||
|
vs\.: versus
|
||||||
@@ -28,6 +28,11 @@ make workflow-check # workflow-lint + workflow-dryrun
|
|||||||
make devx-check-doc-versions # Verify docs version refs match __version__
|
make devx-check-doc-versions # Verify docs version refs match __version__
|
||||||
make devx-vale # Run Vale prose linter on docs and README
|
make devx-vale # Run Vale prose linter on docs and README
|
||||||
make clean # Remove caches, build artifacts, coverage data
|
make clean # Remove caches, build artifacts, coverage data
|
||||||
|
make check-workflow-artifact-deps # Verify artifact download jobs depend on upload jobs
|
||||||
|
make check-workflow-tofu-init # Verify tofu-state jobs have a tofu-init step
|
||||||
|
make check-docker-init # Check Docker Compose services with healthchecks have init: true
|
||||||
|
make check-ansible-set-fact-to-json # Check set_fact tasks don't misuse to_json
|
||||||
|
make check-alert-rules # Validate Prometheus alert rules with promtool
|
||||||
```
|
```
|
||||||
|
|
||||||
`make setup` automatically installs all development tools:
|
`make setup` automatically installs all development tools:
|
||||||
@@ -90,7 +95,10 @@ src/devx/
|
|||||||
│ ├── doc_coverage.py # Documentation coverage check
|
│ ├── doc_coverage.py # Documentation coverage check
|
||||||
│ ├── lint_docs.py # Documentation linter (structure, links, headings, code blocks, orphans)
|
│ ├── lint_docs.py # Documentation linter (structure, links, headings, code blocks, orphans)
|
||||||
│ ├── validate_deploy_ref.py # Validate git tag for deployments (--github-output)
|
│ ├── validate_deploy_ref.py # Validate git tag for deployments (--github-output)
|
||||||
│ └── record_deployed_tag.py # Record deployed tag to Gitea repo variable
|
│ ├── record_deployed_tag.py # Record deployed tag to Gitea repo variable
|
||||||
|
│ ├── cancel_superseded_runs.py # Cancel in-flight CI runs for the same PR branch
|
||||||
|
│ ├── check_workflow_artifact_deps.py # Verify artifact download jobs depend on upload jobs
|
||||||
|
│ └── check_workflow_tofu_init.py # Verify tofu-state jobs have a tofu-init step
|
||||||
├── tools/ # Developer tooling modules (run locally or by CI)
|
├── tools/ # Developer tooling modules (run locally or by CI)
|
||||||
│ ├── setup.py # Environment setup (venv, deps, hooks)
|
│ ├── setup.py # Environment setup (venv, deps, hooks)
|
||||||
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea, hadolint, vale
|
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea, hadolint, vale
|
||||||
@@ -113,10 +121,13 @@ src/devx/
|
|||||||
│ ├── pr_logs.py # Fetch logs for failed CI jobs
|
│ ├── pr_logs.py # Fetch logs for failed CI jobs
|
||||||
│ ├── pr_label.py # Add labels to PRs (idempotent)
|
│ ├── pr_label.py # Add labels to PRs (idempotent)
|
||||||
│ ├── pre_push_check.py # Validate Vikunja task existence before push
|
│ ├── pre_push_check.py # Validate Vikunja task existence before push
|
||||||
|
│ ├── check_docker_init.py # Check Docker Compose services with healthchecks have init: true
|
||||||
|
│ ├── check_ansible_set_fact_to_json.py # Check set_fact tasks don't misuse to_json
|
||||||
|
│ ├── check_alert_rules.py # Validate Prometheus alert rules with promtool
|
||||||
│ └── _shared.py # Shared tool utilities
|
│ └── _shared.py # Shared tool utilities
|
||||||
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
|
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
|
||||||
├── utils/ # Shared utilities (reusable across projects)
|
├── utils/ # Shared utilities (reusable across projects)
|
||||||
│ ├── api.py # API response helpers (is_truthy, is_falsy)
|
│ ├── api.py # API response helpers (is_truthy, is_falsy) + APIClient base class
|
||||||
│ ├── ssh.py # SSH exec + wait_for_ssh (pure-Python socket check)
|
│ ├── ssh.py # SSH exec + wait_for_ssh (pure-Python socket check)
|
||||||
│ ├── crypto.py # Secret generation (shell-safe passwords)
|
│ ├── crypto.py # Secret generation (shell-safe passwords)
|
||||||
│ ├── vault.py # Ansible vault encrypt/decrypt helpers
|
│ ├── vault.py # Ansible vault encrypt/decrypt helpers
|
||||||
@@ -124,11 +135,12 @@ src/devx/
|
|||||||
│ ├── confirm.py # Typed confirmation validation for destructive ops
|
│ ├── confirm.py # Typed confirmation validation for destructive ops
|
||||||
│ ├── json_registry.py # File-locked JSON registry for local state
|
│ ├── json_registry.py # File-locked JSON registry for local state
|
||||||
│ ├── step_tracker.py # Multi-step operation tracking with reports
|
│ ├── step_tracker.py # Multi-step operation tracking with reports
|
||||||
│ └── logging.py # XDG-compliant logging configuration
|
│ ├── logging.py # XDG-compliant logging configuration
|
||||||
|
│ ├── ui.py # say() — unified click.echo + logging output
|
||||||
|
│ └── jinja.py # Jinja2 environment helpers + Ansible-compatible filters
|
||||||
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
||||||
├── discover_runners.py # Dynamic Gitea runner discovery
|
├── discover_runners.py # Dynamic Gitea runner discovery
|
||||||
├── distribute_molecule.py # Distribute molecule scenarios across runners (LPT scheduling, --roles-root for multi-role)
|
├── distribute_molecule.py # Distribute molecule scenarios across runners (LPT scheduling, --roles-root for multi-role)
|
||||||
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast (--roles-root)
|
|
||||||
├── molecule_all.py # Run all molecule scenarios locally
|
├── molecule_all.py # Run all molecule scenarios locally
|
||||||
├── start_docker.py # Ensure Docker daemon is running for molecule tests
|
├── start_docker.py # Ensure Docker daemon is running for molecule tests
|
||||||
└── platforms.py # Supported molecule platforms
|
└── platforms.py # Supported molecule platforms
|
||||||
|
|||||||
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.49.0] - 2026-08-09
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Sync missing features from v0.49.x line to master
|
||||||
|
|
||||||
|
## [0.48.2] - 2026-08-09
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Remove dead translation keys and add missing one
|
||||||
|
|
||||||
## [0.48.1] - 2026-08-08
|
## [0.48.1] - 2026-08-08
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
.PHONY: all setup setup-ci setup-quality setup-release setup-image install update lint lint-all lint-dockerfiles test test-unit pytest-cov clean install-tools install-hooks activate-scripts checkmake check-mutable-globals check-dep-docs check-test-speed build-images push-images build-images-dry-run clean-images
|
.PHONY: all setup setup-ci setup-quality setup-release setup-image install update lint lint-all lint-dockerfiles test test-unit pytest-cov clean install-tools install-hooks activate-scripts checkmake check-mutable-globals check-dep-docs check-test-speed build-images push-images build-images-dry-run clean-images
|
||||||
|
.PHONY: check-workflow-artifact-deps check-workflow-tofu-init check-docker-init check-ansible-set-fact-to-json check-alert-rules
|
||||||
|
|
||||||
PYTHON := python3
|
PYTHON := python3
|
||||||
VENV := .venv
|
VENV := .venv
|
||||||
@@ -65,7 +66,7 @@ setup-release: $(VENV)/bin/activate .env
|
|||||||
# an older devx.mak that doesn't yet define devx-setup-image. Consumer repos
|
# 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.
|
# (grm, infra) can safely alias to devx-setup-image since they install devx from PyPI.
|
||||||
setup-image:
|
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 --no-deps -e . 2>/dev/null; \
|
||||||
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
|
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
|
||||||
|
|
||||||
install-hooks:
|
install-hooks:
|
||||||
@@ -113,6 +114,31 @@ pr-rebase: devx-pr-rebase
|
|||||||
lint-all: lint workflow-lint lint-dockerfiles
|
lint-all: lint workflow-lint lint-dockerfiles
|
||||||
@echo "[lint-all] All linting checks passed."
|
@echo "[lint-all] All linting checks passed."
|
||||||
|
|
||||||
|
# ── Workflow / Ansible / Docker check tools ─────────────────────────────────
|
||||||
|
# Generic check tools ported from infra. These targets are no-ops in devx
|
||||||
|
# itself (no .gitea/workflows or ansible/ directory) but provide the
|
||||||
|
# canonical entry points for consumer repos that include devx.mak.
|
||||||
|
|
||||||
|
check-workflow-artifact-deps:
|
||||||
|
@$(BIN)/python -m devx.ci.check_workflow_artifact_deps || \
|
||||||
|
echo "[check-workflow-artifact-deps] No workflows directory found — skipping."
|
||||||
|
|
||||||
|
check-workflow-tofu-init:
|
||||||
|
@$(BIN)/python -m devx.ci.check_workflow_tofu_init || \
|
||||||
|
echo "[check-workflow-tofu-init] No workflows directory found — skipping."
|
||||||
|
|
||||||
|
check-docker-init:
|
||||||
|
@$(BIN)/python -m devx.tools.check_docker_init || \
|
||||||
|
echo "[check-docker-init] No ansible templates found — skipping."
|
||||||
|
|
||||||
|
check-ansible-set-fact-to-json:
|
||||||
|
@$(BIN)/python -m devx.tools.check_ansible_set_fact_to_json || \
|
||||||
|
echo "[check-ansible-set-fact-to-json] No ansible directory found — skipping."
|
||||||
|
|
||||||
|
check-alert-rules:
|
||||||
|
@$(BIN)/python -m devx.tools.check_alert_rules --template-path ansible/roles/observability/templates || \
|
||||||
|
echo "[check-alert-rules] No alert-rules template found — skipping."
|
||||||
|
|
||||||
# Note: Not aliased to devx-lint-dockerfiles for the same reason as setup-image —
|
# Note: Not aliased to devx-lint-dockerfiles for the same reason as setup-image —
|
||||||
# devx's own CI images may have an older devx.mak. Consumer repos can safely alias.
|
# devx's own CI images may have an older devx.mak. Consumer repos can safely alias.
|
||||||
lint-dockerfiles:
|
lint-dockerfiles:
|
||||||
|
|||||||
@@ -12,16 +12,16 @@ opinionated CI/CD pipeline: conventional commits, automated versioning via
|
|||||||
git-cliff, squash-merge automation, Vikunja task tracking, wiki sync, and
|
git-cliff, squash-merge automation, Vikunja task tracking, wiki sync, and
|
||||||
quality badges.
|
quality badges.
|
||||||
|
|
||||||
> An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
|
> An open source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
|
||||||
|
|
||||||
[](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/src/branch/master/LICENSE)
|
[](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/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/wiki)
|
||||||
[](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/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Why devx?
|
## Why devx?
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.48.1",
|
"devx>=0.49.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pip]
|
[tool.pip]
|
||||||
@@ -101,8 +101,8 @@ pip install -e .
|
|||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** If your project requires a specific devx version, pin it in
|
> **Note:** If your project requires a specific devx version, pin it in
|
||||||
> `dependencies` (for example, `"devx==0.48.1"`) or use a version constraint
|
> `dependencies` (for example, `"devx==0.49.0"`) or use a version constraint
|
||||||
> (for example, `"devx>=0.48.1,<0.49"`).
|
> (for example, `"devx>=0.49.0,<0.50"`).
|
||||||
|
|
||||||
### Optional extras
|
### Optional extras
|
||||||
|
|
||||||
@@ -226,10 +226,6 @@ python -m devx.molecule.distribute_molecule --runner-index 1 --max-runners 3
|
|||||||
python -m devx.molecule.distribute_molecule --list # list all scenarios
|
python -m devx.molecule.distribute_molecule --list # list all scenarios
|
||||||
python -m devx.molecule.distribute_molecule --list-platforms # list platforms
|
python -m devx.molecule.distribute_molecule --list-platforms # list platforms
|
||||||
|
|
||||||
# Run molecule tests with cross-runner fail-fast
|
|
||||||
python -m devx.molecule.molecule_ci_guard pair1 pair2
|
|
||||||
python -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2
|
|
||||||
|
|
||||||
# Run all molecule scenarios locally (sequential)
|
# Run all molecule scenarios locally (sequential)
|
||||||
python -m devx.molecule.molecule_all
|
python -m devx.molecule.molecule_all
|
||||||
python -m devx.molecule.molecule_all --bin .venv/bin
|
python -m devx.molecule.molecule_all --bin .venv/bin
|
||||||
@@ -303,7 +299,6 @@ devx --version
|
|||||||
| `devx molecule all` | Run all molecule scenarios on all supported platforms |
|
| `devx molecule all` | Run all molecule scenarios on all supported platforms |
|
||||||
| `devx molecule discover-runners` | Discover available Gitea Actions runners |
|
| `devx molecule discover-runners` | Discover available Gitea Actions runners |
|
||||||
| `devx molecule distribute` | Distribute molecule test pairs across parallel runners |
|
| `devx molecule distribute` | Distribute molecule test pairs across parallel runners |
|
||||||
| `devx molecule guard` | Run molecule tests with CI failure polling |
|
|
||||||
|
|
||||||
See [CLI Commands](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki/CLI-Commands)
|
See [CLI Commands](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki/CLI-Commands)
|
||||||
in the wiki for full command documentation with examples.
|
in the wiki for full command documentation with examples.
|
||||||
|
|||||||
+12
-12
@@ -8,16 +8,16 @@ parallel test distribution, and more into a single installable package.
|
|||||||
It was extracted from the [GRM](https://git.oblachno.oblachno.fyi/oblachno-oss/grm)
|
It was extracted from the [GRM](https://git.oblachno.oblachno.fyi/oblachno-oss/grm)
|
||||||
project to be reusable across all oblachno-oss repositories.
|
project to be reusable across all oblachno-oss repositories.
|
||||||
|
|
||||||
> An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
|
> An open source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
|
||||||
|
|
||||||
[](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/src/branch/master/LICENSE)
|
[](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/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/wiki)
|
||||||
[](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/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.48.1",
|
"devx>=0.49.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pip]
|
[tool.pip]
|
||||||
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
||||||
```
|
```
|
||||||
|
|
||||||
Pin a specific version if needed: `"devx==0.48.1"` or `"devx>=0.48.1,<0.49"`.
|
Pin a specific version if needed: `"devx==0.49.0"` or `"devx>=0.49.0,<0.50"`.
|
||||||
|
|
||||||
### Optional extras
|
### Optional extras
|
||||||
|
|
||||||
@@ -104,8 +104,8 @@ devx is a self-contained Python package under `src/devx/`:
|
|||||||
- **Dev tools** (`devx.tools`) — setup, install_tools, check_test_speed,
|
- **Dev tools** (`devx.tools`) — setup, install_tools, check_test_speed,
|
||||||
configure_repo, generate_badges, generate_cliff_config, install_checkmake
|
configure_repo, generate_badges, generate_cliff_config, install_checkmake
|
||||||
- **Molecule tools** (`devx.molecule`) — Optional, for projects with Ansible
|
- **Molecule tools** (`devx.molecule`) — Optional, for projects with Ansible
|
||||||
roles: distribute_molecule, molecule_ci_guard, molecule_all, discover_runners,
|
roles: distribute_molecule, molecule_all, discover_runners, start_docker,
|
||||||
start_docker, platforms
|
platforms
|
||||||
|
|
||||||
See [Architecture](Architecture) for the full package structure, module
|
See [Architecture](Architecture) for the full package structure, module
|
||||||
descriptions, design principles, and data flow diagrams.
|
descriptions, design principles, and data flow diagrams.
|
||||||
@@ -132,7 +132,7 @@ devx provides a `devx` CLI with three command groups:
|
|||||||
|
|
||||||
- `devx ci <command>` — CI/CD automation (17 commands)
|
- `devx ci <command>` — CI/CD automation (17 commands)
|
||||||
- `devx tools <command>` — Developer tools (9 commands)
|
- `devx tools <command>` — Developer tools (9 commands)
|
||||||
- `devx molecule <command>` — Molecule testing (4 commands, optional)
|
- `devx molecule <command>` — Molecule testing (3 commands, optional)
|
||||||
|
|
||||||
See [CLI Commands](CLI-Commands) for full command documentation with examples.
|
See [CLI Commands](CLI-Commands) for full command documentation with examples.
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ unblocked auto-merge across all three repos.
|
|||||||
### 2. Double-Prefix Detection (MEDIUM impact)
|
### 2. Double-Prefix Detection (MEDIUM impact)
|
||||||
|
|
||||||
`check_auto_merge_ready.py` now detects and rejects Vikunja task titles
|
`check_auto_merge_ready.py` now detects and rejects Vikunja task titles
|
||||||
that include the identifier prefix (for example, "DEVX-127: Fix...").
|
that include the identifier prefix (for example, "DEVX-127: Fix").
|
||||||
The validator adds the prefix automatically, so a double prefix would
|
The validator adds the prefix automatically, so a double prefix would
|
||||||
fail validation.
|
fail validation.
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ src/devx/
|
|||||||
├── __init__.py
|
├── __init__.py
|
||||||
├── discover_runners.py # Dynamic Gitea runner discovery
|
├── discover_runners.py # Dynamic Gitea runner discovery
|
||||||
├── distribute_molecule.py # Distribute scenarios across runners
|
├── distribute_molecule.py # Distribute scenarios across runners
|
||||||
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast
|
|
||||||
├── molecule_all.py # Run all molecule scenarios locally
|
├── molecule_all.py # Run all molecule scenarios locally
|
||||||
├── start_docker.py # Ensure Docker is available for molecule
|
├── start_docker.py # Ensure Docker is available for molecule
|
||||||
└── platforms.py # Supported molecule platforms
|
└── platforms.py # Supported molecule platforms
|
||||||
@@ -87,11 +86,11 @@ overridden via environment variables with the `DEVX_` prefix. Provides:
|
|||||||
|
|
||||||
- `GITEA_API_URL` / `VIKUNJA_API_URL` — API endpoints
|
- `GITEA_API_URL` / `VIKUNJA_API_URL` — API endpoints
|
||||||
- `REPO_OWNER` — repository owner (must be set per-project)
|
- `REPO_OWNER` — repository owner (must be set per-project)
|
||||||
- `TASK_PREFIX` / `TASK_ID_RE` — task ID prefix and regex (for example, `DEVX-N`)
|
- `TASK_PREFIX` / `TASK_ID_RE` — task ID prefix and regular expression (for example, `DEVX-N`)
|
||||||
- `VIKUNJA_PROJECT_ID` — Vikunja project for task tracking
|
- `VIKUNJA_PROJECT_ID` — Vikunja project for task tracking
|
||||||
- `DEFAULT_TIMEOUT`, `DEFAULT_PER_PAGE` — HTTP client defaults
|
- `DEFAULT_TIMEOUT`, `DEFAULT_PER_PAGE` — HTTP client defaults
|
||||||
- `MAX_RETRIES`, `RETRY_BACKOFF_BASE`, `RETRY_STATUS_CODES` — retry config
|
- `MAX_RETRIES`, `RETRY_BACKOFF_BASE`, `RETRY_STATUS_CODES` — retry config
|
||||||
- `CONVENTIONAL_RE` — conventional commit format regex
|
- `CONVENTIONAL_RE` — conventional commit format regular expression
|
||||||
|
|
||||||
### `exceptions.py`
|
### `exceptions.py`
|
||||||
|
|
||||||
@@ -109,7 +108,7 @@ wraps user-facing strings for translation.
|
|||||||
|
|
||||||
Projects can extend translations by setting `DEVX_TRANSLATIONS_PATH` to a
|
Projects can extend translations by setting `DEVX_TRANSLATIONS_PATH` to a
|
||||||
custom JSON file. Keys from the project's file are merged on top of devx's
|
custom JSON file. Keys from the project's file are merged on top of devx's
|
||||||
built-in translations, allowing projects to override or add keys without
|
built-in translations, allowing projects to override, or add keys without
|
||||||
modifying the package.
|
modifying the package.
|
||||||
|
|
||||||
### `api_clients.py`
|
### `api_clients.py`
|
||||||
@@ -171,7 +170,7 @@ from `devx.api_clients`, `devx.config`, `devx.gitea_cli`, and `devx.i18n`.
|
|||||||
|
|
||||||
Automated release using git-cliff. Calculates the next semver version from
|
Automated release using git-cliff. Calculates the next semver version from
|
||||||
conventional commits since the last tag, updates `__version__` in
|
conventional commits since the last tag, updates `__version__` in
|
||||||
`__init__.py` and `CHANGELOG.md`, runs lint and tests to verify the release
|
`__init__.py` and `CHANGELOG.md`, runs lint, and tests to verify the release
|
||||||
is healthy, commits with `release: vX.Y.Z [skip ci]`, creates an annotated
|
is healthy, commits with `release: vX.Y.Z [skip ci]`, creates an annotated
|
||||||
tag, and pushes both to master.
|
tag, and pushes both to master.
|
||||||
|
|
||||||
@@ -288,7 +287,7 @@ Click commands from `cli.py` and verifies each has documentation in
|
|||||||
### `discover_runners.py`
|
### `discover_runners.py`
|
||||||
|
|
||||||
Discovers available Gitea Actions runners at three levels: repository,
|
Discovers available Gitea Actions runners at three levels: repository,
|
||||||
organization, and instance (admin). Falls back to the `MOLECULE_RUNNERS` repo
|
organization, and instance (administrator). Falls back to the `MOLECULE_RUNNERS` repo
|
||||||
variable or `DEFAULT_MAX_RUNNERS` (3). Outputs runner count or a JSON index
|
variable or `DEFAULT_MAX_RUNNERS` (3). Outputs runner count or a JSON index
|
||||||
array for use as a dynamic matrix in Gitea Actions.
|
array for use as a dynamic matrix in Gitea Actions.
|
||||||
|
|
||||||
@@ -300,9 +299,9 @@ Distributes files matching a glob pattern across N parallel runners
|
|||||||
|
|
||||||
### `integration_guard.py`
|
### `integration_guard.py`
|
||||||
|
|
||||||
Runs pytest with the same cross-runner failure detection mechanism used by
|
Runs pytest with cross-runner failure detection. A background thread polls
|
||||||
`molecule_ci_guard`. If any other integration-tests matrix runner reports
|
the Gitea API. If any other integration-tests matrix runner reports failure,
|
||||||
failure, the current pytest subprocess is killed and this runner exits early.
|
the current pytest subprocess is killed and this runner exits early.
|
||||||
|
|
||||||
## Developer tools (`devx.tools`)
|
## Developer tools (`devx.tools`)
|
||||||
|
|
||||||
@@ -330,7 +329,7 @@ Supports `--tool` to install specific tools and `--list` to show status.
|
|||||||
Runs unit tests and enforces execution-time budgets. Two quality gates:
|
Runs unit tests and enforces execution-time budgets. Two quality gates:
|
||||||
total suite time must not exceed `--max-seconds` (default: 10s), and no
|
total suite time must not exceed `--max-seconds` (default: 10s), and no
|
||||||
individual test may exceed `--max-single-seconds` (default: 0.5s, 0 to
|
individual test may exceed `--max-single-seconds` (default: 0.5s, 0 to
|
||||||
disable). Runs `make test-unit` with `PYTEST_ADDOPTS=--durations=0`.
|
off). Runs `make test-unit` with `PYTEST_ADDOPTS=--durations=0`.
|
||||||
|
|
||||||
### `check_test_isolation.py`
|
### `check_test_isolation.py`
|
||||||
|
|
||||||
@@ -382,13 +381,6 @@ the supported OS platform matrix. Supports `--roles-root` for multi-role
|
|||||||
repositories, `--list` to list scenarios, and `--list-platforms` to list
|
repositories, `--list` to list scenarios, and `--list-platforms` to list
|
||||||
platforms.
|
platforms.
|
||||||
|
|
||||||
### `molecule_ci_guard.py`
|
|
||||||
|
|
||||||
Runs molecule tests sequentially while polling the Gitea API for other runner
|
|
||||||
failures. If any other molecule matrix runner reports failure, the current
|
|
||||||
molecule subprocess is killed and this runner exits early. Supports both
|
|
||||||
single-role (4-part) and multi-role (5-part) pair encoding.
|
|
||||||
|
|
||||||
### `molecule_all.py`
|
### `molecule_all.py`
|
||||||
|
|
||||||
Runs all molecule scenarios on all supported OS platforms sequentially.
|
Runs all molecule scenarios on all supported OS platforms sequentially.
|
||||||
|
|||||||
@@ -478,15 +478,6 @@ python -m devx.molecule.distribute_molecule --list
|
|||||||
python -m devx.molecule.distribute_molecule --list-platforms
|
python -m devx.molecule.distribute_molecule --list-platforms
|
||||||
```
|
```
|
||||||
|
|
||||||
### `molecule_ci_guard.py`
|
|
||||||
|
|
||||||
Runs molecule tests sequentially while polling the Gitea API for other runner
|
|
||||||
failures. Aborts early if another runner fails the same job.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
python -m devx.molecule.molecule_ci_guard [--roles-root <dir>] pair1 pair2 ...
|
|
||||||
```
|
|
||||||
|
|
||||||
### `validate_commit_msg.py`
|
### `validate_commit_msg.py`
|
||||||
|
|
||||||
Validates commit messages. On feature branches: conventional commits only
|
Validates commit messages. On feature branches: conventional commits only
|
||||||
|
|||||||
+104
-30
@@ -85,7 +85,7 @@ devx ci detect-release-commit
|
|||||||
|
|
||||||
Discover available Gitea Actions runners for dynamic job distribution.
|
Discover available Gitea Actions runners for dynamic job distribution.
|
||||||
Queries the Gitea API for registered runners at repository, organization, and
|
Queries the Gitea API for registered runners at repository, organization, and
|
||||||
instance (admin) levels. Falls back to `MOLECULE_RUNNERS` repo variable or
|
instance (administrator) levels. Falls back to `MOLECULE_RUNNERS` repo variable or
|
||||||
`DEFAULT_MAX_RUNNERS` (3).
|
`DEFAULT_MAX_RUNNERS` (3).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -315,6 +315,56 @@ devx ci validate-commit-msg commit-msg.txt --branch master
|
|||||||
Options:
|
Options:
|
||||||
- `--branch <branch>` — override branch detection (for CI use)
|
- `--branch <branch>` — override branch detection (for CI use)
|
||||||
|
|
||||||
|
### `devx ci cancel-superseded-runs`
|
||||||
|
|
||||||
|
Cancel in-flight CI runs for the same PR branch when a new push triggers
|
||||||
|
a new run. Uses the Gitea Actions API to list running pull_request runs
|
||||||
|
and cancel those with a lower run ID on the same branch.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
devx ci cancel-superseded-runs \
|
||||||
|
--repo "$REPOSITORY" \
|
||||||
|
--current-run-id "$GITHUB_RUN_ID" \
|
||||||
|
--head-branch "$HEAD_REF"
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `--repo <owner/repo>` — repository (required)
|
||||||
|
- `--current-run-id <id>` — current run ID, not cancelled (required)
|
||||||
|
- `--head-branch <branch>` — PR head branch name (required)
|
||||||
|
- `--dry-run` — list superseded runs without cancelling
|
||||||
|
- `--base-url <url>` — Gitea base URL (default: `GITEA_API_URL` env var)
|
||||||
|
|
||||||
|
### `devx ci check-workflow-artifact-deps`
|
||||||
|
|
||||||
|
Verify that workflow jobs downloading artifacts depend on the uploading
|
||||||
|
job. Prevents the class of bug where a download job runs in parallel
|
||||||
|
with the upload job and fails because the artifact isn't available yet.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
devx ci check-workflow-artifact-deps
|
||||||
|
devx ci check-workflow-artifact-deps --workflow .gitea/workflows/ci.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `--workflow <path>` — check a specific workflow file
|
||||||
|
- `--workflows-dir <path>` — override workflows directory
|
||||||
|
|
||||||
|
### `devx ci check-workflow-tofu-init`
|
||||||
|
|
||||||
|
Verify that workflow jobs using tofu state (tofu output/plan/apply or
|
||||||
|
scripts that call them) have a tofu-init step in the same job.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
devx ci check-workflow-tofu-init
|
||||||
|
devx ci check-workflow-tofu-init --workflow .gitea/workflows/deploy.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `--workflow <path>` — check a specific workflow file
|
||||||
|
- `--workflows-dir <path>` — override workflows directory
|
||||||
|
- `--state-script <name>` — add a script that uses tofu state (repeatable)
|
||||||
|
|
||||||
## Tools Commands
|
## Tools Commands
|
||||||
|
|
||||||
### `devx tools check-test-speed`
|
### `devx tools check-test-speed`
|
||||||
@@ -323,7 +373,7 @@ Run unit tests and enforce execution-time budgets. Two quality gates:
|
|||||||
|
|
||||||
- **Total suite time** must not exceed `--max-seconds` (default: 10s)
|
- **Total suite time** must not exceed `--max-seconds` (default: 10s)
|
||||||
- **Per-test time** — no individual test may exceed `--max-single-seconds`
|
- **Per-test time** — no individual test may exceed `--max-single-seconds`
|
||||||
(default: 0.5s, 0 to disable)
|
(default: 0.5s, 0 to turn off)
|
||||||
|
|
||||||
Runs `make test-unit` with `PYTEST_ADDOPTS=--durations=0` so pytest emits
|
Runs `make test-unit` with `PYTEST_ADDOPTS=--durations=0` so pytest emits
|
||||||
per-test timing lines.
|
per-test timing lines.
|
||||||
@@ -369,7 +419,7 @@ devx tools check-test-isolation --src-dir src/
|
|||||||
|
|
||||||
Pytest plugin options (automatic when devx is installed):
|
Pytest plugin options (automatic when devx is installed):
|
||||||
|
|
||||||
- `--no-test-isolation` — disable static analysis and runtime subprocess audit
|
- `--no-test-isolation` — turn off static analysis and runtime subprocess audit
|
||||||
- `--test-isolation-max-loop N` — max iterations per loop (default: 100)
|
- `--test-isolation-max-loop N` — max iterations per loop (default: 100)
|
||||||
|
|
||||||
### `devx tools configure-repo`
|
### `devx tools configure-repo`
|
||||||
@@ -414,7 +464,7 @@ devx tools generate-cliff-config --prefix GRM --force # overwrite existing
|
|||||||
Options:
|
Options:
|
||||||
- `--prefix <prefix>` — task ID prefix (default: `DEVX_TASK_PREFIX` env var
|
- `--prefix <prefix>` — task ID prefix (default: `DEVX_TASK_PREFIX` env var
|
||||||
or `DEVX`)
|
or `DEVX`)
|
||||||
- `--output <file>` — output file path (default: `cliff.toml`)
|
- `--output <file>` — output path (default: `cliff.toml`)
|
||||||
- `--force` — overwrite existing file
|
- `--force` — overwrite existing file
|
||||||
|
|
||||||
### `devx tools install-checkmake`
|
### `devx tools install-checkmake`
|
||||||
@@ -488,6 +538,56 @@ devx tools pr-rebase # auto-detect PR from current branch
|
|||||||
Options (pass after `--`):
|
Options (pass after `--`):
|
||||||
- `--pr <N>` — PR number (auto-detected from current branch if omitted)
|
- `--pr <N>` — PR number (auto-detected from current branch if omitted)
|
||||||
|
|
||||||
|
### `devx tools check-docker-init`
|
||||||
|
|
||||||
|
Check that Docker Compose services with healthchecks have `init: true`.
|
||||||
|
Without `init: true`, CMD-SHELL healthchecks spawn child processes that
|
||||||
|
become zombies when PID 1 doesn't reap them.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
devx tools check-docker-init
|
||||||
|
devx tools check-docker-init --path path/to/docker-compose.yml.j2
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `--path <path>` — check a specific file or directory
|
||||||
|
- `--templates-dir <path>` — override templates directory (default: `ansible/roles/`)
|
||||||
|
|
||||||
|
### `devx tools check-ansible-set-fact-to-json`
|
||||||
|
|
||||||
|
Check that Ansible `set_fact` tasks don't misuse `| to_json`. Using
|
||||||
|
`to_json` in `set_fact` converts native Python types to JSON strings,
|
||||||
|
causing iteration bugs (for example, iterating over characters instead
|
||||||
|
of list items).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
devx tools check-ansible-set-fact-to-json
|
||||||
|
devx tools check-ansible-set-fact-to-json --path path/to/playbook.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `--path <path>` — check a specific file or directory
|
||||||
|
- `--ansible-dir <path>` — override ansible directories (repeatable)
|
||||||
|
|
||||||
|
### `devx tools check-alert-rules`
|
||||||
|
|
||||||
|
Validate rendered Prometheus alert rules with `promtool check rules`.
|
||||||
|
Renders a Jinja2 template with test values and validates the output.
|
||||||
|
Skips (exits 0) if promtool is not on PATH.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
devx tools check-alert-rules \
|
||||||
|
--template-path ansible/roles/observability/templates
|
||||||
|
devx tools check-alert-rules \
|
||||||
|
--template-path ansible/roles/observability/templates \
|
||||||
|
--var grafana_base_url=https://grafana.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
- `--template-path <path>` — path to templates directory (required)
|
||||||
|
- `--template-name <name>` — template filename (default: `alert-rules.yml.j2`)
|
||||||
|
- `--var key=value` — template variables (repeatable)
|
||||||
|
|
||||||
## Molecule Commands
|
## Molecule Commands
|
||||||
|
|
||||||
Molecule commands require the `molecule` extra (`pip install devx[molecule]`).
|
Molecule commands require the `molecule` extra (`pip install devx[molecule]`).
|
||||||
@@ -531,29 +631,3 @@ Options:
|
|||||||
- `--list-platforms` — list all platforms, one per line
|
- `--list-platforms` — list all platforms, one per line
|
||||||
- `--roles-root <dir>` — roles root directory for multi-role repos (default:
|
- `--roles-root <dir>` — roles root directory for multi-role repos (default:
|
||||||
`ansible/roles`)
|
`ansible/roles`)
|
||||||
|
|
||||||
### `devx molecule guard`
|
|
||||||
|
|
||||||
Run molecule tests sequentially with CI failure polling. A background thread
|
|
||||||
polls the Gitea API. If any other molecule matrix runner reports failure, the
|
|
||||||
current molecule subprocess is killed and this runner exits early with code 1.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
devx molecule guard pair1 pair2 pair3
|
|
||||||
devx molecule guard --roles-root ansible/roles pair1 pair2
|
|
||||||
```
|
|
||||||
|
|
||||||
Each pair is encoded as:
|
|
||||||
- **Single-role (4-part):** `scenario|platform_name|platform_image|platform_command`
|
|
||||||
- **Multi-role (5-part):** `role|scenario|platform_name|platform_image|platform_command`
|
|
||||||
|
|
||||||
Options:
|
|
||||||
- `--roles-root <dir>` — roles root directory for multi-role repos
|
|
||||||
|
|
||||||
Environment variables:
|
|
||||||
- `GITEA_URL` — base URL of the Gitea instance
|
|
||||||
- `CI_GITEA_TOKEN` — API token with repo access
|
|
||||||
- `RUN_ID` — workflow run ID (`GITHUB_RUN_ID`)
|
|
||||||
- `JOB_NAME` — base job name (`GITHUB_JOB`)
|
|
||||||
- `MATRIX_INDEX` — current matrix index (runner-index)
|
|
||||||
- `GITEA_REPOSITORY` — repository in `owner/repo` format
|
|
||||||
|
|||||||
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.48.1",
|
"devx>=0.49.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"devx>=0.48.1",
|
"devx>=0.49.0",
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+9
-4
@@ -20,6 +20,8 @@ dependencies = [
|
|||||||
"python-dotenv==1.2.2",
|
"python-dotenv==1.2.2",
|
||||||
"click==8.4.2",
|
"click==8.4.2",
|
||||||
"tenacity==9.1.4", # retry logic for GiteaClient/VikunjaClient
|
"tenacity==9.1.4", # retry logic for GiteaClient/VikunjaClient
|
||||||
|
"jinja2==3.1.6", # template rendering (devx.utils.jinja, check_alert_rules)
|
||||||
|
"pyyaml==6.0.3", # YAML parsing (workflow checks, ansible checks)
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -62,13 +64,16 @@ molecule = [
|
|||||||
"ansible-core==2.21.1",
|
"ansible-core==2.21.1",
|
||||||
]
|
]
|
||||||
# Deploy tools (for infra staging/production deployments)
|
# Deploy tools (for infra staging/production deployments)
|
||||||
|
# Versions aligned with infra's pyproject.toml to avoid reinstalls on every CI job.
|
||||||
|
# bcrypt and PyJWT are infra deps not in devx core — included here so the CI
|
||||||
|
# image has them and setup-image can use --no-deps (skip dep resolution).
|
||||||
deploy = [
|
deploy = [
|
||||||
"ansible-core==2.21.1",
|
"ansible-core==2.21.1",
|
||||||
"boto3==1.43.37",
|
"boto3==1.43.44",
|
||||||
"docker==7.1.0",
|
"docker==7.1.0",
|
||||||
"jinja2==3.1.6",
|
"cryptography==50.0.0",
|
||||||
"pyyaml==6.0.3",
|
"bcrypt==5.0.0",
|
||||||
"cryptography==49.0.0",
|
"PyJWT==2.13.0",
|
||||||
]
|
]
|
||||||
# Full dev environment (local development)
|
# Full dev environment (local development)
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||||
|
|
||||||
__version__ = "0.48.1"
|
__version__ = "0.49.0"
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
"""Cancel superseded CI runs for the same PR.
|
||||||
|
|
||||||
|
When a new push to a PR branch triggers a new CI run, any in-flight
|
||||||
|
runs for the same PR are wasting runner time. This script cancels
|
||||||
|
all but the latest running CI run for each PR branch.
|
||||||
|
|
||||||
|
Uses the Gitea Actions API:
|
||||||
|
GET /repos/{owner}/{repo}/actions/runs?status=in_progress&event=pull_request
|
||||||
|
POST /repos/{owner}/{repo}/actions/runs/{run_id}/cancel
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
# CI (cancels superseded runs for the current PR):
|
||||||
|
python -m devx.ci.cancel_superseded_runs \\
|
||||||
|
--repo "$REPOSITORY" \\
|
||||||
|
--current-run-id "$GITHUB_RUN_ID" \\
|
||||||
|
--head-branch "$HEAD_REF"
|
||||||
|
|
||||||
|
# Dry-run (lists what would be cancelled without cancelling):
|
||||||
|
python -m devx.ci.cancel_superseded_runs \\
|
||||||
|
--repo "$REPOSITORY" \\
|
||||||
|
--current-run-id "$GITHUB_RUN_ID" \\
|
||||||
|
--head-branch "$HEAD_REF" \\
|
||||||
|
--dry-run
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
_HTTP_NO_CONTENT = 204
|
||||||
|
_HTTP_NOT_FOUND = 404
|
||||||
|
_HTTP_BAD_REQUEST = 400
|
||||||
|
_PAGE_SIZE = 50
|
||||||
|
|
||||||
|
|
||||||
|
def _log(msg: str) -> None:
|
||||||
|
"""Log to stderr."""
|
||||||
|
print(f"[cancel-superseded] {msg}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _api_request(
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
token: str,
|
||||||
|
base_url: str,
|
||||||
|
body: dict | None = None,
|
||||||
|
) -> dict | list:
|
||||||
|
"""Make a Gitea API request."""
|
||||||
|
url = f"{base_url}/api/v1{path}"
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"token {token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
data = json.dumps(body).encode() if body else None
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp: # nosec B310 — authenticated API request to known Gitea instance
|
||||||
|
if resp.status == _HTTP_NO_CONTENT:
|
||||||
|
return {}
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
_log(f"API error {e.code} on {method} {path}: {e.read().decode()[:200]}")
|
||||||
|
raise
|
||||||
|
except urllib.error.URLError as e:
|
||||||
|
_log(f"URL error on {method} {path}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def list_running_runs(repo: str, token: str, base_url: str) -> list[dict]:
|
||||||
|
"""List all running CI runs for pull_request events."""
|
||||||
|
runs: list[dict] = []
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
result = _api_request(
|
||||||
|
"GET",
|
||||||
|
f"/repos/{repo}/actions/runs?status=in_progress&event=pull_request&page={page}&limit=50",
|
||||||
|
token,
|
||||||
|
base_url,
|
||||||
|
)
|
||||||
|
# Gitea returns {"workflow_runs": [...], "total_count": N}
|
||||||
|
page_runs = result["workflow_runs"] if isinstance(result, dict) else result
|
||||||
|
if not page_runs:
|
||||||
|
break
|
||||||
|
runs.extend(page_runs)
|
||||||
|
if len(page_runs) < _PAGE_SIZE:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return runs
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_run(repo: str, run_id: int, token: str, base_url: str) -> bool:
|
||||||
|
"""Cancel a CI run. Returns True on success."""
|
||||||
|
try:
|
||||||
|
_api_request(
|
||||||
|
"POST",
|
||||||
|
f"/repos/{repo}/actions/runs/{run_id}/cancel",
|
||||||
|
token,
|
||||||
|
base_url,
|
||||||
|
)
|
||||||
|
except (urllib.error.HTTPError, urllib.error.URLError):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Cancel superseded CI runs for the same PR.")
|
||||||
|
parser.add_argument("--repo", required=True, help="owner/repo")
|
||||||
|
parser.add_argument("--current-run-id", required=True, help="Current run ID (not cancelled)")
|
||||||
|
parser.add_argument("--head-branch", required=True, help="PR head branch name")
|
||||||
|
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"),
|
||||||
|
help="Gitea base URL",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
token = os.environ.get("CI_GITEA_API_TOKEN") or os.environ.get("CI_GITEA_TOKEN")
|
||||||
|
if not token:
|
||||||
|
_log("No CI_GITEA_API_TOKEN or CI_GITEA_TOKEN set — skipping")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
current_run_id = int(args.current_run_id)
|
||||||
|
|
||||||
|
_log(f"Listing running PR runs for {args.repo}...")
|
||||||
|
try:
|
||||||
|
runs = list_running_runs(args.repo, token, args.base_url)
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code in (_HTTP_NOT_FOUND, _HTTP_BAD_REQUEST):
|
||||||
|
_log(
|
||||||
|
f"Actions runs API not usable (HTTP {e.code}) — "
|
||||||
|
f"Gitea {args.base_url} may not support this endpoint or status filter. "
|
||||||
|
f"Skipping cancel-superseded (non-fatal)."
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
raise
|
||||||
|
_log(f"Found {len(runs)} running PR runs")
|
||||||
|
|
||||||
|
# Group by head_branch — only cancel runs for the SAME branch
|
||||||
|
# that are older than the current run
|
||||||
|
same_branch_runs = [
|
||||||
|
r
|
||||||
|
for r in runs
|
||||||
|
if r.get("head_branch") == args.head_branch
|
||||||
|
and int(r.get("id", 0)) != current_run_id
|
||||||
|
and int(r.get("id", 0)) < current_run_id
|
||||||
|
]
|
||||||
|
|
||||||
|
if not same_branch_runs:
|
||||||
|
_log(f"No superseded runs for branch {args.head_branch}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
_log(f"Found {len(same_branch_runs)} superseded run(s) for branch {args.head_branch}:")
|
||||||
|
for r in same_branch_runs:
|
||||||
|
run_id = r.get("id")
|
||||||
|
created = r.get("created_at", "?")
|
||||||
|
_log(f" Run #{run_id} (created: {created})")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
_log("[dry-run] Would cancel the above runs")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
cancelled = 0
|
||||||
|
for r in same_branch_runs:
|
||||||
|
run_id = int(r["id"])
|
||||||
|
_log(f"Cancelling run #{run_id}...")
|
||||||
|
if cancel_run(args.repo, run_id, token, args.base_url):
|
||||||
|
cancelled += 1
|
||||||
|
_log(f" Cancelled run #{run_id}")
|
||||||
|
else:
|
||||||
|
_log(f" Failed to cancel run #{run_id}")
|
||||||
|
|
||||||
|
_log(f"Cancelled {cancelled}/{len(same_branch_runs)} superseded runs")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""Check that workflow jobs downloading artifacts depend on the uploading job.
|
||||||
|
|
||||||
|
This prevents the class of bug where a job downloads an artifact produced by
|
||||||
|
another job but does not declare that job in its ``needs`` list. When both
|
||||||
|
jobs run in parallel, the download fails because the artifact hasn't been
|
||||||
|
uploaded yet.
|
||||||
|
|
||||||
|
The check scans all workflow YAML files for:
|
||||||
|
- ``gitea-upload-artifact`` / ``actions/upload-artifact`` steps
|
||||||
|
- ``gitea-download-artifact`` / ``actions/download-artifact`` steps
|
||||||
|
|
||||||
|
For each download, it finds the job(s) that upload an artifact with a
|
||||||
|
matching name and verifies that at least one uploading job is in the
|
||||||
|
downloading job's ``needs`` list.
|
||||||
|
|
||||||
|
Artifact names with ``${{ ... }}`` expressions are matched literally
|
||||||
|
(both sides use the same expression, so they resolve to the same value
|
||||||
|
at runtime).
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python -m devx.ci.check_workflow_artifact_deps
|
||||||
|
python -m devx.ci.check_workflow_artifact_deps --workflow .gitea/workflows/ci.yml
|
||||||
|
|
||||||
|
Exit code 0 if all artifact dependencies are satisfied, 1 otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
REPO_ROOT = Path.cwd()
|
||||||
|
WORKFLOWS_DIR = REPO_ROOT / ".gitea" / "workflows"
|
||||||
|
|
||||||
|
UPLOAD_ACTIONS = ("upload-artifact",)
|
||||||
|
DOWNLOAD_ACTIONS = ("download-artifact",)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_artifact_action(uses: str, action_types: tuple[str, ...]) -> bool:
|
||||||
|
"""Check if a step's ``uses`` field references an artifact action."""
|
||||||
|
if not uses:
|
||||||
|
return False
|
||||||
|
uses_lower = uses.lower()
|
||||||
|
return any(action in uses_lower for action in action_types)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_artifact_info(workflow: dict) -> tuple[dict[str, list[str]], list[tuple[str, str, str]]]:
|
||||||
|
"""Extract artifact upload and download info from a workflow.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
uploads: Mapping of artifact_name → list of job names that upload it.
|
||||||
|
downloads: List of (job_name, artifact_name, step_name) tuples.
|
||||||
|
"""
|
||||||
|
uploads: dict[str, list[str]] = {}
|
||||||
|
downloads: list[tuple[str, str, str]] = []
|
||||||
|
|
||||||
|
jobs = workflow.get("jobs", {})
|
||||||
|
for job_name, job_def in jobs.items():
|
||||||
|
for step in job_def.get("steps", []):
|
||||||
|
uses = step.get("uses", "")
|
||||||
|
with_data = step.get("with", {})
|
||||||
|
artifact_name = with_data.get("name", "")
|
||||||
|
step_name = step.get("name", "")
|
||||||
|
|
||||||
|
if _is_artifact_action(uses, UPLOAD_ACTIONS):
|
||||||
|
if artifact_name:
|
||||||
|
uploads.setdefault(artifact_name, []).append(job_name)
|
||||||
|
elif _is_artifact_action(uses, DOWNLOAD_ACTIONS) and artifact_name:
|
||||||
|
downloads.append((job_name, artifact_name, step_name))
|
||||||
|
|
||||||
|
return uploads, downloads
|
||||||
|
|
||||||
|
|
||||||
|
def _check_workflow(filepath: Path) -> list[str]:
|
||||||
|
"""Check a single workflow file for missing artifact dependencies.
|
||||||
|
|
||||||
|
Returns a list of error messages (empty if all OK).
|
||||||
|
"""
|
||||||
|
errors: list[str] = []
|
||||||
|
content = filepath.read_text(encoding="utf-8")
|
||||||
|
try:
|
||||||
|
workflow = yaml.safe_load(content)
|
||||||
|
except yaml.YAMLError as exc:
|
||||||
|
return [f"{filepath}: cannot parse YAML: {exc}"]
|
||||||
|
|
||||||
|
if not isinstance(workflow, dict):
|
||||||
|
return [f"{filepath}: not a valid workflow (expected dict)"]
|
||||||
|
|
||||||
|
uploads, downloads = _extract_artifact_info(workflow)
|
||||||
|
jobs = workflow.get("jobs", {})
|
||||||
|
|
||||||
|
for dl_job, artifact_name, step_name in downloads:
|
||||||
|
uploading_jobs = uploads.get(artifact_name, [])
|
||||||
|
if not uploading_jobs:
|
||||||
|
# Artifact not uploaded in this workflow — may come from an
|
||||||
|
# external source (e.g., S3). Skip.
|
||||||
|
continue
|
||||||
|
|
||||||
|
dl_job_def = jobs.get(dl_job, {})
|
||||||
|
needs_raw = dl_job_def.get("needs", [])
|
||||||
|
needs = {needs_raw} if isinstance(needs_raw, str) else set(needs_raw or [])
|
||||||
|
|
||||||
|
# Check if any uploading job is in the download job's needs
|
||||||
|
if not any(uploader in needs for uploader in uploading_jobs):
|
||||||
|
# Check if the download step has continue-on-error: true
|
||||||
|
# (valid guard when the uploading job may be skipped due to
|
||||||
|
# Gitea Actions' needs skip behavior — the download will
|
||||||
|
# fail gracefully if the artifact doesn't exist).
|
||||||
|
dl_steps = dl_job_def.get("steps", [])
|
||||||
|
step_def = next((s for s in dl_steps if s.get("name", "") == step_name), {})
|
||||||
|
if step_def.get("continue-on-error") is True:
|
||||||
|
continue
|
||||||
|
|
||||||
|
uploaders_str = ", ".join(sorted(uploading_jobs))
|
||||||
|
errors.append(
|
||||||
|
f"{filepath.name}::{dl_job}: step '{step_name}' downloads "
|
||||||
|
f"artifact '{artifact_name}' produced by job(s) "
|
||||||
|
f"[{uploaders_str}] but none are in its 'needs' list "
|
||||||
|
f"(current needs: {sorted(needs) or 'none'}). "
|
||||||
|
f"Add the uploading job to 'needs' or guard the download "
|
||||||
|
f"with an if: condition checking the upload job's result."
|
||||||
|
)
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
"--workflow",
|
||||||
|
type=click.Path(exists=True, path_type=Path),
|
||||||
|
help="Check a specific workflow file (default: all in .gitea/workflows/).",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--workflows-dir",
|
||||||
|
type=click.Path(exists=True, path_type=Path),
|
||||||
|
default=None,
|
||||||
|
help="Override the workflows directory (default: .gitea/workflows/).",
|
||||||
|
)
|
||||||
|
def main(workflow: Path | None, workflows_dir: Path | None) -> None:
|
||||||
|
"""Check that artifact download jobs depend on upload jobs."""
|
||||||
|
wdir = workflows_dir or WORKFLOWS_DIR
|
||||||
|
files = [workflow] if workflow else sorted(wdir.glob("*.yml"))
|
||||||
|
|
||||||
|
all_errors: list[str] = []
|
||||||
|
for f in files:
|
||||||
|
errors = _check_workflow(f)
|
||||||
|
all_errors.extend(errors)
|
||||||
|
|
||||||
|
if all_errors:
|
||||||
|
click.echo("[check-workflow-artifact-deps] FAIL: missing artifact dependencies found:")
|
||||||
|
for err in all_errors:
|
||||||
|
click.echo(f" - {err}")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
click.echo("[check-workflow-artifact-deps] OK: all artifact downloads have upload jobs in needs.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
main()
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Check that workflow jobs using tofu state have a tofu-init step.
|
||||||
|
|
||||||
|
This prevents the class of bug where a job runs ``tofu output`` or calls
|
||||||
|
a script that uses tofu state without first running ``tofu init``,
|
||||||
|
causing "Required plugins are not installed" errors.
|
||||||
|
|
||||||
|
The check scans all workflow YAML files for jobs that:
|
||||||
|
- Call scripts that use ``tofu output`` (configurable via --state-scripts)
|
||||||
|
- Call ``tofu output`` directly
|
||||||
|
- Call ``tofu plan`` or ``tofu apply`` directly
|
||||||
|
|
||||||
|
For each such job, it verifies the same job has a ``tofu-init`` step,
|
||||||
|
either:
|
||||||
|
- Directly via ``tofu init`` in a step's run command
|
||||||
|
- Via ``create_staging_deployment.py --phase tofu-init``
|
||||||
|
- Via ``create_production_deployment.py --phase tofu-init``
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python -m devx.ci.check_workflow_tofu_init
|
||||||
|
python -m devx.ci.check_workflow_tofu_init --workflow .gitea/workflows/deploy.yml
|
||||||
|
|
||||||
|
Exit code 0 if all jobs have tofu-init, 1 otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
REPO_ROOT = Path.cwd()
|
||||||
|
WORKFLOWS_DIR = REPO_ROOT / ".gitea" / "workflows"
|
||||||
|
|
||||||
|
# Scripts that call `tofu output`, `tofu plan`, or `tofu apply` internally.
|
||||||
|
# If a job calls any of these, it must have a tofu-init step.
|
||||||
|
# NOTE: destroy_orphans.py reads terraform.tfstate directly from disk
|
||||||
|
# (does not invoke `tofu output`), so it does NOT need tofu-init.
|
||||||
|
DEFAULT_TOFU_STATE_SCRIPTS: set[str] = {
|
||||||
|
"preflight_deploy.py",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Commands that directly use tofu state (must be preceded by tofu init).
|
||||||
|
TOFU_STATE_COMMANDS = ("tofu output", "tofu plan", "tofu apply", "tofu show")
|
||||||
|
|
||||||
|
# Commands that initialize tofu (counted as tofu-init steps).
|
||||||
|
TOFU_INIT_COMMANDS = (
|
||||||
|
"tofu init",
|
||||||
|
"--phase tofu-init",
|
||||||
|
"tofu-init",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_workflow(filepath: Path, state_scripts: set[str]) -> list[str]:
|
||||||
|
"""Check a single workflow file for missing tofu-init steps.
|
||||||
|
|
||||||
|
Returns a list of error messages (empty if all OK).
|
||||||
|
"""
|
||||||
|
errors: list[str] = []
|
||||||
|
content = filepath.read_text(encoding="utf-8")
|
||||||
|
try:
|
||||||
|
workflow = yaml.safe_load(content)
|
||||||
|
except yaml.YAMLError as exc:
|
||||||
|
return [f"{filepath}: cannot parse YAML: {exc}"]
|
||||||
|
|
||||||
|
jobs = workflow.get("jobs", {})
|
||||||
|
for job_name, job_def in jobs.items():
|
||||||
|
steps = job_def.get("steps", [])
|
||||||
|
if not steps:
|
||||||
|
continue
|
||||||
|
|
||||||
|
uses_tofu_state = False
|
||||||
|
has_tofu_init = False
|
||||||
|
|
||||||
|
for step in steps:
|
||||||
|
run_cmd = step.get("run", "")
|
||||||
|
if not run_cmd:
|
||||||
|
continue
|
||||||
|
# Check if this step uses tofu state
|
||||||
|
for script in state_scripts:
|
||||||
|
if script in run_cmd:
|
||||||
|
uses_tofu_state = True
|
||||||
|
for cmd in TOFU_STATE_COMMANDS:
|
||||||
|
if cmd in run_cmd:
|
||||||
|
uses_tofu_state = True
|
||||||
|
# Check if this step initializes tofu
|
||||||
|
for cmd in TOFU_INIT_COMMANDS:
|
||||||
|
if cmd in run_cmd:
|
||||||
|
has_tofu_init = True
|
||||||
|
|
||||||
|
if uses_tofu_state and not has_tofu_init:
|
||||||
|
errors.append(
|
||||||
|
f"{filepath.name}::{job_name}: uses tofu state "
|
||||||
|
f"(tofu output/plan/apply or {state_scripts}) "
|
||||||
|
f"but has no tofu-init step. Add a step running "
|
||||||
|
f"'create_*_deployment.py --phase tofu-init' before "
|
||||||
|
f"the first tofu state access."
|
||||||
|
)
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
"--workflow",
|
||||||
|
type=click.Path(exists=True, path_type=Path),
|
||||||
|
help="Check a specific workflow file (default: all in .gitea/workflows/).",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--workflows-dir",
|
||||||
|
type=click.Path(exists=True, path_type=Path),
|
||||||
|
default=None,
|
||||||
|
help="Override the workflows directory (default: .gitea/workflows/).",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--state-script",
|
||||||
|
"state_scripts",
|
||||||
|
multiple=True,
|
||||||
|
default=None,
|
||||||
|
help="Add a script name that uses tofu state (can be repeated). Overrides the default list if any are specified.",
|
||||||
|
)
|
||||||
|
def main(workflow: Path | None, workflows_dir: Path | None, state_scripts: tuple[str, ...]) -> None:
|
||||||
|
"""Check that workflow jobs using tofu state have a tofu-init step."""
|
||||||
|
scripts = set(state_scripts) if state_scripts else DEFAULT_TOFU_STATE_SCRIPTS
|
||||||
|
wdir = workflows_dir or WORKFLOWS_DIR
|
||||||
|
files = [workflow] if workflow else sorted(wdir.glob("*.yml"))
|
||||||
|
|
||||||
|
all_errors: list[str] = []
|
||||||
|
for f in files:
|
||||||
|
errors = _check_workflow(f, scripts)
|
||||||
|
all_errors.extend(errors)
|
||||||
|
|
||||||
|
if all_errors:
|
||||||
|
click.echo("[check-workflow-tofu-init] FAIL: missing tofu-init steps found:")
|
||||||
|
for err in all_errors:
|
||||||
|
click.echo(f" - {err}")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
click.echo("[check-workflow-tofu-init] OK: all tofu-state jobs have tofu-init.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
main()
|
||||||
@@ -52,7 +52,6 @@ REQUIRED_SCRIPTS = [
|
|||||||
"detect_release_commit.py",
|
"detect_release_commit.py",
|
||||||
"push_badges.py",
|
"push_badges.py",
|
||||||
"distribute_molecule.py",
|
"distribute_molecule.py",
|
||||||
"molecule_ci_guard.py",
|
|
||||||
"validate_commit_msg.py",
|
"validate_commit_msg.py",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Run integration tests with cross-runner failure detection.
|
"""Run integration tests with cross-runner failure detection.
|
||||||
|
|
||||||
Wraps ``pytest`` with the same Gitea API polling mechanism used by
|
Wraps ``pytest`` with Gitea API polling. If any other integration-tests
|
||||||
``molecule_ci_guard``. If any other integration-tests matrix runner
|
matrix runner reports failure, the current pytest subprocess is killed
|
||||||
reports failure, the current pytest subprocess is killed and this runner
|
and this runner exits early with code 1.
|
||||||
exits early with code 1.
|
|
||||||
|
|
||||||
Usage::
|
Usage::
|
||||||
|
|
||||||
@@ -35,17 +34,62 @@ import threading
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
import requests
|
||||||
|
|
||||||
from devx.config import REPO_NAME, REPO_OWNER
|
from devx.config import REPO_NAME, REPO_OWNER
|
||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
from devx.molecule.molecule_ci_guard import (
|
|
||||||
poll_for_other_failures,
|
|
||||||
)
|
|
||||||
from devx.tokens import get_ci_token
|
from devx.tokens import get_ci_token
|
||||||
|
|
||||||
POLL_INTERVAL = 10
|
POLL_INTERVAL = 10
|
||||||
|
|
||||||
|
|
||||||
|
def get_running_jobs(gitea_url: str, owner: str, repo: str, token: str, run_id: int) -> list[dict]:
|
||||||
|
"""Return jobs for the given workflow run."""
|
||||||
|
url = f"{gitea_url}/api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/jobs"
|
||||||
|
headers = {"Authorization": f"token {token}"}
|
||||||
|
response = requests.get(url, headers=headers, timeout=30)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return data.get("jobs", [])
|
||||||
|
|
||||||
|
|
||||||
|
def any_other_runner_failed(jobs: list[dict], current_job_name: str, current_index: int) -> bool:
|
||||||
|
"""Return True if any other matrix job has failed."""
|
||||||
|
for job in jobs:
|
||||||
|
name = job.get("name", "")
|
||||||
|
if not name.startswith(current_job_name):
|
||||||
|
continue
|
||||||
|
if name == f"{current_job_name} ({current_index})" or name == current_job_name:
|
||||||
|
continue
|
||||||
|
if job.get("conclusion") == "failure":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def poll_for_other_failures(
|
||||||
|
gitea_url: str,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
token: str,
|
||||||
|
run_id: int,
|
||||||
|
job_name: str,
|
||||||
|
current_index: int,
|
||||||
|
stop_event: threading.Event,
|
||||||
|
failed_event: threading.Event,
|
||||||
|
) -> None:
|
||||||
|
"""Background thread: poll API and signal if another runner fails."""
|
||||||
|
while not stop_event.is_set():
|
||||||
|
try:
|
||||||
|
jobs = get_running_jobs(gitea_url, owner, repo, token, run_id)
|
||||||
|
if any_other_runner_failed(jobs, job_name, current_index):
|
||||||
|
click.echo(_("Another runner failed. Stopping this runner early."))
|
||||||
|
failed_event.set()
|
||||||
|
return
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
click.echo(_("API poll warning: {exc}", exc=exc))
|
||||||
|
stop_event.wait(POLL_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
@click.command(context_settings={"ignore_unknown_options": True})
|
@click.command(context_settings={"ignore_unknown_options": True})
|
||||||
@click.argument("pytest_args", nargs=-1, type=click.UNPROCESSED, required=True)
|
@click.argument("pytest_args", nargs=-1, type=click.UNPROCESSED, required=True)
|
||||||
def cli(pytest_args: tuple[str, ...]) -> None:
|
def cli(pytest_args: tuple[str, ...]) -> None:
|
||||||
|
|||||||
+42
-7
@@ -172,6 +172,27 @@ def ci_integration_guard(args: tuple[str, ...]) -> None:
|
|||||||
_run_module("devx.ci.integration_guard", list(args))
|
_run_module("devx.ci.integration_guard", list(args))
|
||||||
|
|
||||||
|
|
||||||
|
@ci.command("cancel-superseded-runs")
|
||||||
|
@click.argument("args", nargs=-1)
|
||||||
|
def ci_cancel_superseded_runs(args: tuple[str, ...]) -> None:
|
||||||
|
"""Cancel superseded CI runs for the same PR branch."""
|
||||||
|
_run_module("devx.ci.cancel_superseded_runs", list(args))
|
||||||
|
|
||||||
|
|
||||||
|
@ci.command("check-workflow-artifact-deps")
|
||||||
|
@click.argument("args", nargs=-1)
|
||||||
|
def ci_check_workflow_artifact_deps(args: tuple[str, ...]) -> None:
|
||||||
|
"""Check that artifact download jobs depend on upload jobs."""
|
||||||
|
_run_module("devx.ci.check_workflow_artifact_deps", list(args))
|
||||||
|
|
||||||
|
|
||||||
|
@ci.command("check-workflow-tofu-init")
|
||||||
|
@click.argument("args", nargs=-1)
|
||||||
|
def ci_check_workflow_tofu_init(args: tuple[str, ...]) -> None:
|
||||||
|
"""Check that workflow jobs using tofu state have a tofu-init step."""
|
||||||
|
_run_module("devx.ci.check_workflow_tofu_init", list(args))
|
||||||
|
|
||||||
|
|
||||||
@cli.group()
|
@cli.group()
|
||||||
def tools() -> None:
|
def tools() -> None:
|
||||||
"""Development tool commands."""
|
"""Development tool commands."""
|
||||||
@@ -240,6 +261,27 @@ def tools_pr_rebase(args: tuple[str, ...]) -> None:
|
|||||||
_run_module("devx.tools.pr_rebase", list(args))
|
_run_module("devx.tools.pr_rebase", list(args))
|
||||||
|
|
||||||
|
|
||||||
|
@tools.command("check-docker-init")
|
||||||
|
@click.argument("args", nargs=-1)
|
||||||
|
def tools_check_docker_init(args: tuple[str, ...]) -> None:
|
||||||
|
"""Check that Docker Compose services with healthchecks have init: true."""
|
||||||
|
_run_module("devx.tools.check_docker_init", list(args))
|
||||||
|
|
||||||
|
|
||||||
|
@tools.command("check-ansible-set-fact-to-json")
|
||||||
|
@click.argument("args", nargs=-1)
|
||||||
|
def tools_check_ansible_set_fact_to_json(args: tuple[str, ...]) -> None:
|
||||||
|
"""Check that Ansible set_fact tasks don't misuse to_json."""
|
||||||
|
_run_module("devx.tools.check_ansible_set_fact_to_json", list(args))
|
||||||
|
|
||||||
|
|
||||||
|
@tools.command("check-alert-rules")
|
||||||
|
@click.argument("args", nargs=-1)
|
||||||
|
def tools_check_alert_rules(args: tuple[str, ...]) -> None:
|
||||||
|
"""Validate rendered Prometheus alert rules with promtool."""
|
||||||
|
_run_module("devx.tools.check_alert_rules", list(args))
|
||||||
|
|
||||||
|
|
||||||
@cli.group()
|
@cli.group()
|
||||||
def molecule() -> None:
|
def molecule() -> None:
|
||||||
"""Molecule testing commands (requires devx[molecule])."""
|
"""Molecule testing commands (requires devx[molecule])."""
|
||||||
@@ -259,13 +301,6 @@ def molecule_discover_runners(args: tuple[str, ...]) -> None:
|
|||||||
_run_module("devx.molecule.discover_runners", list(args))
|
_run_module("devx.molecule.discover_runners", list(args))
|
||||||
|
|
||||||
|
|
||||||
@molecule.command("guard")
|
|
||||||
@click.argument("args", nargs=-1)
|
|
||||||
def molecule_guard(args: tuple[str, ...]) -> None:
|
|
||||||
"""Run molecule tests sequentially with CI failure polling."""
|
|
||||||
_run_module("devx.molecule.molecule_ci_guard", list(args))
|
|
||||||
|
|
||||||
|
|
||||||
@molecule.command("all")
|
@molecule.command("all")
|
||||||
@click.argument("args", nargs=-1)
|
@click.argument("args", nargs=-1)
|
||||||
def molecule_all(args: tuple[str, ...]) -> None:
|
def molecule_all(args: tuple[str, ...]) -> None:
|
||||||
|
|||||||
+34
-5
@@ -6,6 +6,10 @@ Supported: en, bg, de, ru, zh, pl.
|
|||||||
Projects can extend translations by setting DEVX_TRANSLATIONS_PATH to a
|
Projects can extend translations by setting DEVX_TRANSLATIONS_PATH to a
|
||||||
JSON file with additional keys. Keys from the project's file are merged
|
JSON file with additional keys. Keys from the project's file are merged
|
||||||
on top of devx's built-in translations.
|
on top of devx's built-in translations.
|
||||||
|
|
||||||
|
Projects that use different env var names (e.g. GRM_LANG instead of
|
||||||
|
DEVX_LANG) can call :func:`configure_i18n` at import time to override
|
||||||
|
the defaults.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -14,15 +18,39 @@ import json
|
|||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Configurable env var names — projects can override via configure_i18n()
|
||||||
|
_lang_env_var = "DEVX_LANG"
|
||||||
|
_translations_path_env_var = "DEVX_TRANSLATIONS_PATH"
|
||||||
|
|
||||||
# Load built-in translations
|
# Load built-in translations
|
||||||
_BUILTIN_TRANSLATIONS: dict[str, dict[str, str]] = json.loads(
|
_BUILTIN_TRANSLATIONS: dict[str, dict[str, str]] = json.loads(
|
||||||
(Path(__file__).parent / "translations.json").read_text(encoding="utf-8")
|
(Path(__file__).parent / "translations.json").read_text(encoding="utf-8")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_i18n(
|
||||||
|
*,
|
||||||
|
lang_env_var: str = "DEVX_LANG",
|
||||||
|
translations_path_env_var: str = "DEVX_TRANSLATIONS_PATH",
|
||||||
|
) -> None:
|
||||||
|
"""Override the env var names used for language and translations path.
|
||||||
|
|
||||||
|
This allows downstream projects (e.g. grm) to use their own env var
|
||||||
|
names (e.g. ``GRM_LANG``) while still using devx's i18n system.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lang_env_var: Environment variable name for language selection.
|
||||||
|
translations_path_env_var: Environment variable name for the
|
||||||
|
path to a JSON file with project-specific translations.
|
||||||
|
"""
|
||||||
|
global _lang_env_var, _translations_path_env_var
|
||||||
|
_lang_env_var = lang_env_var
|
||||||
|
_translations_path_env_var = translations_path_env_var
|
||||||
|
|
||||||
|
|
||||||
def _load_project_translations() -> dict[str, dict[str, str]]:
|
def _load_project_translations() -> dict[str, dict[str, str]]:
|
||||||
"""Load project-specific translations from DEVX_TRANSLATIONS_PATH if set."""
|
"""Load project-specific translations from the configured env var if set."""
|
||||||
path = os.getenv("DEVX_TRANSLATIONS_PATH")
|
path = os.getenv(_translations_path_env_var)
|
||||||
if not path:
|
if not path:
|
||||||
return {}
|
return {}
|
||||||
p = Path(path)
|
p = Path(path)
|
||||||
@@ -41,10 +69,11 @@ TRANSLATIONS: dict[str, dict[str, str]] = {**_BUILTIN_TRANSLATIONS, **_load_proj
|
|||||||
def _(key: str, **kwargs: object) -> str:
|
def _(key: str, **kwargs: object) -> str:
|
||||||
"""Return a translated string for the given key.
|
"""Return a translated string for the given key.
|
||||||
|
|
||||||
Translation is opt-in via the ``DEVX_LANG`` environment variable.
|
Translation is opt-in via the configured language environment variable
|
||||||
If unset, English is always returned regardless of system locale.
|
(default ``DEVX_LANG``). If unset, English is always returned regardless
|
||||||
|
of system locale.
|
||||||
"""
|
"""
|
||||||
lang = os.getenv("DEVX_LANG", "en")
|
lang = os.getenv(_lang_env_var, "en")
|
||||||
if lang not in ("en", "bg", "de", "ru", "zh", "pl"):
|
if lang not in ("en", "bg", "de", "ru", "zh", "pl"):
|
||||||
lang = "en"
|
lang = "en"
|
||||||
template = TRANSLATIONS.get(key, {}).get(lang, key)
|
template = TRANSLATIONS.get(key, {}).get(lang, key)
|
||||||
|
|||||||
@@ -104,21 +104,36 @@ def discover_scenarios(root: Path | None = None) -> list[str]:
|
|||||||
return sorted(scenarios)
|
return sorted(scenarios)
|
||||||
|
|
||||||
|
|
||||||
def discover_multi_role_scenarios(roles_root: Path | None = None) -> list[tuple[str, str]]:
|
def discover_multi_role_scenarios(
|
||||||
|
roles_root: Path | None = None,
|
||||||
|
include_roles: list[str] | None = None,
|
||||||
|
exclude_roles: list[str] | None = None,
|
||||||
|
) -> list[tuple[str, str]]:
|
||||||
"""Discover (role, scenario) pairs across all roles under *roles_root*.
|
"""Discover (role, scenario) pairs across all roles under *roles_root*.
|
||||||
|
|
||||||
Scans ``roles_root/*/molecule/*/`` for scenario directories, skipping
|
Scans ``roles_root/*/molecule/*/`` for scenario directories, skipping
|
||||||
``common`` and directories starting with ``_``. Returns a sorted list of
|
``common`` and directories starting with ``_``. Returns a sorted list of
|
||||||
``(role_name, scenario_name)`` tuples.
|
``(role_name, scenario_name)`` tuples.
|
||||||
|
|
||||||
|
If *include_roles* is given, only roles whose name is in the list are
|
||||||
|
returned. If *exclude_roles* is given, roles whose name is in the list
|
||||||
|
are skipped. Both filters are case-insensitive.
|
||||||
"""
|
"""
|
||||||
if roles_root is None:
|
if roles_root is None:
|
||||||
roles_root = DEFAULT_ROLES_ROOT
|
roles_root = DEFAULT_ROLES_ROOT
|
||||||
if not roles_root.is_dir():
|
if not roles_root.is_dir():
|
||||||
raise click.ClickException(_("Roles directory not found: {path}", path=str(roles_root)))
|
raise click.ClickException(_("Roles directory not found: {path}", path=str(roles_root)))
|
||||||
|
include_set = {r.lower() for r in include_roles} if include_roles else None
|
||||||
|
exclude_set = {r.lower() for r in exclude_roles} if exclude_roles else None
|
||||||
pairs: list[tuple[str, str]] = []
|
pairs: list[tuple[str, str]] = []
|
||||||
for role_dir in sorted(roles_root.iterdir()):
|
for role_dir in sorted(roles_root.iterdir()):
|
||||||
if not role_dir.is_dir():
|
if not role_dir.is_dir():
|
||||||
continue
|
continue
|
||||||
|
role_name = role_dir.name
|
||||||
|
if include_set is not None and role_name.lower() not in include_set:
|
||||||
|
continue
|
||||||
|
if exclude_set is not None and role_name.lower() in exclude_set:
|
||||||
|
continue
|
||||||
mol_dir = role_dir / "molecule"
|
mol_dir = role_dir / "molecule"
|
||||||
if not mol_dir.is_dir():
|
if not mol_dir.is_dir():
|
||||||
continue
|
continue
|
||||||
@@ -348,6 +363,24 @@ def _write_github_env(key: str, value: str) -> None:
|
|||||||
help="JSON file with custom platform list (each entry: name, image, command). "
|
help="JSON file with custom platform list (each entry: name, image, command). "
|
||||||
"Overrides the default platform matrix. Useful for projects with custom test images.",
|
"Overrides the default platform matrix. Useful for projects with custom test images.",
|
||||||
)
|
)
|
||||||
|
@click.option(
|
||||||
|
"--include-roles",
|
||||||
|
"include_roles",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help="Comma-separated list of role names to include (multi-role mode only). "
|
||||||
|
"Only scenarios from these roles are distributed. Case-insensitive. "
|
||||||
|
"Example: --include-roles docker_base,crowdsec,disk_cleanup,app_hardening",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--exclude-roles",
|
||||||
|
"exclude_roles",
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help="Comma-separated list of role names to exclude (multi-role mode only). "
|
||||||
|
"Scenarios from these roles are skipped. Case-insensitive. "
|
||||||
|
"Example: --exclude-roles docker_base,crowdsec,disk_cleanup,app_hardening",
|
||||||
|
)
|
||||||
def cli(
|
def cli(
|
||||||
runner_index: int | None,
|
runner_index: int | None,
|
||||||
max_runners: int,
|
max_runners: int,
|
||||||
@@ -358,11 +391,18 @@ def cli(
|
|||||||
molecule_root: Path | None,
|
molecule_root: Path | None,
|
||||||
roles_root: Path | None,
|
roles_root: Path | None,
|
||||||
platforms_file: Path | None,
|
platforms_file: Path | None,
|
||||||
|
include_roles: str | None,
|
||||||
|
exclude_roles: str | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
platforms = load_platforms(platforms_file)
|
platforms = load_platforms(platforms_file)
|
||||||
|
# Parse role filters
|
||||||
|
include_list = [r.strip() for r in include_roles.split(",")] if include_roles else None
|
||||||
|
exclude_list = [r.strip() for r in exclude_roles.split(",")] if exclude_roles else None
|
||||||
# Multi-role mode: discover (role, scenario) pairs across all roles
|
# Multi-role mode: discover (role, scenario) pairs across all roles
|
||||||
if roles_root is not None:
|
if roles_root is not None:
|
||||||
role_scenarios = discover_multi_role_scenarios(roles_root)
|
role_scenarios = discover_multi_role_scenarios(
|
||||||
|
roles_root, include_roles=include_list, exclude_roles=exclude_list
|
||||||
|
)
|
||||||
if list_all:
|
if list_all:
|
||||||
for role, scenario in role_scenarios:
|
for role, scenario in role_scenarios:
|
||||||
click.echo(f"{role}|{scenario}")
|
click.echo(f"{role}|{scenario}")
|
||||||
|
|||||||
@@ -1,333 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Run molecule tests sequentially while polling Gitea for other runner failures.
|
|
||||||
|
|
||||||
Each pair is encoded as one of:
|
|
||||||
|
|
||||||
- **Single-role (4-part):** ``scenario|platform_name|platform_image|platform_command``
|
|
||||||
- **Multi-role (5-part):** ``role|scenario|platform_name|platform_image|platform_command``
|
|
||||||
|
|
||||||
Pairs are executed one at a time (molecule scenarios share temp directories and
|
|
||||||
Docker networks, so parallel execution within a single runner is unsafe).
|
|
||||||
|
|
||||||
A background thread polls the Gitea API. If any other molecule matrix runner
|
|
||||||
reports failure, the current molecule subprocess is killed and this runner
|
|
||||||
exits early with code 1.
|
|
||||||
|
|
||||||
Usage::
|
|
||||||
|
|
||||||
# Single-role
|
|
||||||
python3 -m devx.molecule.molecule_ci_guard pair1 pair2 ...
|
|
||||||
# Multi-role
|
|
||||||
python3 -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2 ...
|
|
||||||
|
|
||||||
Environment variables:
|
|
||||||
GITEA_URL Base URL of the Gitea instance.
|
|
||||||
CI_GITEA_API_TOKEN API token with repo access (CI_GITEA_TOKEN accepted for legacy).
|
|
||||||
RUN_ID Workflow run ID (GITHUB_RUN_ID).
|
|
||||||
JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests".
|
|
||||||
MATRIX_INDEX Current matrix index (runner-index).
|
|
||||||
GITEA_REPOSITORY Repository in "owner/repo" format.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
import subprocess # nosec B404
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import click
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from devx.config import REPO_NAME, REPO_OWNER
|
|
||||||
from devx.i18n import _
|
|
||||||
from devx.tokens import get_ci_token
|
|
||||||
|
|
||||||
POLL_INTERVAL = 10
|
|
||||||
|
|
||||||
|
|
||||||
def get_running_jobs(gitea_url: str, owner: str, repo: str, token: str, run_id: int) -> list[dict]:
|
|
||||||
"""Return jobs for the given workflow run."""
|
|
||||||
url = f"{gitea_url}/api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/jobs"
|
|
||||||
headers = {"Authorization": f"token {token}"}
|
|
||||||
response = requests.get(url, headers=headers, timeout=30)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
return data.get("jobs", [])
|
|
||||||
|
|
||||||
|
|
||||||
def any_other_runner_failed(jobs: list[dict], current_job_name: str, current_index: int) -> bool:
|
|
||||||
"""Return True if any other molecule matrix job has failed."""
|
|
||||||
for job in jobs:
|
|
||||||
name = job.get("name", "")
|
|
||||||
if not name.startswith(current_job_name):
|
|
||||||
continue
|
|
||||||
if name == f"{current_job_name} ({current_index})" or name == current_job_name:
|
|
||||||
continue
|
|
||||||
if job.get("conclusion") == "failure":
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def poll_for_other_failures(
|
|
||||||
gitea_url: str,
|
|
||||||
owner: str,
|
|
||||||
repo: str,
|
|
||||||
token: str,
|
|
||||||
run_id: int,
|
|
||||||
job_name: str,
|
|
||||||
current_index: int,
|
|
||||||
stop_event: threading.Event,
|
|
||||||
failed_event: threading.Event,
|
|
||||||
) -> None:
|
|
||||||
"""Background thread: poll API and signal if another runner fails."""
|
|
||||||
while not stop_event.is_set():
|
|
||||||
try:
|
|
||||||
jobs = get_running_jobs(gitea_url, owner, repo, token, run_id)
|
|
||||||
if any_other_runner_failed(jobs, job_name, current_index):
|
|
||||||
click.echo(_("Another molecule runner failed. Stopping this runner early."))
|
|
||||||
failed_event.set()
|
|
||||||
return
|
|
||||||
except requests.RequestException as exc:
|
|
||||||
click.echo(_("API poll warning: {exc}", exc=exc))
|
|
||||||
stop_event.wait(POLL_INTERVAL)
|
|
||||||
|
|
||||||
|
|
||||||
def build_molecule_cmd(scenario: str) -> list[str]:
|
|
||||||
"""Build the molecule command for a scenario."""
|
|
||||||
cmd = ["molecule", "test"]
|
|
||||||
if scenario != "default":
|
|
||||||
cmd.extend(["-s", scenario])
|
|
||||||
return cmd
|
|
||||||
|
|
||||||
|
|
||||||
def parse_pair(pair: str) -> tuple[str, str, str, str, str]:
|
|
||||||
"""Parse a pair string into (role, scenario, platform_name, platform_image, platform_command).
|
|
||||||
|
|
||||||
Supports both 4-part (single-role) and 5-part (multi-role) formats.
|
|
||||||
For 4-part pairs, role is empty (caller uses default role dir).
|
|
||||||
Spaces in the command field are encoded as ``__SPACE__`` to survive
|
|
||||||
shell word-splitting when ``$TEST_PAIRS`` is expanded unquoted.
|
|
||||||
"""
|
|
||||||
parts = pair.split("|")
|
|
||||||
if len(parts) == 4:
|
|
||||||
return "", parts[0], parts[1], parts[2], parts[3].replace("__SPACE__", " ")
|
|
||||||
if len(parts) == 5:
|
|
||||||
return parts[0], parts[1], parts[2], parts[3], parts[4].replace("__SPACE__", " ")
|
|
||||||
raise click.ClickException(f"Invalid pair format: {pair!r} (expected 4 or 5 pipe-delimited parts)")
|
|
||||||
|
|
||||||
|
|
||||||
def build_env_for_pair(pair: str, base_env: dict[str, str]) -> dict[str, str]:
|
|
||||||
"""Build environment for a single molecule pair."""
|
|
||||||
_role, _scenario, platform_name, platform_image, platform_command = parse_pair(pair)
|
|
||||||
env = base_env.copy()
|
|
||||||
# Append runner index to platform name when running in CI matrix to avoid
|
|
||||||
# Docker container name conflicts when multiple runners share the same Docker host.
|
|
||||||
matrix_index = env.get("MATRIX_INDEX")
|
|
||||||
if matrix_index:
|
|
||||||
platform_name = f"{platform_name}-r{matrix_index}"
|
|
||||||
env["MOLECULE_PLATFORM_NAME"] = platform_name
|
|
||||||
env["MOLECULE_PLATFORM_IMAGE"] = platform_image
|
|
||||||
if platform_command:
|
|
||||||
env["MOLECULE_PLATFORM_COMMAND"] = platform_command
|
|
||||||
elif "MOLECULE_PLATFORM_COMMAND" in env:
|
|
||||||
del env["MOLECULE_PLATFORM_COMMAND"]
|
|
||||||
env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] = "true"
|
|
||||||
# Use a fresh MOLECULE_HOME per pair to avoid stale config cache
|
|
||||||
# from previous CI runs (causes "Instances missing" errors).
|
|
||||||
if "MOLECULE_HOME" not in env:
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
env["MOLECULE_HOME"] = tempfile.mkdtemp(prefix="molecule-ci-")
|
|
||||||
return env
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_role_dir(role: str, roles_root: Path | None, repo_root: Path) -> Path:
|
|
||||||
"""Resolve the working directory for a molecule pair.
|
|
||||||
|
|
||||||
For multi-role pairs (role non-empty), uses ``roles_root/role``.
|
|
||||||
For single-role pairs, auto-discovers the first role with a molecule/
|
|
||||||
subdirectory under ``repo_root/ansible/roles/``.
|
|
||||||
"""
|
|
||||||
if role:
|
|
||||||
if roles_root is None:
|
|
||||||
roles_root = repo_root / "ansible" / "roles"
|
|
||||||
return roles_root / role
|
|
||||||
roles_dir = repo_root / "ansible" / "roles"
|
|
||||||
if roles_dir.is_dir():
|
|
||||||
role_dirs = sorted(d for d in roles_dir.iterdir() if (d / "molecule").is_dir())
|
|
||||||
if role_dirs:
|
|
||||||
return role_dirs[0]
|
|
||||||
return roles_dir / "role" # will produce a clear "not found" error
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.argument("pairs", nargs=-1, required=True)
|
|
||||||
@click.option(
|
|
||||||
"--roles-root",
|
|
||||||
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
|
||||||
default=None,
|
|
||||||
help="Root directory for multi-role pairs (e.g. ansible/roles). Required when pairs use 5-part format.",
|
|
||||||
)
|
|
||||||
def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None:
|
|
||||||
"""Run molecule pairs sequentially, stop if another CI runner fails."""
|
|
||||||
gitea_url = os.environ.get("GITEA_URL", "")
|
|
||||||
try:
|
|
||||||
token = get_ci_token()
|
|
||||||
except click.ClickException:
|
|
||||||
token = None
|
|
||||||
run_id = int(os.environ.get("RUN_ID", "0"))
|
|
||||||
job_name = os.environ.get("JOB_NAME", "molecule-tests")
|
|
||||||
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
|
|
||||||
repository = os.environ.get("GITEA_REPOSITORY", "")
|
|
||||||
owner, _sep, repo = repository.partition("/")
|
|
||||||
if not owner or not repo:
|
|
||||||
owner, repo = REPO_OWNER, REPO_NAME
|
|
||||||
|
|
||||||
if not all([gitea_url, token, run_id]):
|
|
||||||
click.echo(_("GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
|
|
||||||
|
|
||||||
# When devx is installed as a pip package, __file__ resolves to the
|
|
||||||
# site-packages directory, not the repo root. Use GITHUB_WORKSPACE
|
|
||||||
# (set by Gitea Actions) or cwd as the repo root.
|
|
||||||
repo_root = Path(os.environ.get("GITHUB_WORKSPACE", os.getcwd())).resolve()
|
|
||||||
|
|
||||||
base_env = os.environ.copy()
|
|
||||||
base_env.setdefault("DOCKER_HOST", f"unix:///run/user/{os.getuid()}/docker.sock")
|
|
||||||
base_env.setdefault("ANSIBLE_INJECT_INVOCATION", "1")
|
|
||||||
|
|
||||||
stop_event = threading.Event()
|
|
||||||
failed_event = threading.Event()
|
|
||||||
|
|
||||||
if gitea_url and token and run_id:
|
|
||||||
poller = threading.Thread(
|
|
||||||
target=poll_for_other_failures,
|
|
||||||
args=(
|
|
||||||
gitea_url,
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
token,
|
|
||||||
run_id,
|
|
||||||
job_name,
|
|
||||||
current_index,
|
|
||||||
stop_event,
|
|
||||||
failed_event,
|
|
||||||
),
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
poller.start()
|
|
||||||
|
|
||||||
try:
|
|
||||||
for pair in pairs:
|
|
||||||
if failed_event.is_set():
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
role, scenario, platform_name, _img, _cmd = parse_pair(pair)
|
|
||||||
click.echo(_("Running: {scenario} on {platform}", scenario=scenario, platform=platform_name))
|
|
||||||
|
|
||||||
cmd = build_molecule_cmd(scenario)
|
|
||||||
env = build_env_for_pair(pair, base_env)
|
|
||||||
cwd = resolve_role_dir(role, roles_root, repo_root)
|
|
||||||
|
|
||||||
process = subprocess.Popen( # nosec B603
|
|
||||||
cmd,
|
|
||||||
cwd=str(cwd),
|
|
||||||
env=env,
|
|
||||||
preexec_fn=os.setsid,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
while process.poll() is None:
|
|
||||||
if failed_event.is_set():
|
|
||||||
with contextlib.suppress(ProcessLookupError):
|
|
||||||
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
|
||||||
try:
|
|
||||||
process.wait(timeout=10)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
with contextlib.suppress(ProcessLookupError):
|
|
||||||
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
|
||||||
process.wait()
|
|
||||||
# Clean up containers left behind by the killed test.
|
|
||||||
click.echo(_("Cleaning up: running molecule destroy for {scenario}", scenario=scenario))
|
|
||||||
destroy_cmd = ["molecule", "destroy"]
|
|
||||||
if scenario != "default":
|
|
||||||
destroy_cmd.extend(["-s", scenario])
|
|
||||||
with contextlib.suppress(subprocess.SubprocessError, OSError):
|
|
||||||
subprocess.run( # nosec B603, B607
|
|
||||||
destroy_cmd,
|
|
||||||
cwd=str(cwd),
|
|
||||||
env=env,
|
|
||||||
check=False,
|
|
||||||
capture_output=True,
|
|
||||||
timeout=120,
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
time.sleep(1)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
with contextlib.suppress(ProcessLookupError):
|
|
||||||
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
|
||||||
process.wait()
|
|
||||||
# Clean up containers left behind by the interrupted test.
|
|
||||||
click.echo(_("Cleaning up: running molecule destroy for {scenario}", scenario=scenario))
|
|
||||||
destroy_cmd = ["molecule", "destroy"]
|
|
||||||
if scenario != "default":
|
|
||||||
destroy_cmd.extend(["-s", scenario])
|
|
||||||
with contextlib.suppress(subprocess.SubprocessError, OSError):
|
|
||||||
subprocess.run( # nosec B603, B607
|
|
||||||
destroy_cmd,
|
|
||||||
cwd=str(cwd),
|
|
||||||
env=env,
|
|
||||||
check=False,
|
|
||||||
capture_output=True,
|
|
||||||
timeout=120,
|
|
||||||
)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
rc = process.returncode
|
|
||||||
|
|
||||||
if rc != 0:
|
|
||||||
click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc))
|
|
||||||
# Run molecule destroy to clean up containers left behind by the
|
|
||||||
# failed test. Without this, containers stay running and accumulate
|
|
||||||
# on the runner, consuming disk/memory and degrading CI performance.
|
|
||||||
click.echo(_("Cleaning up: running molecule destroy for {scenario}", scenario=scenario))
|
|
||||||
destroy_cmd = ["molecule", "destroy"]
|
|
||||||
if scenario != "default":
|
|
||||||
destroy_cmd.extend(["-s", scenario])
|
|
||||||
with contextlib.suppress(subprocess.SubprocessError, OSError):
|
|
||||||
subprocess.run( # nosec B603, B607
|
|
||||||
destroy_cmd,
|
|
||||||
cwd=str(cwd),
|
|
||||||
env=env,
|
|
||||||
check=False,
|
|
||||||
capture_output=True,
|
|
||||||
timeout=120,
|
|
||||||
)
|
|
||||||
sys.exit(rc)
|
|
||||||
|
|
||||||
click.echo(_("PASSED: {pair}", pair=pair))
|
|
||||||
|
|
||||||
# Prune Docker data between scenarios to prevent disk exhaustion
|
|
||||||
# in Docker-in-Docker molecule containers (each scenario pulls
|
|
||||||
# hundreds of MB of images that accumulate across pairs).
|
|
||||||
with contextlib.suppress(subprocess.SubprocessError, OSError):
|
|
||||||
subprocess.run( # nosec B603, B607
|
|
||||||
["docker", "system", "prune", "-af", "--volumes"],
|
|
||||||
check=False,
|
|
||||||
capture_output=True,
|
|
||||||
timeout=60,
|
|
||||||
)
|
|
||||||
|
|
||||||
click.echo(_("All molecule tests passed."))
|
|
||||||
finally:
|
|
||||||
stop_event.set()
|
|
||||||
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
cli()
|
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""Validate Prometheus alert rules with promtool check rules.
|
||||||
|
|
||||||
|
Renders an alert-rules Jinja2 template with test values and validates
|
||||||
|
the output with ``promtool check rules``. Exits 0 if valid, non-zero
|
||||||
|
otherwise. Skips (exits 0) if promtool is not on PATH.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python -m devx.tools.check_alert_rules \\
|
||||||
|
--template-path ansible/roles/observability/templates \\
|
||||||
|
--template-name alert-rules.yml.j2
|
||||||
|
|
||||||
|
# With extra template variables:
|
||||||
|
python -m devx.tools.check_alert_rules \\
|
||||||
|
--template-path ansible/roles/observability/templates \\
|
||||||
|
--template-name alert-rules.yml.j2 \\
|
||||||
|
--var grafana_base_url=https://grafana.test.example.com
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess # nosec B404 — used to run promtool, a trusted binary
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from devx.utils.jinja import make_env, render_template
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
"--template-path",
|
||||||
|
type=click.Path(exists=True, path_type=Path),
|
||||||
|
required=True,
|
||||||
|
help="Path to the directory containing the Jinja2 template.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--template-name",
|
||||||
|
default="alert-rules.yml.j2",
|
||||||
|
help="Name of the Jinja2 template file to render.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--var",
|
||||||
|
"template_vars",
|
||||||
|
multiple=True,
|
||||||
|
help="Template variables in key=value format (can be repeated). "
|
||||||
|
"Example: --var grafana_base_url=https://grafana.example.com",
|
||||||
|
)
|
||||||
|
def main(template_path: Path, template_name: str, template_vars: tuple[str, ...]) -> None:
|
||||||
|
"""Validate rendered alert rules with promtool."""
|
||||||
|
if not shutil.which("promtool"):
|
||||||
|
click.echo("promtool not found in PATH — skipping alert rules validation")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Parse template variables
|
||||||
|
kwargs: dict[str, str] = {}
|
||||||
|
for v in template_vars:
|
||||||
|
if "=" in v:
|
||||||
|
key, value = v.split("=", 1)
|
||||||
|
kwargs[key] = value
|
||||||
|
|
||||||
|
env = make_env(str(template_path))
|
||||||
|
output = render_template(env, template_name, **kwargs)
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f:
|
||||||
|
f.write(output)
|
||||||
|
tmp_path = f.name
|
||||||
|
|
||||||
|
click.echo("[check-alert-rules] Validating rendered rules with promtool...")
|
||||||
|
result = subprocess.run( # nosec
|
||||||
|
["promtool", "check", "rules", tmp_path],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
click.echo(result.stdout, nl=False)
|
||||||
|
if result.returncode != 0:
|
||||||
|
click.echo(result.stderr, nl=False, err=True)
|
||||||
|
sys.exit(result.returncode)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
main()
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""Check that Ansible ``set_fact`` tasks don't misuse ``| to_json``.
|
||||||
|
|
||||||
|
This prevents the class of bug where ``set_fact`` tasks use
|
||||||
|
``{{ targets | to_json }}`` to store Python lists, but ``to_json``
|
||||||
|
converts native types to JSON strings. Ansible then stored the result
|
||||||
|
as a string, so iterating over the fact yielded individual characters
|
||||||
|
instead of list items, causing ``object of type 'str' has no attribute
|
||||||
|
'ip'`` errors.
|
||||||
|
|
||||||
|
The check scans all Ansible task files (playbooks and role tasks) for
|
||||||
|
``set_fact`` tasks where any value uses ``| to_json`` or ``| to_nice_json``
|
||||||
|
and flags them as potential bugs.
|
||||||
|
|
||||||
|
``| to_json`` is legitimate in Jinja2 templates (e.g., rendering JSON
|
||||||
|
config files) but almost never correct in ``set_fact`` — the fact should
|
||||||
|
store the native Python type so downstream tasks can iterate/index it.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python -m devx.tools.check_ansible_set_fact_to_json
|
||||||
|
python -m devx.tools.check_ansible_set_fact_to_json --path ansible/playbooks/deploy.yml
|
||||||
|
|
||||||
|
Exit code 0 if no misuses found, 1 otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
REPO_ROOT = Path.cwd()
|
||||||
|
DEFAULT_ANSIBLE_DIRS: list[Path] = [
|
||||||
|
REPO_ROOT / "ansible" / "playbooks",
|
||||||
|
REPO_ROOT / "ansible" / "roles",
|
||||||
|
]
|
||||||
|
|
||||||
|
TO_JSON_FILTERS = ("| to_json", "| to_nice_json", "|to_json", "|to_nice_json")
|
||||||
|
|
||||||
|
|
||||||
|
def _find_task_files(base: Path) -> list[Path]:
|
||||||
|
"""Find all YAML task files under a base directory."""
|
||||||
|
if base.is_file() and base.suffix in (".yml", ".yaml"):
|
||||||
|
return [base]
|
||||||
|
if not base.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(base.rglob("*.yml")) + sorted(base.rglob("*.yaml"))
|
||||||
|
|
||||||
|
|
||||||
|
def _check_file(filepath: Path, repo_root: Path) -> list[str]:
|
||||||
|
"""Check a single YAML file for set_fact + to_json misuse.
|
||||||
|
|
||||||
|
Returns a list of error messages (empty if all OK).
|
||||||
|
"""
|
||||||
|
errors: list[str] = []
|
||||||
|
content = filepath.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Multi-document YAML (--- separators) is common in playbooks
|
||||||
|
try:
|
||||||
|
docs = list(yaml.safe_load_all(content))
|
||||||
|
except yaml.YAMLError as exc:
|
||||||
|
return [f"{filepath}: cannot parse YAML: {exc}"]
|
||||||
|
|
||||||
|
for doc in docs:
|
||||||
|
if isinstance(doc, list):
|
||||||
|
# Could be a playbook (list of plays) or a role tasks file (list of tasks)
|
||||||
|
for item in doc:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
if any(k in item for k in ("tasks", "pre_tasks", "post_tasks", "handlers", "roles")):
|
||||||
|
# It's a play
|
||||||
|
_check_tasks(item, filepath, errors, repo_root)
|
||||||
|
else:
|
||||||
|
# It's a bare task (role tasks file)
|
||||||
|
_check_task(item, filepath, errors, repo_root)
|
||||||
|
block = item.get("block")
|
||||||
|
if isinstance(block, list):
|
||||||
|
_check_task_list(block, filepath, errors, repo_root)
|
||||||
|
elif isinstance(doc, dict):
|
||||||
|
# Role tasks file or single play — _check_tasks handles all task sections
|
||||||
|
_check_tasks(doc, filepath, errors, repo_root)
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
def _check_tasks(doc: dict, filepath: Path, errors: list[str], repo_root: Path) -> None:
|
||||||
|
"""Check top-level tasks and nested task sections in a playbook doc."""
|
||||||
|
tasks = doc.get("tasks")
|
||||||
|
if isinstance(tasks, list):
|
||||||
|
_check_task_list(tasks, filepath, errors, repo_root)
|
||||||
|
for role_key in ("pre_tasks", "post_tasks", "handlers"):
|
||||||
|
section = doc.get(role_key)
|
||||||
|
if isinstance(section, list):
|
||||||
|
_check_task_list(section, filepath, errors, repo_root)
|
||||||
|
# Check tasks in roles imported via `roles:` key
|
||||||
|
roles = doc.get("roles")
|
||||||
|
if isinstance(roles, list):
|
||||||
|
for role_entry in roles:
|
||||||
|
if isinstance(role_entry, dict):
|
||||||
|
role_tasks = role_entry.get("tasks")
|
||||||
|
if isinstance(role_tasks, list):
|
||||||
|
_check_task_list(role_tasks, filepath, errors, repo_root)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_task_list(tasks: list, filepath: Path, errors: list[str], repo_root: Path) -> None:
|
||||||
|
"""Check a list of task definitions for set_fact + to_json."""
|
||||||
|
for task in tasks:
|
||||||
|
if not isinstance(task, dict):
|
||||||
|
continue
|
||||||
|
_check_task(task, filepath, errors, repo_root)
|
||||||
|
# Check nested block tasks
|
||||||
|
block = task.get("block")
|
||||||
|
if isinstance(block, list):
|
||||||
|
_check_task_list(block, filepath, errors, repo_root)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_task(task: dict, filepath: Path, errors: list[str], repo_root: Path) -> None:
|
||||||
|
"""Check a single task for set_fact + to_json misuse."""
|
||||||
|
# Detect set_fact — could be a module name key or ansible.builtin.set_fact
|
||||||
|
has_set_fact = False
|
||||||
|
for key in task:
|
||||||
|
if key in {"set_fact", "ansible.builtin.set_fact"}:
|
||||||
|
has_set_fact = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not has_set_fact:
|
||||||
|
return
|
||||||
|
|
||||||
|
set_fact_body = task.get("set_fact") or task.get("ansible.builtin.set_fact")
|
||||||
|
if not isinstance(set_fact_body, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
task_name = task.get("name", "(unnamed)")
|
||||||
|
|
||||||
|
for fact_name, fact_value in set_fact_body.items():
|
||||||
|
if fact_name in ("cacheable",):
|
||||||
|
continue
|
||||||
|
value_str = str(fact_value)
|
||||||
|
for filter_pattern in TO_JSON_FILTERS:
|
||||||
|
if filter_pattern in value_str:
|
||||||
|
try:
|
||||||
|
display_path = filepath.relative_to(repo_root)
|
||||||
|
except ValueError:
|
||||||
|
display_path = filepath
|
||||||
|
errors.append(
|
||||||
|
f"{display_path}: task '{task_name}' "
|
||||||
|
f"sets fact '{fact_name}' with '{filter_pattern.strip()}' "
|
||||||
|
f"— this converts native Python types to JSON strings. "
|
||||||
|
f"Remove the filter to preserve the native type, or use "
|
||||||
|
f"'| from_json' in the consuming task if the string "
|
||||||
|
f"representation is intentional."
|
||||||
|
)
|
||||||
|
break # One error per fact is enough
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
"--path",
|
||||||
|
type=click.Path(exists=True, path_type=Path),
|
||||||
|
help="Check a specific file or directory (default: ansible/playbooks + ansible/roles).",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--ansible-dir",
|
||||||
|
"ansible_dirs",
|
||||||
|
type=click.Path(exists=True, path_type=Path),
|
||||||
|
multiple=True,
|
||||||
|
default=None,
|
||||||
|
help="Override the default ansible directories (can be repeated). Defaults to ansible/playbooks and ansible/roles.",
|
||||||
|
)
|
||||||
|
def main(path: Path | None, ansible_dirs: tuple[Path, ...]) -> None:
|
||||||
|
"""Check that set_fact tasks don't misuse to_json."""
|
||||||
|
dirs = list(ansible_dirs) if ansible_dirs else DEFAULT_ANSIBLE_DIRS
|
||||||
|
if path:
|
||||||
|
files = _find_task_files(path)
|
||||||
|
else:
|
||||||
|
files: list[Path] = []
|
||||||
|
for d in dirs:
|
||||||
|
files.extend(_find_task_files(d))
|
||||||
|
|
||||||
|
all_errors: list[str] = []
|
||||||
|
for f in files:
|
||||||
|
errors = _check_file(f, REPO_ROOT)
|
||||||
|
all_errors.extend(errors)
|
||||||
|
|
||||||
|
if all_errors:
|
||||||
|
click.echo("[check-ansible-set-fact-to-json] FAIL: set_fact with to_json found:")
|
||||||
|
for err in all_errors:
|
||||||
|
click.echo(f" - {err}")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
click.echo("[check-ansible-set-fact-to-json] OK: no set_fact tasks misuse to_json.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
main()
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
"""Check that Docker Compose services with healthchecks have ``init: true``.
|
||||||
|
|
||||||
|
This prevents zombie process accumulation on production VMs. Without
|
||||||
|
``init: true``, Docker uses the container's PID 1 process to reap
|
||||||
|
child processes. Many images (especially those using CMD-SHELL
|
||||||
|
healthchecks with ``wget``) don't call ``wait()`` on children, causing
|
||||||
|
zombies to accumulate.
|
||||||
|
|
||||||
|
The check scans all Jinja2 docker-compose templates for services that
|
||||||
|
have a ``healthcheck:`` key but no ``init: true`` key. Since the
|
||||||
|
templates use Jinja2 syntax (not pure YAML), the check uses text-based
|
||||||
|
parsing to identify service blocks and their properties.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python -m devx.tools.check_docker_init
|
||||||
|
python -m devx.tools.check_docker_init --path ansible/roles/observability/templates/docker-compose.yml.j2
|
||||||
|
|
||||||
|
Exit code 0 if all services with healthchecks have init: true, 1 otherwise.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
REPO_ROOT = Path.cwd()
|
||||||
|
DEFAULT_TEMPLATES_DIR = REPO_ROOT / "ansible" / "roles"
|
||||||
|
|
||||||
|
|
||||||
|
def _find_compose_templates(base: Path) -> list[Path]:
|
||||||
|
"""Find all Jinja2 docker-compose templates under a base directory."""
|
||||||
|
if base.is_file():
|
||||||
|
return [base]
|
||||||
|
if not base.is_dir():
|
||||||
|
return []
|
||||||
|
results: list[Path] = []
|
||||||
|
for pattern in ("*docker-compose*", "*compose*"):
|
||||||
|
results.extend(base.rglob(f"{pattern}.yml.j2"))
|
||||||
|
results.extend(base.rglob(f"{pattern}.yaml.j2"))
|
||||||
|
# Also check exporters-compose
|
||||||
|
results.extend(base.rglob("exporters-compose*.j2"))
|
||||||
|
# Deduplicate while preserving order
|
||||||
|
seen: set[Path] = set()
|
||||||
|
unique: list[Path] = []
|
||||||
|
for p in sorted(results):
|
||||||
|
if p not in seen:
|
||||||
|
seen.add(p)
|
||||||
|
unique.append(p)
|
||||||
|
return unique
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_services(content: str) -> dict[str, list[str]]:
|
||||||
|
"""Parse service blocks from a docker-compose Jinja2 template.
|
||||||
|
|
||||||
|
Returns a mapping of service_name → list of lines in that service block.
|
||||||
|
"""
|
||||||
|
lines = content.splitlines()
|
||||||
|
in_services = False
|
||||||
|
services: dict[str, list[str]] = {}
|
||||||
|
current_svc: str | None = None
|
||||||
|
current_lines: list[str] = []
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if line.startswith("services:"):
|
||||||
|
in_services = True
|
||||||
|
continue
|
||||||
|
if not in_services:
|
||||||
|
continue
|
||||||
|
# Top-level keys (networks:, volumes:) end the services section
|
||||||
|
if re.match(r"^(networks|volumes):\s*$", line):
|
||||||
|
if current_svc is not None:
|
||||||
|
services[current_svc] = current_lines
|
||||||
|
current_svc = None
|
||||||
|
in_services = False
|
||||||
|
continue
|
||||||
|
# Service definition: exactly 2-space indent, ends with :
|
||||||
|
# Service names can contain Jinja2 variables like {{ app_name }}
|
||||||
|
# or {{ app_name }}-db. Match: 2-space indent + non-whitespace
|
||||||
|
# chars (including {{ }}, -, _, .) + optional spaces inside {{ }} + :
|
||||||
|
m = re.match(r"^ (\{\{.*?\}\}[a-zA-Z0-9_-]*|[a-zA-Z0-9_().-]+):\s*$", line)
|
||||||
|
if m:
|
||||||
|
if current_svc is not None:
|
||||||
|
services[current_svc] = current_lines
|
||||||
|
current_svc = m.group(1)
|
||||||
|
current_lines = []
|
||||||
|
elif current_svc is not None:
|
||||||
|
current_lines.append(line)
|
||||||
|
|
||||||
|
if current_svc is not None:
|
||||||
|
services[current_svc] = current_lines
|
||||||
|
|
||||||
|
return services
|
||||||
|
|
||||||
|
|
||||||
|
def _check_template(filepath: Path, repo_root: Path) -> list[str]:
|
||||||
|
"""Check a single docker-compose template for missing init: true.
|
||||||
|
|
||||||
|
Returns a list of error messages (empty if all OK).
|
||||||
|
"""
|
||||||
|
errors: list[str] = []
|
||||||
|
content = filepath.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
if "services:" not in content:
|
||||||
|
return errors
|
||||||
|
|
||||||
|
services = _parse_services(content)
|
||||||
|
|
||||||
|
for svc_name, svc_lines in services.items():
|
||||||
|
svc_text = "\n".join(svc_lines)
|
||||||
|
has_init = "init: true" in svc_text
|
||||||
|
has_healthcheck = "healthcheck:" in svc_text
|
||||||
|
# Skip services that are conditionally included (Jinja2 if blocks)
|
||||||
|
# but still check them — the healthcheck is inside the conditional
|
||||||
|
if has_healthcheck and not has_init:
|
||||||
|
try:
|
||||||
|
display_path = filepath.relative_to(repo_root)
|
||||||
|
except ValueError:
|
||||||
|
display_path = filepath
|
||||||
|
errors.append(
|
||||||
|
f"{display_path}: service '{svc_name}' has a healthcheck "
|
||||||
|
f"but no 'init: true'. Without init: true, CMD-SHELL "
|
||||||
|
f"healthchecks (wget, pgrep) spawn children that become "
|
||||||
|
f"zombies when PID 1 doesn't reap them. Add 'init: true' "
|
||||||
|
f"to enable Docker's built-in tini as PID 1."
|
||||||
|
)
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
"--path",
|
||||||
|
type=click.Path(exists=True, path_type=Path),
|
||||||
|
help="Check a specific file or directory (default: ansible/roles/).",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--templates-dir",
|
||||||
|
type=click.Path(exists=True, path_type=Path),
|
||||||
|
default=None,
|
||||||
|
help="Override the default templates directory (default: ansible/roles/).",
|
||||||
|
)
|
||||||
|
def main(path: Path | None, templates_dir: Path | None) -> None:
|
||||||
|
"""Check that Docker Compose services with healthchecks have init: true."""
|
||||||
|
tdir = templates_dir or DEFAULT_TEMPLATES_DIR
|
||||||
|
files = _find_compose_templates(path) if path else _find_compose_templates(tdir)
|
||||||
|
|
||||||
|
all_errors: list[str] = []
|
||||||
|
for f in files:
|
||||||
|
errors = _check_template(f, tdir)
|
||||||
|
all_errors.extend(errors)
|
||||||
|
|
||||||
|
if all_errors:
|
||||||
|
click.echo("[check-docker-init] FAIL: services with healthchecks missing init: true:")
|
||||||
|
for err in all_errors:
|
||||||
|
click.echo(f" - {err}")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
click.echo("[check-docker-init] OK: all services with healthchecks have init: true.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
main()
|
||||||
@@ -75,6 +75,25 @@ def _download(url: str, dest: Path) -> None:
|
|||||||
shutil.copyfileobj(resp, f)
|
shutil.copyfileobj(resp, f)
|
||||||
|
|
||||||
|
|
||||||
|
def _download_with_fallback(urls: list[str], binary_name: str) -> Path:
|
||||||
|
"""Try downloading a binary from a list of URLs, falling back on failure.
|
||||||
|
|
||||||
|
Returns the path to the installed binary. Raises if all URLs fail.
|
||||||
|
"""
|
||||||
|
target_dir = _ensure_target_dir()
|
||||||
|
dest = target_dir / binary_name
|
||||||
|
errors: list[str] = []
|
||||||
|
for url in urls:
|
||||||
|
try:
|
||||||
|
_download(url, dest)
|
||||||
|
dest.chmod(0o755)
|
||||||
|
return dest
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
errors.append(f"{url}: {exc}")
|
||||||
|
click.echo(f" {binary_name}: retrying — {exc}")
|
||||||
|
raise click.ClickException(f"Failed to download {binary_name} from all URLs: {'; '.join(errors)}")
|
||||||
|
|
||||||
|
|
||||||
def _download_and_extract_tarball(url: str, binary_name: str) -> Path:
|
def _download_and_extract_tarball(url: str, binary_name: str) -> Path:
|
||||||
"""Download a tarball, extract the binary, and install it to TARGET_DIR.
|
"""Download a tarball, extract the binary, and install it to TARGET_DIR.
|
||||||
|
|
||||||
@@ -169,8 +188,13 @@ def install_tea() -> bool:
|
|||||||
click.echo("tea: already installed")
|
click.echo("tea: already installed")
|
||||||
return True
|
return True
|
||||||
arch = _arch()
|
arch = _arch()
|
||||||
url = f"https://dl.gitea.com/tea/{TEA_VERSION}/tea-{TEA_VERSION}-linux-{arch}"
|
# dl.gitea.com is the primary CDN, but it can return 403 from some networks.
|
||||||
dest = _download_binary(url, "tea")
|
# Fall back to the gitea.com release downloads URL.
|
||||||
|
urls = [
|
||||||
|
f"https://dl.gitea.com/tea/{TEA_VERSION}/tea-{TEA_VERSION}-linux-{arch}",
|
||||||
|
f"https://gitea.com/gitea/tea/releases/download/v{TEA_VERSION}/tea-{TEA_VERSION}-linux-{arch}",
|
||||||
|
]
|
||||||
|
dest = _download_with_fallback(urls, "tea")
|
||||||
click.echo(f"tea: installed to {dest}")
|
click.echo(f"tea: installed to {dest}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -59,12 +59,6 @@ def _install_pre_commit_hooks(bin_dir: str) -> None:
|
|||||||
def _install_ansible_collections(bin_dir: str) -> None:
|
def _install_ansible_collections(bin_dir: str) -> None:
|
||||||
"""Install required Ansible Galaxy collections if requirements exist.
|
"""Install required Ansible Galaxy collections if requirements exist.
|
||||||
|
|
||||||
If the requirements file uses ``type: url`` entries pointing to the
|
|
||||||
Gitea package registry, downloads them with authentication (using
|
|
||||||
``CI_GITEA_TOKEN`` / ``CI_GITEA_API_TOKEN``) and installs from local
|
|
||||||
files with ``--offline``. Falls back to direct galaxy install if the
|
|
||||||
mirror download fails or no token is available.
|
|
||||||
|
|
||||||
Retries up to 3 times with exponential backoff to handle transient
|
Retries up to 3 times with exponential backoff to handle transient
|
||||||
network timeouts when contacting galaxy.ansible.com.
|
network timeouts when contacting galaxy.ansible.com.
|
||||||
"""
|
"""
|
||||||
@@ -74,11 +68,6 @@ def _install_ansible_collections(bin_dir: str) -> None:
|
|||||||
click.echo(" ansible/requirements.yml not found — skipping collections.")
|
click.echo(" ansible/requirements.yml not found — skipping collections.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Try Gitea mirror first if requirements use type: url
|
|
||||||
if _try_gitea_mirror_install(galaxy, requirements):
|
|
||||||
return
|
|
||||||
|
|
||||||
# Fall back to direct galaxy install with retries
|
|
||||||
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=2, min=2, max=10), reraise=True)
|
@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=2, min=2, max=10), reraise=True)
|
||||||
def _do_install() -> None:
|
def _do_install() -> None:
|
||||||
_run([galaxy, "collection", "install", "-r", str(requirements)])
|
_run([galaxy, "collection", "install", "-r", str(requirements)])
|
||||||
@@ -86,89 +75,6 @@ def _install_ansible_collections(bin_dir: str) -> None:
|
|||||||
_do_install()
|
_do_install()
|
||||||
|
|
||||||
|
|
||||||
def _try_gitea_mirror_install(galaxy: str, requirements: Path) -> bool:
|
|
||||||
"""Download ``type: url`` entries from Gitea with auth and install locally.
|
|
||||||
|
|
||||||
Returns ``True`` if the mirror install succeeded, ``False`` to fall back
|
|
||||||
to direct galaxy install.
|
|
||||||
"""
|
|
||||||
import tempfile
|
|
||||||
import urllib.request # noqa: PTH123 # nosec B404
|
|
||||||
|
|
||||||
import yaml # pyright: ignore[reportMissingImports]
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = yaml.safe_load(requirements.read_text())
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
collections = data.get("collections", []) if data else []
|
|
||||||
url_entries = [c for c in collections if c.get("type") == "url"]
|
|
||||||
if not url_entries:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Resolve Gitea token for authenticated downloads
|
|
||||||
token = os.environ.get("CI_GITEA_API_TOKEN", "").strip()
|
|
||||||
if not token:
|
|
||||||
token = os.environ.get("CI_GITEA_TOKEN", "").strip()
|
|
||||||
if not token:
|
|
||||||
token = os.environ.get("DEVELOPER_GITEA_API_TOKEN", "").strip()
|
|
||||||
if not token:
|
|
||||||
click.echo(" No Gitea token found — falling back to galaxy.ansible.com")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Download each tarball with auth
|
|
||||||
tmpdir = Path(tempfile.mkdtemp(prefix="ansible-collections-"))
|
|
||||||
local_entries = []
|
|
||||||
try:
|
|
||||||
for entry in url_entries:
|
|
||||||
source = entry.get("source", "")
|
|
||||||
if "/api/packages/" not in source:
|
|
||||||
local_entries.append(entry)
|
|
||||||
continue
|
|
||||||
filename = source.rsplit("/", 1)[-1]
|
|
||||||
dest = tmpdir / filename
|
|
||||||
click.echo(f" Downloading {entry.get('name', filename)} from Gitea mirror...")
|
|
||||||
req = urllib.request.Request(source) # nosec B310
|
|
||||||
req.add_header("Authorization", f"token {token}")
|
|
||||||
try:
|
|
||||||
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: PTH123 # nosec B310
|
|
||||||
dest.write_bytes(resp.read())
|
|
||||||
except Exception as e:
|
|
||||||
click.echo(f" WARN: mirror download failed for {entry.get('name')}: {e}")
|
|
||||||
click.echo(" Falling back to galaxy.ansible.com")
|
|
||||||
return False
|
|
||||||
# Extract version from filename (e.g. ansible-posix-2.2.2.tar.gz)
|
|
||||||
import re
|
|
||||||
|
|
||||||
ver_match = re.search(r"(\d+\.\d+\.\d+)", filename)
|
|
||||||
local_entries.append(
|
|
||||||
{
|
|
||||||
"name": entry["name"],
|
|
||||||
"version": ver_match.group(1) if ver_match else entry.get("version"),
|
|
||||||
"type": "file",
|
|
||||||
"source": str(dest),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add non-url entries as-is
|
|
||||||
for entry in collections:
|
|
||||||
if entry.get("type") != "url":
|
|
||||||
local_entries.append(entry)
|
|
||||||
|
|
||||||
# Write local requirements file
|
|
||||||
local_req = tmpdir / "requirements.yml"
|
|
||||||
local_req.write_text(yaml.dump({"collections": local_entries}))
|
|
||||||
|
|
||||||
click.echo(" Installing collections from Gitea mirror (offline)...")
|
|
||||||
_run([galaxy, "collection", "install", "-r", str(local_req), "--offline"])
|
|
||||||
return True
|
|
||||||
finally:
|
|
||||||
import shutil as _shutil
|
|
||||||
|
|
||||||
_shutil.rmtree(tmpdir, ignore_errors=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _configure_tea_login() -> None:
|
def _configure_tea_login() -> None:
|
||||||
"""Configure tea CLI login from .env if a Gitea token is set.
|
"""Configure tea CLI login from .env if a Gitea token is set.
|
||||||
|
|
||||||
|
|||||||
@@ -64,9 +64,11 @@ def _install_in_image(
|
|||||||
link.symlink_to(opt_venv)
|
link.symlink_to(opt_venv)
|
||||||
|
|
||||||
# Build pip install command
|
# Build pip install command
|
||||||
|
# --no-deps: the CI image already has all dependencies pre-installed.
|
||||||
|
# We only need to install the project itself in editable mode.
|
||||||
spec = f".[{extras}]" if extras else "."
|
spec = f".[{extras}]" if extras else "."
|
||||||
pip_bin = str(Path(venv_link) / "bin" / "pip")
|
pip_bin = str(Path(venv_link) / "bin" / "pip")
|
||||||
cmd = [pip_bin, "install", "--no-cache-dir", "-e", spec]
|
cmd = [pip_bin, "install", "--no-cache-dir", "--no-deps", "-e", spec]
|
||||||
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -783,14 +783,6 @@
|
|||||||
"ru": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
"ru": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||||
"zh": "Additional directory to scan (default: scripts, tests). Can be repeated."
|
"zh": "Additional directory to scan (default: scripts, tests). Can be repeated."
|
||||||
},
|
},
|
||||||
"All molecule tests passed.": {
|
|
||||||
"bg": "All molecule tests passed.",
|
|
||||||
"de": "All molecule tests passed.",
|
|
||||||
"en": "All molecule tests passed.",
|
|
||||||
"pl": "Wszystkie testy molecule zakończone pomyślnie.",
|
|
||||||
"ru": "All molecule tests passed.",
|
|
||||||
"zh": "All molecule tests passed."
|
|
||||||
},
|
|
||||||
"Allow empty tag (PR mode where SHA is concrete).": {
|
"Allow empty tag (PR mode where SHA is concrete).": {
|
||||||
"bg": "Позволи празен таг (PR режим, където SHA е конкретен).",
|
"bg": "Позволи празен таг (PR режим, където SHA е конкретен).",
|
||||||
"de": "Leeren Tag zulassen (PR-Modus, in dem SHA konkret ist).",
|
"de": "Leeren Tag zulassen (PR-Modus, in dem SHA konkret ist).",
|
||||||
@@ -799,13 +791,13 @@
|
|||||||
"ru": "Разрешить пустой тег (режим PR, где SHA конкретен).",
|
"ru": "Разрешить пустой тег (режим PR, где SHA конкретен).",
|
||||||
"zh": "允许空标签(SHA 为具体值的 PR 模式)。"
|
"zh": "允许空标签(SHA 为具体值的 PR 模式)。"
|
||||||
},
|
},
|
||||||
"Another molecule runner failed. Stopping this runner early.": {
|
"Another runner failed. Stopping this runner early.": {
|
||||||
"bg": "Another molecule runner failed. Stopping this runner early.",
|
"bg": "Друг runner се провали. Спиране на този runner по-рано.",
|
||||||
"de": "Another molecule runner failed. Stopping this runner early.",
|
"de": "Ein anderer Runner ist fehlgeschlagen. Dieser Runner wird vorzeitig gestoppt.",
|
||||||
"en": "Another molecule runner failed. Stopping this runner early.",
|
"en": "Another runner failed. Stopping this runner early.",
|
||||||
"pl": "Inny runner molecule zakończył się niepowodzeniem. Wczesne zatrzymanie tego runnera.",
|
"pl": "Inny runner zakończył się niepowodzeniem. Wczesne zatrzymanie tego runnera.",
|
||||||
"ru": "Another molecule runner failed. Stopping this runner early.",
|
"ru": "Другой runner завершился с ошибкой. Останавливаю этот runner досрочно.",
|
||||||
"zh": "Another molecule runner failed. Stopping this runner early."
|
"zh": "另一个 runner 失败。提前停止此 runner。"
|
||||||
},
|
},
|
||||||
"Assigned {count} files to runner {runner_index}": {
|
"Assigned {count} files to runner {runner_index}": {
|
||||||
"bg": "Assigned {count} files to runner {runner_index}",
|
"bg": "Assigned {count} files to runner {runner_index}",
|
||||||
@@ -1495,14 +1487,6 @@
|
|||||||
"ru": "FAILED: {count} undocumented dependency/ies",
|
"ru": "FAILED: {count} undocumented dependency/ies",
|
||||||
"zh": "FAILED: {count} undocumented dependency/ies"
|
"zh": "FAILED: {count} undocumented dependency/ies"
|
||||||
},
|
},
|
||||||
"FAILED: {pair} exited with code {code}": {
|
|
||||||
"bg": "FAILED: {pair} exited with code {code}",
|
|
||||||
"de": "FAILED: {pair} exited with code {code}",
|
|
||||||
"en": "FAILED: {pair} exited with code {code}",
|
|
||||||
"pl": "NIEUDANE: {pair} zakończone kodem {code}",
|
|
||||||
"ru": "FAILED: {pair} exited with code {code}",
|
|
||||||
"zh": "FAILED: {pair} exited with code {code}"
|
|
||||||
},
|
|
||||||
"Failed images: {names}": {
|
"Failed images: {names}": {
|
||||||
"bg": "Failed images: {names}",
|
"bg": "Failed images: {names}",
|
||||||
"de": "Failed images: {names}",
|
"de": "Failed images: {names}",
|
||||||
@@ -2263,14 +2247,6 @@
|
|||||||
"ru": "PASS: All documentation checks passed!",
|
"ru": "PASS: All documentation checks passed!",
|
||||||
"zh": "PASS: All documentation checks passed!"
|
"zh": "PASS: All documentation checks passed!"
|
||||||
},
|
},
|
||||||
"PASSED: {pair}": {
|
|
||||||
"bg": "PASSED: {pair}",
|
|
||||||
"de": "PASSED: {pair}",
|
|
||||||
"en": "PASSED: {pair}",
|
|
||||||
"pl": "UDANE: {pair}",
|
|
||||||
"ru": "PASSED: {pair}",
|
|
||||||
"zh": "PASSED: {pair}"
|
|
||||||
},
|
|
||||||
"PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.": {
|
"PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.": {
|
||||||
"bg": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
|
"bg": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
|
||||||
"de": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
|
"de": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
|
||||||
@@ -2743,14 +2719,6 @@
|
|||||||
"ru": "Running: {cmd}",
|
"ru": "Running: {cmd}",
|
||||||
"zh": "Running: {cmd}"
|
"zh": "Running: {cmd}"
|
||||||
},
|
},
|
||||||
"Running: {scenario} on {platform}": {
|
|
||||||
"bg": "Running: {scenario} on {platform}",
|
|
||||||
"de": "Running: {scenario} on {platform}",
|
|
||||||
"en": "Running: {scenario} on {platform}",
|
|
||||||
"pl": "Uruchamianie: {scenario} na {platform}",
|
|
||||||
"ru": "Running: {scenario} on {platform}",
|
|
||||||
"zh": "Running: {scenario} on {platform}"
|
|
||||||
},
|
|
||||||
"SSH key set up successfully": {
|
"SSH key set up successfully": {
|
||||||
"bg": "SSH ключът е настроен успешно",
|
"bg": "SSH ключът е настроен успешно",
|
||||||
"de": "SSH-Schlüssel erfolgreich eingerichtet",
|
"de": "SSH-Schlüssel erfolgreich eingerichtet",
|
||||||
@@ -3830,13 +3798,5 @@
|
|||||||
"pl": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
|
"pl": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
|
||||||
"ru": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
|
"ru": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)",
|
||||||
"zh": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)"
|
"zh": "[check-test-speed] CI environment detected — scaling limits by {factor}x (total: {orig}s → {eff}s, per-test: {orig_s}s → {eff_s}s)"
|
||||||
},
|
|
||||||
"Cleaning up: running molecule destroy for {scenario}": {
|
|
||||||
"en": "Cleaning up: running molecule destroy for {scenario}",
|
|
||||||
"bg": "Изчистване: изпълнение на molecule destroy за {scenario}",
|
|
||||||
"de": "Aufräumen: molecule destroy wird ausgeführt für {scenario}",
|
|
||||||
"pl": "Czyszczenie: uruchamianie molecule destroy dla {scenario}",
|
|
||||||
"ru": "Очистка: запуск molecule destroy для {scenario}",
|
|
||||||
"zh": "清理:正在为 {scenario} 运行 molecule destroy"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+94
-6
@@ -1,14 +1,26 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Utilities for handling API response values.
|
"""Utilities for handling API response values and base HTTP API client.
|
||||||
|
|
||||||
Many APIs return boolean values as strings (``"true"``, ``"false"``)
|
This module provides two categories of utilities:
|
||||||
rather than native JSON booleans. The Mattermost ``/api/v4/config/client``
|
|
||||||
endpoint is a notable example. These helpers handle both string and
|
1. **Response helpers** — :func:`is_truthy` and :func:`is_falsy` handle
|
||||||
boolean responses safely.
|
APIs that return boolean values as strings (``"true"``, ``"false"``)
|
||||||
|
rather than native JSON booleans.
|
||||||
|
|
||||||
|
2. **Base API client** — :class:`APIClient` provides a reusable base
|
||||||
|
class for HTTP API clients with consistent timeout handling, header
|
||||||
|
propagation, and automatic raising on 4xx/5xx responses.
|
||||||
|
|
||||||
Usage::
|
Usage::
|
||||||
|
|
||||||
from devx.utils.api import is_truthy, is_falsy
|
from devx.utils.api import APIClient, is_truthy
|
||||||
|
|
||||||
|
class MyClient(APIClient):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
base_url="https://api.example.com",
|
||||||
|
headers={"Authorization": "Bearer token"},
|
||||||
|
)
|
||||||
|
|
||||||
if not is_truthy(config.get("EnableOpenServer")):
|
if not is_truthy(config.get("EnableOpenServer")):
|
||||||
raise ValueError("EnableOpenServer not enabled")
|
raise ValueError("EnableOpenServer not enabled")
|
||||||
@@ -16,6 +28,82 @@ Usage::
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class APIClient:
|
||||||
|
"""Base class for HTTP API clients.
|
||||||
|
|
||||||
|
Subclasses set ``base_url``, ``headers``, and optionally ``auth`` in
|
||||||
|
their constructor, then use :meth:`_request` or the convenience
|
||||||
|
methods (:meth:`get`, :meth:`post`, etc.) to make requests.
|
||||||
|
|
||||||
|
All requests raise :class:`requests.HTTPError` on 4xx/5xx responses
|
||||||
|
via :meth:`requests.Response.raise_for_status`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
headers: dict,
|
||||||
|
timeout: int = 30,
|
||||||
|
verify: bool = True,
|
||||||
|
auth: tuple[str, str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the API client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Base URL for the API (trailing slash stripped).
|
||||||
|
headers: Default headers sent with every request.
|
||||||
|
timeout: Request timeout in seconds.
|
||||||
|
verify: Whether to verify TLS certificates.
|
||||||
|
auth: Optional ``(username, password)`` tuple for basic auth.
|
||||||
|
"""
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.headers = headers
|
||||||
|
self.timeout = timeout
|
||||||
|
self.verify = verify
|
||||||
|
self.auth = auth
|
||||||
|
|
||||||
|
def _request(self, method: str, path: str, **kwargs) -> requests.Response:
|
||||||
|
"""Execute an HTTP request against the API.
|
||||||
|
|
||||||
|
The URL is constructed as ``{base_url}{path}``. Default timeout,
|
||||||
|
verify, auth, and headers are applied but can be overridden via
|
||||||
|
``kwargs``.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
requests.HTTPError: On 4xx/5xx response status codes.
|
||||||
|
"""
|
||||||
|
url = f"{self.base_url}{path}"
|
||||||
|
kwargs.setdefault("timeout", self.timeout)
|
||||||
|
kwargs.setdefault("verify", self.verify)
|
||||||
|
if self.auth is not None:
|
||||||
|
kwargs.setdefault("auth", self.auth)
|
||||||
|
resp = requests.request(method, url, headers=self.headers, **kwargs) # noqa: S113
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp
|
||||||
|
|
||||||
|
def get(self, path: str, **kwargs) -> requests.Response:
|
||||||
|
"""Send a GET request."""
|
||||||
|
return self._request("GET", path, **kwargs)
|
||||||
|
|
||||||
|
def post(self, path: str, **kwargs) -> requests.Response:
|
||||||
|
"""Send a POST request."""
|
||||||
|
return self._request("POST", path, **kwargs)
|
||||||
|
|
||||||
|
def put(self, path: str, **kwargs) -> requests.Response:
|
||||||
|
"""Send a PUT request."""
|
||||||
|
return self._request("PUT", path, **kwargs)
|
||||||
|
|
||||||
|
def delete(self, path: str, **kwargs) -> requests.Response:
|
||||||
|
"""Send a DELETE request."""
|
||||||
|
return self._request("DELETE", path, **kwargs)
|
||||||
|
|
||||||
|
def patch(self, path: str, **kwargs) -> requests.Response:
|
||||||
|
"""Send a PATCH request."""
|
||||||
|
return self._request("PATCH", path, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def is_truthy(value: str | bool | None) -> bool:
|
def is_truthy(value: str | bool | None) -> bool:
|
||||||
"""Check if an API config value is truthy.
|
"""Check if an API config value is truthy.
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
"""Shared Jinja2 environment helpers for unit tests and template rendering.
|
||||||
|
|
||||||
|
Creating a Jinja2 Environment is expensive (filesystem scanning, template
|
||||||
|
compilation). These helpers create cached environments with
|
||||||
|
``auto_reload=False`` to skip stat() calls on every ``get_template``,
|
||||||
|
which is the single biggest speedup for template-heavy test suites.
|
||||||
|
|
||||||
|
The filters mimic Ansible builtins not available in plain Jinja2,
|
||||||
|
making it possible to render Ansible templates outside of Ansible
|
||||||
|
(e.g. in unit tests or config generation scripts).
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from devx.utils.jinja import make_env, render_template
|
||||||
|
|
||||||
|
env = make_env("/path/to/templates")
|
||||||
|
output = render_template(env, "alert-rules.yml.j2", grafana_base_url="https://grafana.example.com")
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
|
import jinja2
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Filters (mimic Ansible builtins not available in plain Jinja2)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def to_json(value) -> str:
|
||||||
|
return json.dumps(value)
|
||||||
|
|
||||||
|
|
||||||
|
def to_bool(value) -> bool:
|
||||||
|
"""Mimic Ansible's |bool filter for plain Jinja2 tests."""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value.lower() not in ("", "false", "0", "no", "off", "null", "none")
|
||||||
|
return bool(value)
|
||||||
|
|
||||||
|
|
||||||
|
def regex_replace(value, pattern: str, replacement: str) -> str:
|
||||||
|
"""Mimic Ansible's |regex_replace filter."""
|
||||||
|
return re.sub(pattern, replacement, str(value))
|
||||||
|
|
||||||
|
|
||||||
|
def regex_escape(value) -> str:
|
||||||
|
"""Mimic Ansible's |regex_escape filter."""
|
||||||
|
return re.escape(str(value))
|
||||||
|
|
||||||
|
|
||||||
|
def regex_search(value, pattern: str) -> str | None:
|
||||||
|
"""Mimic Ansible's |regex_search filter.
|
||||||
|
|
||||||
|
Returns the first match (group 0) or None if no match.
|
||||||
|
Ansible returns the full match string or None.
|
||||||
|
"""
|
||||||
|
m = re.search(pattern, str(value))
|
||||||
|
return m.group(0) if m else None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Environment factory
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_FILTERS = {
|
||||||
|
"to_json": to_json,
|
||||||
|
"bool": to_bool,
|
||||||
|
"regex_replace": regex_replace,
|
||||||
|
"regex_escape": regex_escape,
|
||||||
|
"regex_search": regex_search,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@functools.cache
|
||||||
|
def make_env(loader_path: str) -> jinja2.Environment:
|
||||||
|
"""Create a cached Jinja2 Environment with standard filters.
|
||||||
|
|
||||||
|
``auto_reload=False`` skips stat() on every get_template call —
|
||||||
|
templates don't change during a test run so this is safe and
|
||||||
|
cuts ~40% off render time.
|
||||||
|
"""
|
||||||
|
env = jinja2.Environment( # nosec B701 — renders YAML/config templates, not HTML
|
||||||
|
loader=jinja2.FileSystemLoader(loader_path),
|
||||||
|
undefined=jinja2.StrictUndefined,
|
||||||
|
auto_reload=False,
|
||||||
|
cache_size=400,
|
||||||
|
)
|
||||||
|
env.filters.update(_FILTERS)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
@functools.cache
|
||||||
|
def make_value_env() -> jinja2.Environment:
|
||||||
|
"""Cached environment for rendering individual manifest string values."""
|
||||||
|
env = jinja2.Environment( # nosec B701 — renders config values, not HTML
|
||||||
|
undefined=jinja2.ChainableUndefined,
|
||||||
|
auto_reload=False,
|
||||||
|
)
|
||||||
|
env.filters.update(_FILTERS)
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Render helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def render_template(env: jinja2.Environment, template_name: str, **kwargs) -> str:
|
||||||
|
"""Render a named template from a FileSystemLoader-backed env."""
|
||||||
|
return env.get_template(template_name).render(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def render_value(value, ctx: dict):
|
||||||
|
"""Render a single string value as a Jinja2 template if it contains expressions."""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return value
|
||||||
|
if "{{" not in value and "{%" not in value:
|
||||||
|
return value
|
||||||
|
return make_value_env().from_string(value).render(**ctx)
|
||||||
|
|
||||||
|
|
||||||
|
def render_manifest_values(obj, ctx: dict):
|
||||||
|
"""Recursively render all Jinja2 expressions in manifest string values."""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
return {k: render_manifest_values(v, ctx) for k, v in obj.items()}
|
||||||
|
if isinstance(obj, list):
|
||||||
|
return [render_manifest_values(v, ctx) for v in obj]
|
||||||
|
return render_value(obj, ctx)
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""User-facing output utilities combining console and log output.
|
||||||
|
|
||||||
|
Console messages are colorised via ``click.style`` for visual feedback.
|
||||||
|
The persistent log file always receives plain text (no ANSI codes).
|
||||||
|
|
||||||
|
This is a generalisation of grm's ``ui.say()`` function, extracted so
|
||||||
|
that any CLI tool can use the same pattern. The logger name and
|
||||||
|
console-level env var are configurable.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from devx.utils.ui import say
|
||||||
|
|
||||||
|
say("Starting deployment...")
|
||||||
|
say("Error occurred", level=logging.ERROR, err=True, color="red")
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
# Configurable env var for console verbosity — projects can override
|
||||||
|
# via :func:`configure_ui`.
|
||||||
|
_LOG_LEVEL_ENV_VAR = "DEVX_LOG_LEVEL"
|
||||||
|
_LOGGER_NAME = "devx"
|
||||||
|
|
||||||
|
|
||||||
|
def configure_ui(*, log_level_env_var: str = "DEVX_LOG_LEVEL", logger_name: str = "devx") -> None:
|
||||||
|
"""Override the env var name and logger name used by :func:`say`.
|
||||||
|
|
||||||
|
This allows downstream projects (e.g. grm) to use their own env var
|
||||||
|
names (e.g. ``GRM_LOG_LEVEL``) and logger names while still using
|
||||||
|
devx's ui module.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
log_level_env_var: Environment variable name for console log level.
|
||||||
|
logger_name: Logger name for persistent log file output.
|
||||||
|
"""
|
||||||
|
global _LOG_LEVEL_ENV_VAR, _LOGGER_NAME
|
||||||
|
_LOG_LEVEL_ENV_VAR = log_level_env_var
|
||||||
|
_LOGGER_NAME = logger_name
|
||||||
|
|
||||||
|
|
||||||
|
def _console_level() -> int:
|
||||||
|
"""Return the minimum level for console output from the configured env var."""
|
||||||
|
value = os.getenv(_LOG_LEVEL_ENV_VAR, "INFO")
|
||||||
|
try:
|
||||||
|
return getattr(logging, value.upper())
|
||||||
|
except AttributeError:
|
||||||
|
return logging.INFO
|
||||||
|
|
||||||
|
|
||||||
|
def say(
|
||||||
|
msg: str,
|
||||||
|
level: int = logging.INFO,
|
||||||
|
err: bool = False,
|
||||||
|
color: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Output a message to the user and also log it for auditing.
|
||||||
|
|
||||||
|
Console output goes via ``click.echo`` (handles encoding, CliRunner,
|
||||||
|
Windows colorama) only when *level* is at least the configured
|
||||||
|
console log level (default ``DEVX_LOG_LEVEL``, falls back to INFO).
|
||||||
|
The same message is always sent to the configured logger so it
|
||||||
|
appears in the persistent log file regardless of console verbosity.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg: Message to display.
|
||||||
|
level: Logging level (e.g. ``logging.INFO``, ``logging.ERROR``).
|
||||||
|
err: If True, output to stderr instead of stdout.
|
||||||
|
color: Optional ``click.style`` fg color (e.g. ``"green"``, ``"red"``).
|
||||||
|
"""
|
||||||
|
if level >= _console_level():
|
||||||
|
styled = click.style(msg, fg=color) if color else msg
|
||||||
|
click.echo(styled, err=err)
|
||||||
|
logging.getLogger(_LOGGER_NAME).log(level, msg)
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""Unit tests for devx.ci.cancel_superseded_runs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import urllib.error
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import devx.ci.cancel_superseded_runs as mod
|
||||||
|
from devx.ci.cancel_superseded_runs import _api_request, cancel_run, list_running_runs, main
|
||||||
|
|
||||||
|
_HTTP_NO_CONTENT = mod._HTTP_NO_CONTENT
|
||||||
|
_PAGE_SIZE = mod._PAGE_SIZE
|
||||||
|
|
||||||
|
|
||||||
|
class TestConstants:
|
||||||
|
def test_http_no_content_is_204(self) -> None:
|
||||||
|
assert _HTTP_NO_CONTENT == 204
|
||||||
|
|
||||||
|
def test_page_size_is_50(self) -> None:
|
||||||
|
assert _PAGE_SIZE == 50
|
||||||
|
|
||||||
|
|
||||||
|
class TestApiRequest:
|
||||||
|
def test_returns_empty_for_204(self) -> None:
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.status = _HTTP_NO_CONTENT
|
||||||
|
mock_resp.read.return_value = b""
|
||||||
|
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
|
||||||
|
mock_resp.__exit__ = MagicMock(return_value=None)
|
||||||
|
with patch("urllib.request.urlopen", return_value=mock_resp):
|
||||||
|
result = _api_request("POST", "/repos/test/actions/runs/1/cancel", "tok", "https://x")
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
def test_returns_json_for_200(self) -> None:
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.status = 200
|
||||||
|
mock_resp.read.return_value = json.dumps({"id": 1}).encode()
|
||||||
|
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
|
||||||
|
mock_resp.__exit__ = MagicMock(return_value=None)
|
||||||
|
with patch("urllib.request.urlopen", return_value=mock_resp):
|
||||||
|
result = _api_request("GET", "/repos/test/actions/runs", "tok", "https://x")
|
||||||
|
assert result == {"id": 1}
|
||||||
|
|
||||||
|
def test_http_error_raises(self) -> None:
|
||||||
|
err = urllib.error.HTTPError("x", 500, "err", {}, None)
|
||||||
|
err.read = MagicMock(return_value=b"error body")
|
||||||
|
with patch("urllib.request.urlopen", side_effect=err):
|
||||||
|
with pytest.raises(urllib.error.HTTPError):
|
||||||
|
_api_request("GET", "/repos/test/actions/runs", "tok", "https://x")
|
||||||
|
|
||||||
|
def test_url_error_raises(self) -> None:
|
||||||
|
with patch("urllib.request.urlopen", side_effect=urllib.error.URLError("fail")):
|
||||||
|
with pytest.raises(urllib.error.URLError):
|
||||||
|
_api_request("GET", "/repos/test/actions/runs", "tok", "https://x")
|
||||||
|
|
||||||
|
|
||||||
|
class TestListRunningRuns:
|
||||||
|
def test_paginates_until_empty(self) -> None:
|
||||||
|
page1 = {"workflow_runs": [{"id": 1}, {"id": 2}], "total_count": 2}
|
||||||
|
page2 = {"workflow_runs": [], "total_count": 2}
|
||||||
|
responses = iter([page1, page2])
|
||||||
|
with patch.object(mod, "_api_request", side_effect=lambda *a, **k: next(responses)):
|
||||||
|
runs = list_running_runs("owner/repo", "tok", "https://x")
|
||||||
|
assert len(runs) == 2
|
||||||
|
|
||||||
|
def test_empty_first_page(self) -> None:
|
||||||
|
with patch.object(mod, "_api_request", return_value={"workflow_runs": [], "total_count": 0}):
|
||||||
|
runs = list_running_runs("owner/repo", "tok", "https://x")
|
||||||
|
assert runs == []
|
||||||
|
|
||||||
|
def test_stops_at_page_size(self) -> None:
|
||||||
|
full_page = {"workflow_runs": [{"id": i} for i in range(_PAGE_SIZE)], "total_count": _PAGE_SIZE + 1}
|
||||||
|
half_page = {"workflow_runs": [{"id": 99}], "total_count": _PAGE_SIZE + 1}
|
||||||
|
responses = iter([full_page, half_page])
|
||||||
|
with patch.object(mod, "_api_request", side_effect=lambda *a, **k: next(responses)):
|
||||||
|
runs = list_running_runs("owner/repo", "tok", "https://x")
|
||||||
|
assert len(runs) == _PAGE_SIZE + 1
|
||||||
|
|
||||||
|
def test_uses_in_progress_status(self) -> None:
|
||||||
|
with patch.object(mod, "_api_request", return_value={"workflow_runs": [], "total_count": 0}) as mock_req:
|
||||||
|
list_running_runs("owner/repo", "tok", "https://x")
|
||||||
|
path = mock_req.call_args.args[1]
|
||||||
|
assert "status=in_progress" in path
|
||||||
|
assert "status=running" not in path
|
||||||
|
|
||||||
|
def test_accepts_bare_list(self) -> None:
|
||||||
|
with patch.object(mod, "_api_request", return_value=[{"id": 1}, {"id": 2}]):
|
||||||
|
runs = list_running_runs("owner/repo", "tok", "https://x")
|
||||||
|
assert len(runs) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestCancelRun:
|
||||||
|
def test_success_returns_true(self) -> None:
|
||||||
|
with patch.object(mod, "_api_request", return_value={}):
|
||||||
|
assert cancel_run("owner/repo", 123, "tok", "https://x") is True
|
||||||
|
|
||||||
|
def test_http_error_returns_false(self) -> None:
|
||||||
|
with patch.object(mod, "_api_request", side_effect=urllib.error.HTTPError("x", 500, "err", {}, None)):
|
||||||
|
assert cancel_run("owner/repo", 123, "tok", "https://x") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestMain:
|
||||||
|
def test_no_token_exits_zero(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.delenv("CI_GITEA_API_TOKEN", raising=False)
|
||||||
|
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
|
||||||
|
monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "1", "--head-branch", "feat"])
|
||||||
|
assert main() == 0
|
||||||
|
|
||||||
|
def test_no_superseded_runs(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok")
|
||||||
|
monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat"])
|
||||||
|
with patch.object(mod, "list_running_runs", return_value=[]):
|
||||||
|
assert main() == 0
|
||||||
|
|
||||||
|
def test_cancels_superseded(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok")
|
||||||
|
runs = [
|
||||||
|
{"id": 5, "head_branch": "feat"},
|
||||||
|
{"id": 8, "head_branch": "feat"},
|
||||||
|
{"id": 12, "head_branch": "other"},
|
||||||
|
]
|
||||||
|
monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat"])
|
||||||
|
with patch.object(mod, "list_running_runs", return_value=runs):
|
||||||
|
with patch.object(mod, "cancel_run", return_value=True) as mock_cancel:
|
||||||
|
assert main() == 0
|
||||||
|
cancelled_ids = [call.args[1] for call in mock_cancel.call_args_list]
|
||||||
|
assert cancelled_ids == [5, 8]
|
||||||
|
|
||||||
|
def test_dry_run_does_not_cancel(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok")
|
||||||
|
runs = [{"id": 5, "head_branch": "feat"}]
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"sys.argv",
|
||||||
|
["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat", "--dry-run"],
|
||||||
|
)
|
||||||
|
with patch.object(mod, "list_running_runs", return_value=runs):
|
||||||
|
with patch.object(mod, "cancel_run", return_value=True) as mock_cancel:
|
||||||
|
assert main() == 0
|
||||||
|
assert mock_cancel.call_count == 0
|
||||||
|
|
||||||
|
def test_cancel_failure_continues(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok")
|
||||||
|
runs = [{"id": 5, "head_branch": "feat"}, {"id": 8, "head_branch": "feat"}]
|
||||||
|
monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat"])
|
||||||
|
with patch.object(mod, "list_running_runs", return_value=runs):
|
||||||
|
with patch.object(mod, "cancel_run", side_effect=[False, True]):
|
||||||
|
assert main() == 0
|
||||||
|
|
||||||
|
def test_404_returns_zero(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok")
|
||||||
|
monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat"])
|
||||||
|
err = urllib.error.HTTPError("x", 404, "Not Found", {}, None)
|
||||||
|
with patch.object(mod, "list_running_runs", side_effect=err):
|
||||||
|
assert main() == 0
|
||||||
|
|
||||||
|
def test_400_returns_zero(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok")
|
||||||
|
monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat"])
|
||||||
|
err = urllib.error.HTTPError("x", 400, "Bad Request", {}, None)
|
||||||
|
with patch.object(mod, "list_running_runs", side_effect=err):
|
||||||
|
assert main() == 0
|
||||||
|
|
||||||
|
def test_500_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setenv("CI_GITEA_API_TOKEN", "tok")
|
||||||
|
monkeypatch.setattr("sys.argv", ["cancel", "--repo", "o/r", "--current-run-id", "10", "--head-branch", "feat"])
|
||||||
|
err = urllib.error.HTTPError("x", 500, "Server Error", {}, None)
|
||||||
|
with patch.object(mod, "list_running_runs", side_effect=err):
|
||||||
|
with pytest.raises(urllib.error.HTTPError):
|
||||||
|
main()
|
||||||
@@ -0,0 +1,419 @@
|
|||||||
|
"""Unit tests for devx.ci.check_workflow_artifact_deps."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import textwrap
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.ci.check_workflow_artifact_deps import (
|
||||||
|
_check_workflow,
|
||||||
|
_extract_artifact_info,
|
||||||
|
_is_artifact_action,
|
||||||
|
main,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsArtifactAction:
|
||||||
|
def test_upload_action_gitea(self):
|
||||||
|
assert _is_artifact_action("christopherhx/gitea-upload-artifact@v4", ("upload-artifact",))
|
||||||
|
|
||||||
|
def test_upload_action_github(self):
|
||||||
|
assert _is_artifact_action("actions/upload-artifact@v4", ("upload-artifact",))
|
||||||
|
|
||||||
|
def test_download_action(self):
|
||||||
|
assert _is_artifact_action("christopherhx/gitea-download-artifact@v4", ("download-artifact",))
|
||||||
|
|
||||||
|
def test_non_artifact_action(self):
|
||||||
|
assert not _is_artifact_action("actions/checkout@v4", ("upload-artifact",))
|
||||||
|
|
||||||
|
def test_empty_string(self):
|
||||||
|
assert not _is_artifact_action("", ("upload-artifact",))
|
||||||
|
|
||||||
|
def test_case_insensitive(self):
|
||||||
|
assert _is_artifact_action("Actions/Upload-Artifact@v4", ("upload-artifact",))
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractArtifactInfo:
|
||||||
|
def test_uploads_and_downloads(self):
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
workflow_yaml = textwrap.dedent("""
|
||||||
|
jobs:
|
||||||
|
producer:
|
||||||
|
steps:
|
||||||
|
- name: Upload config
|
||||||
|
uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: config-${{ github.run_id }}
|
||||||
|
consumer:
|
||||||
|
needs: [producer]
|
||||||
|
steps:
|
||||||
|
- name: Download config
|
||||||
|
uses: christopherhx/gitea-download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: config-${{ github.run_id }}
|
||||||
|
""").strip()
|
||||||
|
wf = yaml.safe_load(workflow_yaml)
|
||||||
|
uploads, downloads = _extract_artifact_info(wf)
|
||||||
|
assert uploads == {"config-${{ github.run_id }}": ["producer"]}
|
||||||
|
assert downloads == [("consumer", "config-${{ github.run_id }}", "Download config")]
|
||||||
|
|
||||||
|
def test_no_artifacts(self):
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
workflow_yaml = textwrap.dedent("""
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
""").strip()
|
||||||
|
wf = yaml.safe_load(workflow_yaml)
|
||||||
|
uploads, downloads = _extract_artifact_info(wf)
|
||||||
|
assert uploads == {}
|
||||||
|
assert downloads == []
|
||||||
|
|
||||||
|
def test_multiple_uploaders_same_artifact(self):
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
workflow_yaml = textwrap.dedent("""
|
||||||
|
jobs:
|
||||||
|
producer-a:
|
||||||
|
steps:
|
||||||
|
- uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: shared
|
||||||
|
producer-b:
|
||||||
|
steps:
|
||||||
|
- uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: shared
|
||||||
|
""").strip()
|
||||||
|
wf = yaml.safe_load(workflow_yaml)
|
||||||
|
uploads, downloads = _extract_artifact_info(wf)
|
||||||
|
assert uploads == {"shared": ["producer-a", "producer-b"]}
|
||||||
|
|
||||||
|
def test_step_without_name(self):
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
workflow_yaml = textwrap.dedent("""
|
||||||
|
jobs:
|
||||||
|
producer:
|
||||||
|
steps:
|
||||||
|
- uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: data
|
||||||
|
consumer:
|
||||||
|
needs: [producer]
|
||||||
|
steps:
|
||||||
|
- uses: christopherhx/gitea-download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: data
|
||||||
|
""").strip()
|
||||||
|
wf = yaml.safe_load(workflow_yaml)
|
||||||
|
uploads, downloads = _extract_artifact_info(wf)
|
||||||
|
assert downloads == [("consumer", "data", "")]
|
||||||
|
|
||||||
|
def test_upload_without_name_skipped(self):
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
workflow_yaml = textwrap.dedent("""
|
||||||
|
jobs:
|
||||||
|
producer:
|
||||||
|
steps:
|
||||||
|
- uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
path: ./dist
|
||||||
|
""").strip()
|
||||||
|
wf = yaml.safe_load(workflow_yaml)
|
||||||
|
uploads, downloads = _extract_artifact_info(wf)
|
||||||
|
assert uploads == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckWorkflow:
|
||||||
|
def test_valid_dependency(self, tmp_path: Path):
|
||||||
|
workflow_yaml = textwrap.dedent("""
|
||||||
|
jobs:
|
||||||
|
producer:
|
||||||
|
steps:
|
||||||
|
- name: Upload config
|
||||||
|
uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: config
|
||||||
|
consumer:
|
||||||
|
needs: [producer]
|
||||||
|
steps:
|
||||||
|
- name: Download config
|
||||||
|
uses: christopherhx/gitea-download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: config
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "test.yml"
|
||||||
|
f.write_text(workflow_yaml)
|
||||||
|
assert _check_workflow(f) == []
|
||||||
|
|
||||||
|
def test_missing_dependency(self, tmp_path: Path):
|
||||||
|
workflow_yaml = textwrap.dedent("""
|
||||||
|
jobs:
|
||||||
|
producer:
|
||||||
|
steps:
|
||||||
|
- name: Upload config
|
||||||
|
uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: config
|
||||||
|
consumer:
|
||||||
|
needs: [other-job]
|
||||||
|
steps:
|
||||||
|
- name: Download config
|
||||||
|
uses: christopherhx/gitea-download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: config
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "test.yml"
|
||||||
|
f.write_text(workflow_yaml)
|
||||||
|
errors = _check_workflow(f)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "consumer" in errors[0]
|
||||||
|
assert "producer" in errors[0]
|
||||||
|
|
||||||
|
def test_no_needs_at_all(self, tmp_path: Path):
|
||||||
|
workflow_yaml = textwrap.dedent("""
|
||||||
|
jobs:
|
||||||
|
producer:
|
||||||
|
steps:
|
||||||
|
- uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: data
|
||||||
|
consumer:
|
||||||
|
steps:
|
||||||
|
- uses: christopherhx/gitea-download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: data
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "test.yml"
|
||||||
|
f.write_text(workflow_yaml)
|
||||||
|
errors = _check_workflow(f)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "consumer" in errors[0]
|
||||||
|
|
||||||
|
def test_artifact_not_uploaded_in_workflow(self, tmp_path: Path):
|
||||||
|
workflow_yaml = textwrap.dedent("""
|
||||||
|
jobs:
|
||||||
|
consumer:
|
||||||
|
steps:
|
||||||
|
- name: Download external
|
||||||
|
uses: christopherhx/gitea-download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: external-artifact
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "test.yml"
|
||||||
|
f.write_text(workflow_yaml)
|
||||||
|
assert _check_workflow(f) == []
|
||||||
|
|
||||||
|
def test_multiple_uploaders_one_in_needs(self, tmp_path: Path):
|
||||||
|
workflow_yaml = textwrap.dedent("""
|
||||||
|
jobs:
|
||||||
|
producer-a:
|
||||||
|
steps:
|
||||||
|
- uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: shared
|
||||||
|
producer-b:
|
||||||
|
steps:
|
||||||
|
- uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: shared
|
||||||
|
consumer:
|
||||||
|
needs: [producer-a, other]
|
||||||
|
steps:
|
||||||
|
- uses: christopherhx/gitea-download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: shared
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "test.yml"
|
||||||
|
f.write_text(workflow_yaml)
|
||||||
|
assert _check_workflow(f) == []
|
||||||
|
|
||||||
|
def test_string_needs(self, tmp_path: Path):
|
||||||
|
workflow_yaml = textwrap.dedent("""
|
||||||
|
jobs:
|
||||||
|
producer:
|
||||||
|
steps:
|
||||||
|
- uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: data
|
||||||
|
consumer:
|
||||||
|
needs: producer
|
||||||
|
steps:
|
||||||
|
- uses: christopherhx/gitea-download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: data
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "test.yml"
|
||||||
|
f.write_text(workflow_yaml)
|
||||||
|
assert _check_workflow(f) == []
|
||||||
|
|
||||||
|
def test_needs_null(self, tmp_path: Path):
|
||||||
|
workflow_yaml = textwrap.dedent("""
|
||||||
|
jobs:
|
||||||
|
producer:
|
||||||
|
steps:
|
||||||
|
- uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: data
|
||||||
|
consumer:
|
||||||
|
needs: null
|
||||||
|
steps:
|
||||||
|
- uses: christopherhx/gitea-download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: data
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "test.yml"
|
||||||
|
f.write_text(workflow_yaml)
|
||||||
|
errors = _check_workflow(f)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "consumer" in errors[0]
|
||||||
|
|
||||||
|
def test_invalid_yaml(self, tmp_path: Path):
|
||||||
|
f = tmp_path / "test.yml"
|
||||||
|
f.write_text("jobs: [invalid yaml: {")
|
||||||
|
errors = _check_workflow(f)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "cannot parse YAML" in errors[0]
|
||||||
|
|
||||||
|
def test_not_a_dict(self, tmp_path: Path):
|
||||||
|
f = tmp_path / "test.yml"
|
||||||
|
f.write_text("just a string")
|
||||||
|
errors = _check_workflow(f)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "not a valid workflow" in errors[0]
|
||||||
|
|
||||||
|
def test_no_jobs(self, tmp_path: Path):
|
||||||
|
f = tmp_path / "test.yml"
|
||||||
|
f.write_text("name: empty\non: push\n")
|
||||||
|
assert _check_workflow(f) == []
|
||||||
|
|
||||||
|
def test_continue_on_error_guard(self, tmp_path: Path):
|
||||||
|
workflow_yaml = textwrap.dedent("""
|
||||||
|
jobs:
|
||||||
|
producer:
|
||||||
|
steps:
|
||||||
|
- name: Upload config
|
||||||
|
uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: config
|
||||||
|
consumer:
|
||||||
|
needs: [other-job]
|
||||||
|
steps:
|
||||||
|
- name: Download config
|
||||||
|
continue-on-error: true
|
||||||
|
uses: christopherhx/gitea-download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: config
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "test.yml"
|
||||||
|
f.write_text(workflow_yaml)
|
||||||
|
assert _check_workflow(f) == []
|
||||||
|
|
||||||
|
def test_continue_on_error_false_still_errors(self, tmp_path: Path):
|
||||||
|
workflow_yaml = textwrap.dedent("""
|
||||||
|
jobs:
|
||||||
|
producer:
|
||||||
|
steps:
|
||||||
|
- name: Upload config
|
||||||
|
uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: config
|
||||||
|
consumer:
|
||||||
|
needs: [other-job]
|
||||||
|
steps:
|
||||||
|
- name: Download config
|
||||||
|
continue-on-error: false
|
||||||
|
uses: christopherhx/gitea-download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: config
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "test.yml"
|
||||||
|
f.write_text(workflow_yaml)
|
||||||
|
errors = _check_workflow(f)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "consumer" in errors[0]
|
||||||
|
|
||||||
|
def test_job_with_no_steps(self, tmp_path: Path):
|
||||||
|
workflow_yaml = textwrap.dedent("""
|
||||||
|
jobs:
|
||||||
|
empty:
|
||||||
|
runs-on: docker
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "test.yml"
|
||||||
|
f.write_text(workflow_yaml)
|
||||||
|
assert _check_workflow(f) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestMain:
|
||||||
|
def test_passes_when_valid(self, tmp_path: Path):
|
||||||
|
workflow_yaml = textwrap.dedent("""
|
||||||
|
jobs:
|
||||||
|
producer:
|
||||||
|
steps:
|
||||||
|
- uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: data
|
||||||
|
consumer:
|
||||||
|
needs: [producer]
|
||||||
|
steps:
|
||||||
|
- uses: christopherhx/gitea-download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: data
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "test.yml"
|
||||||
|
f.write_text(workflow_yaml)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--workflows-dir", str(tmp_path)])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "OK" in result.output
|
||||||
|
|
||||||
|
def test_fails_when_missing_dep(self, tmp_path: Path):
|
||||||
|
workflow_yaml = textwrap.dedent("""
|
||||||
|
jobs:
|
||||||
|
producer:
|
||||||
|
steps:
|
||||||
|
- uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: data
|
||||||
|
consumer:
|
||||||
|
steps:
|
||||||
|
- uses: christopherhx/gitea-download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: data
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "test.yml"
|
||||||
|
f.write_text(workflow_yaml)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--workflows-dir", str(tmp_path)])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "FAIL" in result.output
|
||||||
|
assert "consumer" in result.output
|
||||||
|
|
||||||
|
def test_specific_workflow_file(self, tmp_path: Path):
|
||||||
|
workflow_yaml = textwrap.dedent("""
|
||||||
|
jobs:
|
||||||
|
producer:
|
||||||
|
steps:
|
||||||
|
- uses: christopherhx/gitea-upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: data
|
||||||
|
consumer:
|
||||||
|
needs: [producer]
|
||||||
|
steps:
|
||||||
|
- uses: christopherhx/gitea-download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: data
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "test.yml"
|
||||||
|
f.write_text(workflow_yaml)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--workflow", str(f)])
|
||||||
|
assert result.exit_code == 0
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
"""Unit tests for devx.ci.check_workflow_tofu_init."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import textwrap
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
import devx.ci.check_workflow_tofu_init as mod
|
||||||
|
from devx.ci.check_workflow_tofu_init import _check_workflow, main
|
||||||
|
|
||||||
|
|
||||||
|
def _write_workflow(tmp_path: Path, content: str) -> Path:
|
||||||
|
filepath = tmp_path / "test.yml"
|
||||||
|
filepath.write_text(textwrap.dedent(content), encoding="utf-8")
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckWorkflow:
|
||||||
|
def test_passes_when_tofu_init_present(self, tmp_path: Path) -> None:
|
||||||
|
filepath = _write_workflow(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
name: Test
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- run: python3 scripts/create_production_deployment.py --phase tofu-init
|
||||||
|
- run: python3 scripts/preflight_deploy.py --env production
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
assert _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) == []
|
||||||
|
|
||||||
|
def test_fails_when_tofu_init_missing(self, tmp_path: Path) -> None:
|
||||||
|
filepath = _write_workflow(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
name: Test
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
preflight:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- run: python3 scripts/preflight_deploy.py --env production
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
errors = _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "preflight" in errors[0]
|
||||||
|
assert "tofu-init" in errors[0]
|
||||||
|
|
||||||
|
def test_passes_when_direct_tofu_init(self, tmp_path: Path) -> None:
|
||||||
|
filepath = _write_workflow(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
name: Test
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- run: tofu init
|
||||||
|
- run: tofu output -json
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
assert _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) == []
|
||||||
|
|
||||||
|
def test_fails_when_direct_tofu_output_without_init(self, tmp_path: Path) -> None:
|
||||||
|
filepath = _write_workflow(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
name: Test
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- run: tofu output -json
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
errors = _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "check" in errors[0]
|
||||||
|
|
||||||
|
def test_passes_when_no_tofu_usage(self, tmp_path: Path) -> None:
|
||||||
|
filepath = _write_workflow(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
name: Test
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
lint:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- run: make lint
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
assert _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) == []
|
||||||
|
|
||||||
|
def test_passes_with_staging_deployment_tofu_init(self, tmp_path: Path) -> None:
|
||||||
|
filepath = _write_workflow(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
name: Test
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- run: python3 scripts/create_staging_deployment.py --phase tofu-init
|
||||||
|
- run: python3 scripts/create_staging_deployment.py --phase deploy
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
assert _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) == []
|
||||||
|
|
||||||
|
def test_fails_with_tofu_plan_without_init(self, tmp_path: Path) -> None:
|
||||||
|
filepath = _write_workflow(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
name: Test
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
plan:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- run: tofu plan
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
errors = _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "plan" in errors[0]
|
||||||
|
|
||||||
|
def test_fails_with_tofu_apply_without_init(self, tmp_path: Path) -> None:
|
||||||
|
filepath = _write_workflow(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
name: Test
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
apply:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- run: tofu apply -auto-approve
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
errors = _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "apply" in errors[0]
|
||||||
|
|
||||||
|
def test_multiple_jobs_one_missing(self, tmp_path: Path) -> None:
|
||||||
|
filepath = _write_workflow(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
name: Test
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
good:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- run: python3 scripts/create_production_deployment.py --phase tofu-init
|
||||||
|
- run: python3 scripts/preflight_deploy.py --env production
|
||||||
|
bad:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- run: python3 scripts/preflight_deploy.py --env production
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
errors = _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "bad" in errors[0]
|
||||||
|
|
||||||
|
def test_no_steps_passes(self, tmp_path: Path) -> None:
|
||||||
|
filepath = _write_workflow(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
name: Test
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
empty:
|
||||||
|
runs-on: docker
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
assert _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) == []
|
||||||
|
|
||||||
|
def test_destroy_orphans_does_not_require_init(self, tmp_path: Path) -> None:
|
||||||
|
filepath = _write_workflow(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
name: Test
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
cleanup:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- run: python3 scripts/destroy_orphans.py
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
assert _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) == []
|
||||||
|
|
||||||
|
def test_invalid_yaml_returns_error(self, tmp_path: Path) -> None:
|
||||||
|
filepath = tmp_path / "bad.yml"
|
||||||
|
filepath.write_text("jobs: [invalid yaml: {", encoding="utf-8")
|
||||||
|
errors = _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "cannot parse YAML" in errors[0]
|
||||||
|
|
||||||
|
def test_tofu_show_requires_init(self, tmp_path: Path) -> None:
|
||||||
|
filepath = _write_workflow(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
name: Test
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
show:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- run: tofu show -json
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
errors = _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "show" in errors[0]
|
||||||
|
|
||||||
|
def test_custom_state_scripts(self, tmp_path: Path) -> None:
|
||||||
|
filepath = _write_workflow(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
name: Test
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
custom:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- run: python3 scripts/my_custom_script.py
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
errors = _check_workflow(filepath, {"my_custom_script.py"})
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "custom" in errors[0]
|
||||||
|
|
||||||
|
def test_step_with_no_run_skipped(self, tmp_path: Path) -> None:
|
||||||
|
"""A step with no 'run' key should be skipped (line 80 continue)."""
|
||||||
|
filepath = _write_workflow(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
name: Test
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- run: tofu init
|
||||||
|
- run: tofu output
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
assert _check_workflow(filepath, mod.DEFAULT_TOFU_STATE_SCRIPTS) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestCli:
|
||||||
|
def test_passes_with_specific_workflow(self, tmp_path: Path) -> None:
|
||||||
|
filepath = _write_workflow(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
name: Test
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- run: tofu init
|
||||||
|
- run: tofu output
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--workflow", str(filepath)])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "OK" in result.output
|
||||||
|
|
||||||
|
def test_fails_with_missing_tofu_init(self, tmp_path: Path) -> None:
|
||||||
|
filepath = _write_workflow(
|
||||||
|
tmp_path,
|
||||||
|
"""
|
||||||
|
name: Test
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
preflight:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- run: python3 scripts/preflight_deploy.py --env production
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--workflow", str(filepath)])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "FAIL" in result.output
|
||||||
|
assert "preflight" in result.output
|
||||||
|
|
||||||
|
def test_checks_all_workflows_by_default(self, tmp_path: Path) -> None:
|
||||||
|
workflows_dir = tmp_path / "workflows"
|
||||||
|
workflows_dir.mkdir()
|
||||||
|
(workflows_dir / "good.yml").write_text(
|
||||||
|
textwrap.dedent("""
|
||||||
|
name: Good
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- run: tofu init
|
||||||
|
- run: tofu output
|
||||||
|
"""),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(workflows_dir / "bad.yml").write_text(
|
||||||
|
textwrap.dedent("""
|
||||||
|
name: Bad
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- run: tofu output
|
||||||
|
"""),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--workflows-dir", str(workflows_dir)])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "bad.yml" in result.output
|
||||||
|
assert "check" in result.output
|
||||||
|
|
||||||
|
def test_all_workflows_pass(self, tmp_path: Path) -> None:
|
||||||
|
workflows_dir = tmp_path / "workflows"
|
||||||
|
workflows_dir.mkdir()
|
||||||
|
(workflows_dir / "ok.yml").write_text(
|
||||||
|
textwrap.dedent("""
|
||||||
|
name: OK
|
||||||
|
on: push
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: docker
|
||||||
|
steps:
|
||||||
|
- run: tofu init
|
||||||
|
- run: tofu plan
|
||||||
|
"""),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--workflows-dir", str(workflows_dir)])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "OK" in result.output
|
||||||
@@ -40,7 +40,6 @@ class TestCliGroups:
|
|||||||
result = runner.invoke(cli, ["molecule", "--help"])
|
result = runner.invoke(cli, ["molecule", "--help"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "distribute" in result.output
|
assert "distribute" in result.output
|
||||||
assert "guard" in result.output
|
|
||||||
assert "all" in result.output
|
assert "all" in result.output
|
||||||
|
|
||||||
|
|
||||||
@@ -231,13 +230,6 @@ class TestMoleculeCommands:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
mock_run.assert_called_once_with("devx.molecule.discover_runners", [])
|
mock_run.assert_called_once_with("devx.molecule.discover_runners", [])
|
||||||
|
|
||||||
@patch("devx.cli._run_module")
|
|
||||||
def test_molecule_guard(self, mock_run: MagicMock) -> None:
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["molecule", "guard"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
mock_run.assert_called_once_with("devx.molecule.molecule_ci_guard", [])
|
|
||||||
|
|
||||||
@patch("devx.cli._run_module")
|
@patch("devx.cli._run_module")
|
||||||
def test_molecule_all(self, mock_run: MagicMock) -> None:
|
def test_molecule_all(self, mock_run: MagicMock) -> None:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|||||||
@@ -311,6 +311,61 @@ class TestDiscoverMultiRole:
|
|||||||
with pytest.raises(click.ClickException):
|
with pytest.raises(click.ClickException):
|
||||||
discover_multi_role_scenarios()
|
discover_multi_role_scenarios()
|
||||||
|
|
||||||
|
def test_include_roles_filters_to_subset(self, tmp_path: Path) -> None:
|
||||||
|
roles = tmp_path / "roles"
|
||||||
|
for scenario in ["default"]:
|
||||||
|
(roles / "docker_base" / "molecule" / scenario).mkdir(parents=True)
|
||||||
|
(roles / "crowdsec" / "molecule" / scenario).mkdir(parents=True)
|
||||||
|
(roles / "app_container" / "molecule" / scenario).mkdir(parents=True)
|
||||||
|
result = discover_multi_role_scenarios(roles, include_roles=["docker_base", "crowdsec"])
|
||||||
|
assert ("docker_base", "default") in result
|
||||||
|
assert ("crowdsec", "default") in result
|
||||||
|
assert ("app_container", "default") not in result
|
||||||
|
assert len(result) == 2
|
||||||
|
|
||||||
|
def test_include_roles_case_insensitive(self, tmp_path: Path) -> None:
|
||||||
|
roles = tmp_path / "roles"
|
||||||
|
(roles / "Docker_Base" / "molecule" / "default").mkdir(parents=True)
|
||||||
|
(roles / "other" / "molecule" / "default").mkdir(parents=True)
|
||||||
|
result = discover_multi_role_scenarios(roles, include_roles=["docker_base"])
|
||||||
|
assert ("Docker_Base", "default") in result
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
def test_exclude_roles_skips_subset(self, tmp_path: Path) -> None:
|
||||||
|
roles = tmp_path / "roles"
|
||||||
|
for scenario in ["default"]:
|
||||||
|
(roles / "docker_base" / "molecule" / scenario).mkdir(parents=True)
|
||||||
|
(roles / "crowdsec" / "molecule" / scenario).mkdir(parents=True)
|
||||||
|
(roles / "app_container" / "molecule" / scenario).mkdir(parents=True)
|
||||||
|
result = discover_multi_role_scenarios(roles, exclude_roles=["docker_base", "crowdsec"])
|
||||||
|
assert ("docker_base", "default") not in result
|
||||||
|
assert ("crowdsec", "default") not in result
|
||||||
|
assert ("app_container", "default") in result
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
def test_exclude_roles_case_insensitive(self, tmp_path: Path) -> None:
|
||||||
|
roles = tmp_path / "roles"
|
||||||
|
(roles / "Docker_Base" / "molecule" / "default").mkdir(parents=True)
|
||||||
|
(roles / "other" / "molecule" / "default").mkdir(parents=True)
|
||||||
|
result = discover_multi_role_scenarios(roles, exclude_roles=["docker_base"])
|
||||||
|
assert ("Docker_Base", "default") not in result
|
||||||
|
assert ("other", "default") in result
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
def test_include_and_exclude_combined(self, tmp_path: Path) -> None:
|
||||||
|
roles = tmp_path / "roles"
|
||||||
|
for scenario in ["default"]:
|
||||||
|
(roles / "docker_base" / "molecule" / scenario).mkdir(parents=True)
|
||||||
|
(roles / "crowdsec" / "molecule" / scenario).mkdir(parents=True)
|
||||||
|
(roles / "app_container" / "molecule" / scenario).mkdir(parents=True)
|
||||||
|
result = discover_multi_role_scenarios(
|
||||||
|
roles, include_roles=["docker_base", "crowdsec", "app_container"], exclude_roles=["crowdsec"]
|
||||||
|
)
|
||||||
|
assert ("docker_base", "default") in result
|
||||||
|
assert ("crowdsec", "default") not in result
|
||||||
|
assert ("app_container", "default") in result
|
||||||
|
assert len(result) == 2
|
||||||
|
|
||||||
def test_default_roles_root_constant(self) -> None:
|
def test_default_roles_root_constant(self) -> None:
|
||||||
assert Path("ansible/roles") == DEFAULT_ROLES_ROOT
|
assert Path("ansible/roles") == DEFAULT_ROLES_ROOT
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""Unit tests for devx.i18n."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import devx.i18n as i18n_mod
|
||||||
|
from devx.i18n import _, configure_i18n
|
||||||
|
|
||||||
|
|
||||||
|
class TestTranslate:
|
||||||
|
def test_returns_english_by_default(self) -> None:
|
||||||
|
with pytest.MonkeyPatch().context() as mp:
|
||||||
|
mp.delenv("DEVX_LANG", raising=False)
|
||||||
|
assert _("Running tests") == "Running tests"
|
||||||
|
|
||||||
|
def test_returns_key_when_missing(self) -> None:
|
||||||
|
with pytest.MonkeyPatch().context() as mp:
|
||||||
|
mp.delenv("DEVX_LANG", raising=False)
|
||||||
|
assert _("nonexistent.key.xyz") == "nonexistent.key.xyz"
|
||||||
|
|
||||||
|
def test_formats_kwargs(self) -> None:
|
||||||
|
# Find a key with format placeholders
|
||||||
|
for key, translations in i18n_mod.TRANSLATIONS.items():
|
||||||
|
en = translations.get("en", "")
|
||||||
|
if "{" in en:
|
||||||
|
with pytest.MonkeyPatch().context() as mp:
|
||||||
|
mp.delenv("DEVX_LANG", raising=False)
|
||||||
|
result = _(key, **dict.fromkeys(_extract_format_keys(en), "x"))
|
||||||
|
assert "{" not in result
|
||||||
|
return
|
||||||
|
pytest.skip("No key with format placeholders found")
|
||||||
|
|
||||||
|
def test_invalid_lang_falls_back_to_english(self) -> None:
|
||||||
|
with pytest.MonkeyPatch().context() as mp:
|
||||||
|
mp.setenv("DEVX_LANG", "fr")
|
||||||
|
assert _("Running tests") == "Running tests"
|
||||||
|
|
||||||
|
def test_bulgarian_translation(self) -> None:
|
||||||
|
with pytest.MonkeyPatch().context() as mp:
|
||||||
|
mp.setenv("DEVX_LANG", "bg")
|
||||||
|
# Find a key that has a Bulgarian translation
|
||||||
|
for key, translations in i18n_mod.TRANSLATIONS.items():
|
||||||
|
if "bg" in translations:
|
||||||
|
result = _(key)
|
||||||
|
assert result == translations["bg"]
|
||||||
|
return
|
||||||
|
pytest.skip("No Bulgarian translation found")
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigureI18n:
|
||||||
|
def test_custom_lang_env_var(self) -> None:
|
||||||
|
configure_i18n(lang_env_var="GRM_LANG")
|
||||||
|
try:
|
||||||
|
with pytest.MonkeyPatch().context() as mp:
|
||||||
|
mp.setenv("GRM_LANG", "bg")
|
||||||
|
mp.delenv("DEVX_LANG", raising=False)
|
||||||
|
# Find a key with Bulgarian translation
|
||||||
|
for key, translations in i18n_mod.TRANSLATIONS.items():
|
||||||
|
if "bg" in translations:
|
||||||
|
assert _(key) == translations["bg"]
|
||||||
|
return
|
||||||
|
pytest.skip("No Bulgarian translation found")
|
||||||
|
finally:
|
||||||
|
configure_i18n() # Reset to defaults
|
||||||
|
|
||||||
|
def test_custom_translations_path_env_var(self, tmp_path) -> None:
|
||||||
|
custom_translations = {"custom.key": {"en": "Custom Value", "bg": "Персонализирано"}}
|
||||||
|
custom_file = tmp_path / "custom.json"
|
||||||
|
custom_file.write_text(__import__("json").dumps(custom_translations))
|
||||||
|
|
||||||
|
configure_i18n(translations_path_env_var="GRM_TRANSLATIONS_PATH")
|
||||||
|
try:
|
||||||
|
# Use i18n_mod.TRANSLATIONS (not a stale import) — other tests
|
||||||
|
# may call importlib.reload(devx.i18n), replacing the dict object.
|
||||||
|
translations = i18n_mod.TRANSLATIONS
|
||||||
|
original = dict(translations)
|
||||||
|
translations.update(custom_translations)
|
||||||
|
try:
|
||||||
|
with pytest.MonkeyPatch().context() as mp:
|
||||||
|
mp.setenv("GRM_TRANSLATIONS_PATH", str(custom_file))
|
||||||
|
assert _("custom.key") == "Custom Value"
|
||||||
|
finally:
|
||||||
|
translations.clear()
|
||||||
|
translations.update(original)
|
||||||
|
finally:
|
||||||
|
configure_i18n() # Reset to defaults
|
||||||
|
|
||||||
|
def test_reset_to_defaults(self) -> None:
|
||||||
|
configure_i18n(lang_env_var="GRM_LANG")
|
||||||
|
configure_i18n() # Reset
|
||||||
|
assert i18n_mod._lang_env_var == "DEVX_LANG"
|
||||||
|
assert i18n_mod._translations_path_env_var == "DEVX_TRANSLATIONS_PATH"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_format_keys(template: str) -> list[str]:
|
||||||
|
"""Extract {key} format placeholders from a template string."""
|
||||||
|
import re
|
||||||
|
|
||||||
|
return re.findall(r"\{(\w+)\}", template)
|
||||||
@@ -238,6 +238,33 @@ class TestInstallTea:
|
|||||||
assert install_tools.install_tea() is True
|
assert install_tools.install_tea() is True
|
||||||
assert (tmp_path / "tea").exists()
|
assert (tmp_path / "tea").exists()
|
||||||
|
|
||||||
|
def test_install_fallback_to_second_url(self, tmp_path: Path) -> None:
|
||||||
|
"""First URL fails (403), second URL succeeds."""
|
||||||
|
call_count = [0]
|
||||||
|
|
||||||
|
def _download_side_effect(url: str, dest: Path) -> None:
|
||||||
|
call_count[0] += 1
|
||||||
|
if call_count[0] == 1:
|
||||||
|
raise OSError("HTTP Error 403: Forbidden")
|
||||||
|
Path(dest).write_bytes(b"binary")
|
||||||
|
|
||||||
|
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(install_tools, "_download", side_effect=_download_side_effect):
|
||||||
|
assert install_tools.install_tea() is True
|
||||||
|
assert (tmp_path / "tea").exists()
|
||||||
|
assert call_count[0] == 2
|
||||||
|
|
||||||
|
def test_install_all_urls_fail(self, tmp_path: Path) -> None:
|
||||||
|
"""All URLs fail — should raise ClickException."""
|
||||||
|
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(install_tools, "_download", side_effect=OSError("403 Forbidden")):
|
||||||
|
with pytest.raises(ClickException, match="Failed to download tea"):
|
||||||
|
install_tools.install_tea()
|
||||||
|
|
||||||
|
|
||||||
class TestInstallHadolint:
|
class TestInstallHadolint:
|
||||||
def test_already_installed(self) -> None:
|
def test_already_installed(self) -> None:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import subprocess # nosec B404
|
|||||||
import time
|
import time
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
from devx.ci.integration_guard import cli
|
from devx.ci.integration_guard import cli
|
||||||
@@ -111,9 +112,8 @@ class TestCli:
|
|||||||
clear=True,
|
clear=True,
|
||||||
),
|
),
|
||||||
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
||||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
|
||||||
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
patch("devx.ci.integration_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
patch("os.killpg") as mock_killpg,
|
patch("os.killpg") as mock_killpg,
|
||||||
patch("os.getpgid") as mock_getpgid,
|
patch("os.getpgid") as mock_getpgid,
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
@@ -158,9 +158,8 @@ class TestCli:
|
|||||||
clear=True,
|
clear=True,
|
||||||
),
|
),
|
||||||
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
||||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
|
||||||
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
patch("devx.ci.integration_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
patch("os.killpg", side_effect=ProcessLookupError("no such process")),
|
patch("os.killpg", side_effect=ProcessLookupError("no such process")),
|
||||||
patch("os.getpgid") as mock_getpgid,
|
patch("os.getpgid") as mock_getpgid,
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
@@ -203,9 +202,8 @@ class TestCli:
|
|||||||
clear=True,
|
clear=True,
|
||||||
),
|
),
|
||||||
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
patch("devx.ci.integration_guard.POLL_INTERVAL", 0.01),
|
||||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
|
||||||
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
patch("devx.ci.integration_guard.subprocess.Popen") as mock_popen,
|
||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
patch("devx.ci.integration_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
patch("os.killpg") as mock_killpg,
|
patch("os.killpg") as mock_killpg,
|
||||||
patch("os.getpgid") as mock_getpgid,
|
patch("os.getpgid") as mock_getpgid,
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
@@ -289,3 +287,125 @@ def test_main_module_block() -> None:
|
|||||||
namespace = dict(ig.__dict__)
|
namespace = dict(ig.__dict__)
|
||||||
exec(compile(source, ig.__file__, "exec"), namespace)
|
exec(compile(source, ig.__file__, "exec"), namespace)
|
||||||
assert callable(namespace["cli"])
|
assert callable(namespace["cli"])
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetRunningJobs:
|
||||||
|
def test_returns_jobs(self) -> None:
|
||||||
|
with patch("devx.ci.integration_guard.requests.get") as mock_get:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"jobs": [
|
||||||
|
{"name": "integration-tests (0)", "conclusion": "success"},
|
||||||
|
{"name": "integration-tests (1)", "conclusion": "failure"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_get.return_value = mock_response
|
||||||
|
|
||||||
|
from devx.ci.integration_guard import get_running_jobs
|
||||||
|
|
||||||
|
jobs = get_running_jobs("https://gitea.example", "owner", "repo", "token", 123)
|
||||||
|
assert len(jobs) == 2
|
||||||
|
mock_get.assert_called_once()
|
||||||
|
|
||||||
|
def test_raises_on_request_error(self) -> None:
|
||||||
|
import requests
|
||||||
|
|
||||||
|
with patch("devx.ci.integration_guard.requests.get") as mock_get:
|
||||||
|
mock_get.side_effect = requests.RequestException("boom")
|
||||||
|
with pytest.raises(requests.RequestException):
|
||||||
|
from devx.ci.integration_guard import get_running_jobs
|
||||||
|
|
||||||
|
get_running_jobs("https://gitea.example", "owner", "repo", "token", 123)
|
||||||
|
|
||||||
|
|
||||||
|
class TestAnyOtherRunnerFailed:
|
||||||
|
def test_detects_other_failure(self) -> None:
|
||||||
|
from devx.ci.integration_guard import any_other_runner_failed
|
||||||
|
|
||||||
|
jobs = [
|
||||||
|
{"name": "integration-tests (0)", "conclusion": "success"},
|
||||||
|
{"name": "integration-tests (1)", "conclusion": "failure"},
|
||||||
|
{"name": "integration-tests (2)", "conclusion": "running"},
|
||||||
|
]
|
||||||
|
assert any_other_runner_failed(jobs, "integration-tests", 0) is True
|
||||||
|
|
||||||
|
def test_ignores_current_runner(self) -> None:
|
||||||
|
from devx.ci.integration_guard import any_other_runner_failed
|
||||||
|
|
||||||
|
jobs = [
|
||||||
|
{"name": "integration-tests (0)", "conclusion": "failure"},
|
||||||
|
{"name": "integration-tests (1)", "conclusion": "success"},
|
||||||
|
]
|
||||||
|
assert any_other_runner_failed(jobs, "integration-tests", 0) is False
|
||||||
|
|
||||||
|
def test_ignores_non_matching_jobs(self) -> None:
|
||||||
|
from devx.ci.integration_guard import any_other_runner_failed
|
||||||
|
|
||||||
|
jobs = [
|
||||||
|
{"name": "quality", "conclusion": "failure"},
|
||||||
|
{"name": "integration-tests (1)", "conclusion": "success"},
|
||||||
|
]
|
||||||
|
assert any_other_runner_failed(jobs, "integration-tests", 0) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestPollForOtherFailures:
|
||||||
|
def test_sets_failed_event_when_other_runner_fails(self) -> None:
|
||||||
|
from devx.ci.integration_guard import poll_for_other_failures
|
||||||
|
|
||||||
|
stop_event = MagicMock()
|
||||||
|
failed_event = MagicMock()
|
||||||
|
|
||||||
|
def side_effect(*args, **kwargs):
|
||||||
|
if stop_event.wait.call_count < 1:
|
||||||
|
return [
|
||||||
|
{"name": "integration-tests (0)", "conclusion": "success"},
|
||||||
|
{"name": "integration-tests (1)", "conclusion": "failure"},
|
||||||
|
]
|
||||||
|
return []
|
||||||
|
|
||||||
|
with patch("devx.ci.integration_guard.get_running_jobs") as mock_get_jobs:
|
||||||
|
mock_get_jobs.side_effect = side_effect
|
||||||
|
stop_event.is_set.side_effect = [False, False]
|
||||||
|
stop_event.wait.return_value = True
|
||||||
|
|
||||||
|
poll_for_other_failures(
|
||||||
|
"https://gitea.example",
|
||||||
|
"owner",
|
||||||
|
"repo",
|
||||||
|
"token",
|
||||||
|
123,
|
||||||
|
"integration-tests",
|
||||||
|
0,
|
||||||
|
stop_event,
|
||||||
|
failed_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
failed_event.set.assert_called_once()
|
||||||
|
|
||||||
|
def test_poll_warns_on_api_error(self) -> None:
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from devx.ci.integration_guard import poll_for_other_failures
|
||||||
|
|
||||||
|
stop_event = MagicMock()
|
||||||
|
failed_event = MagicMock()
|
||||||
|
|
||||||
|
with patch("devx.ci.integration_guard.get_running_jobs") as mock_get_jobs:
|
||||||
|
mock_get_jobs.side_effect = requests.RequestException("boom")
|
||||||
|
stop_event.is_set.side_effect = [False, True]
|
||||||
|
stop_event.wait.return_value = True
|
||||||
|
|
||||||
|
poll_for_other_failures(
|
||||||
|
"https://gitea.example",
|
||||||
|
"owner",
|
||||||
|
"repo",
|
||||||
|
"token",
|
||||||
|
123,
|
||||||
|
"integration-tests",
|
||||||
|
0,
|
||||||
|
stop_event,
|
||||||
|
failed_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
failed_event.set.assert_not_called()
|
||||||
|
|||||||
@@ -1,591 +0,0 @@
|
|||||||
"""Unit tests for scripts/ci/molecule_ci_guard.py."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess # nosec B404
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import click
|
|
||||||
import pytest
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from devx.molecule.molecule_ci_guard import (
|
|
||||||
any_other_runner_failed,
|
|
||||||
build_env_for_pair,
|
|
||||||
build_molecule_cmd,
|
|
||||||
cli,
|
|
||||||
get_running_jobs,
|
|
||||||
parse_pair,
|
|
||||||
poll_for_other_failures,
|
|
||||||
resolve_role_dir,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetRunningJobs:
|
|
||||||
def test_returns_jobs(self) -> None:
|
|
||||||
with patch("devx.molecule.molecule_ci_guard.requests.get") as mock_get:
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.json.return_value = {
|
|
||||||
"jobs": [
|
|
||||||
{"name": "molecule-tests (0)", "conclusion": "success"},
|
|
||||||
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
mock_response.raise_for_status.return_value = None
|
|
||||||
mock_get.return_value = mock_response
|
|
||||||
|
|
||||||
jobs = get_running_jobs("https://gitea.example", "owner", "repo", "token", 123)
|
|
||||||
assert len(jobs) == 2
|
|
||||||
mock_get.assert_called_once()
|
|
||||||
|
|
||||||
def test_raises_on_request_error(self) -> None:
|
|
||||||
with patch("devx.molecule.molecule_ci_guard.requests.get") as mock_get:
|
|
||||||
mock_get.side_effect = requests.RequestException("boom")
|
|
||||||
with pytest.raises(requests.RequestException):
|
|
||||||
get_running_jobs("https://gitea.example", "owner", "repo", "token", 123)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAnyOtherRunnerFailed:
|
|
||||||
def test_detects_other_failure(self) -> None:
|
|
||||||
jobs = [
|
|
||||||
{"name": "molecule-tests (0)", "conclusion": "success"},
|
|
||||||
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
|
||||||
{"name": "molecule-tests (2)", "conclusion": "running"},
|
|
||||||
]
|
|
||||||
assert any_other_runner_failed(jobs, "molecule-tests", 0) is True
|
|
||||||
|
|
||||||
def test_ignores_current_runner(self) -> None:
|
|
||||||
jobs = [
|
|
||||||
{"name": "molecule-tests (0)", "conclusion": "failure"},
|
|
||||||
{"name": "molecule-tests (1)", "conclusion": "success"},
|
|
||||||
]
|
|
||||||
assert any_other_runner_failed(jobs, "molecule-tests", 0) is False
|
|
||||||
|
|
||||||
def test_ignores_non_molecule_jobs(self) -> None:
|
|
||||||
jobs = [
|
|
||||||
{"name": "quality", "conclusion": "failure"},
|
|
||||||
{"name": "molecule-tests (1)", "conclusion": "success"},
|
|
||||||
]
|
|
||||||
assert any_other_runner_failed(jobs, "molecule-tests", 0) is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildMoleculeCmd:
|
|
||||||
def test_default_scenario(self) -> None:
|
|
||||||
assert build_molecule_cmd("default") == ["molecule", "test"]
|
|
||||||
|
|
||||||
def test_named_scenario(self) -> None:
|
|
||||||
assert build_molecule_cmd("lifecycle") == ["molecule", "test", "-s", "lifecycle"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildEnvForPair:
|
|
||||||
def test_with_command(self) -> None:
|
|
||||||
env = build_env_for_pair("default|ubuntu-2204|img:latest|/lib/systemd/systemd", {})
|
|
||||||
assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204"
|
|
||||||
assert env["MOLECULE_PLATFORM_IMAGE"] == "img:latest"
|
|
||||||
assert env["MOLECULE_PLATFORM_COMMAND"] == "/lib/systemd/systemd"
|
|
||||||
assert env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] == "true"
|
|
||||||
|
|
||||||
def test_without_command(self) -> None:
|
|
||||||
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {})
|
|
||||||
assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204"
|
|
||||||
assert "MOLECULE_PLATFORM_COMMAND" not in env
|
|
||||||
|
|
||||||
def test_without_command_removes_existing(self) -> None:
|
|
||||||
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_PLATFORM_COMMAND": "old"})
|
|
||||||
assert "MOLECULE_PLATFORM_COMMAND" not in env
|
|
||||||
|
|
||||||
def test_preserves_existing_molecule_home(self) -> None:
|
|
||||||
"""When MOLECULE_HOME is already set, it is not overridden."""
|
|
||||||
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_HOME": "/custom/home"})
|
|
||||||
assert env["MOLECULE_HOME"] == "/custom/home"
|
|
||||||
|
|
||||||
def test_appends_matrix_index_to_platform_name(self) -> None:
|
|
||||||
"""When MATRIX_INDEX is set, platform name gets a unique suffix."""
|
|
||||||
env = build_env_for_pair("default|ubuntu-2604|img:latest|sleep infinity", {"MATRIX_INDEX": "3"})
|
|
||||||
assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2604-r3"
|
|
||||||
|
|
||||||
def test_no_matrix_index_keeps_platform_name(self) -> None:
|
|
||||||
"""Without MATRIX_INDEX, platform name is unchanged."""
|
|
||||||
env = build_env_for_pair("default|ubuntu-2604|img:latest|sleep infinity", {})
|
|
||||||
assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2604"
|
|
||||||
|
|
||||||
|
|
||||||
class TestPollForOtherFailures:
|
|
||||||
def test_sets_failed_event_when_other_runner_fails(self) -> None:
|
|
||||||
stop_event = MagicMock()
|
|
||||||
failed_event = MagicMock()
|
|
||||||
|
|
||||||
def side_effect(*args, **kwargs):
|
|
||||||
if stop_event.wait.call_count < 1:
|
|
||||||
return [
|
|
||||||
{"name": "molecule-tests (0)", "conclusion": "success"},
|
|
||||||
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
|
||||||
]
|
|
||||||
return []
|
|
||||||
|
|
||||||
with patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs:
|
|
||||||
mock_get_jobs.side_effect = side_effect
|
|
||||||
stop_event.is_set.side_effect = [False, False]
|
|
||||||
stop_event.wait.return_value = True
|
|
||||||
|
|
||||||
poll_for_other_failures(
|
|
||||||
"https://gitea.example",
|
|
||||||
"owner",
|
|
||||||
"repo",
|
|
||||||
"token",
|
|
||||||
123,
|
|
||||||
"molecule-tests",
|
|
||||||
0,
|
|
||||||
stop_event,
|
|
||||||
failed_event,
|
|
||||||
)
|
|
||||||
|
|
||||||
failed_event.set.assert_called_once()
|
|
||||||
|
|
||||||
def test_poll_warns_on_api_error(self) -> None:
|
|
||||||
stop_event = MagicMock()
|
|
||||||
failed_event = MagicMock()
|
|
||||||
|
|
||||||
with patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs:
|
|
||||||
mock_get_jobs.side_effect = requests.RequestException("boom")
|
|
||||||
stop_event.is_set.side_effect = [False, True]
|
|
||||||
stop_event.wait.return_value = True
|
|
||||||
|
|
||||||
poll_for_other_failures(
|
|
||||||
"https://gitea.example",
|
|
||||||
"owner",
|
|
||||||
"repo",
|
|
||||||
"token",
|
|
||||||
123,
|
|
||||||
"molecule-tests",
|
|
||||||
0,
|
|
||||||
stop_event,
|
|
||||||
failed_event,
|
|
||||||
)
|
|
||||||
|
|
||||||
failed_event.set.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
class TestCli:
|
|
||||||
def test_all_pass(self) -> None:
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
|
||||||
patch("time.sleep"),
|
|
||||||
):
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.poll.return_value = 0
|
|
||||||
proc.returncode = 0
|
|
||||||
mock_popen.return_value = proc
|
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "All molecule tests passed" in result.output
|
|
||||||
# Verify Docker prune was called between scenarios
|
|
||||||
mock_run.assert_called_once_with(
|
|
||||||
["docker", "system", "prune", "-af", "--volumes"],
|
|
||||||
check=False,
|
|
||||||
capture_output=True,
|
|
||||||
timeout=60,
|
|
||||||
)
|
|
||||||
|
|
||||||
@patch("devx.molecule.molecule_ci_guard.get_ci_token", side_effect=click.ClickException("no token"))
|
|
||||||
def test_missing_token_runs_without_polling(self, mock_token: MagicMock) -> None:
|
|
||||||
"""When no token is available, cross-runner polling is skipped."""
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
|
||||||
patch("devx.molecule.molecule_ci_guard.poll_for_other_failures") as mock_poll,
|
|
||||||
patch("time.sleep"),
|
|
||||||
):
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.poll.return_value = 0
|
|
||||||
proc.returncode = 0
|
|
||||||
mock_popen.return_value = proc
|
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
mock_poll.assert_not_called()
|
|
||||||
|
|
||||||
def test_invalid_pair_format_raises(self) -> None:
|
|
||||||
"""Pair with fewer than 2 parts should raise."""
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen"),
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
|
||||||
):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["invalid_no_pipe"])
|
|
||||||
assert result.exit_code != 0
|
|
||||||
assert "Invalid pair format" in result.output
|
|
||||||
|
|
||||||
def test_failure_exits_nonzero(self) -> None:
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
|
||||||
patch("time.sleep"),
|
|
||||||
):
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.poll.return_value = 1
|
|
||||||
proc.returncode = 1
|
|
||||||
mock_popen.return_value = proc
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "FAILED" in result.output
|
|
||||||
|
|
||||||
def test_exits_before_starting_when_already_failed(self) -> None:
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.dict(
|
|
||||||
os.environ,
|
|
||||||
{
|
|
||||||
"GITEA_URL": "https://gitea.example",
|
|
||||||
"CI_GITEA_TOKEN": "token",
|
|
||||||
"RUN_ID": "123",
|
|
||||||
"JOB_NAME": "molecule-tests",
|
|
||||||
"MATRIX_INDEX": "0",
|
|
||||||
"GITEA_REPOSITORY": "my-org/my-repo",
|
|
||||||
"PATH": os.environ.get("PATH", ""),
|
|
||||||
},
|
|
||||||
clear=True,
|
|
||||||
),
|
|
||||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
|
||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
|
|
||||||
patch("time.sleep"),
|
|
||||||
):
|
|
||||||
mock_get_jobs.return_value = [
|
|
||||||
{"name": "molecule-tests (0)", "conclusion": "running"},
|
|
||||||
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
|
||||||
]
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.poll.return_value = 0
|
|
||||||
proc.returncode = 0
|
|
||||||
mock_popen.return_value = proc
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "Another molecule runner failed" in result.output
|
|
||||||
|
|
||||||
def test_keyboard_interrupt_kills_process(self) -> None:
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
|
||||||
patch("time.sleep", side_effect=KeyboardInterrupt),
|
|
||||||
patch("os.killpg") as mock_killpg,
|
|
||||||
patch("os.getpgid") as mock_getpgid,
|
|
||||||
):
|
|
||||||
mock_getpgid.return_value = 123
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.poll.return_value = None
|
|
||||||
proc.wait.return_value = 0
|
|
||||||
mock_popen.return_value = proc
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
mock_killpg.assert_called()
|
|
||||||
|
|
||||||
def test_exits_when_other_runner_fails(self) -> None:
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
real_sleep = time.sleep
|
|
||||||
call_count = [0]
|
|
||||||
|
|
||||||
def get_jobs_side_effect(*args, **kwargs):
|
|
||||||
call_count[0] += 1
|
|
||||||
if call_count[0] < 2:
|
|
||||||
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
|
|
||||||
return [
|
|
||||||
{"name": "molecule-tests (0)", "conclusion": "running"},
|
|
||||||
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
|
||||||
]
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.dict(
|
|
||||||
os.environ,
|
|
||||||
{
|
|
||||||
"GITEA_URL": "https://gitea.example",
|
|
||||||
"CI_GITEA_TOKEN": "token",
|
|
||||||
"RUN_ID": "123",
|
|
||||||
"JOB_NAME": "molecule-tests",
|
|
||||||
"MATRIX_INDEX": "0",
|
|
||||||
"GITEA_REPOSITORY": "my-org/my-repo",
|
|
||||||
"PATH": os.environ.get("PATH", ""),
|
|
||||||
},
|
|
||||||
clear=True,
|
|
||||||
),
|
|
||||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
|
||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
|
||||||
patch("os.killpg") as mock_killpg,
|
|
||||||
patch("os.getpgid") as mock_getpgid,
|
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
|
||||||
):
|
|
||||||
mock_getpgid.return_value = 123
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.poll.return_value = None
|
|
||||||
proc.wait.return_value = 0
|
|
||||||
mock_popen.return_value = proc
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
mock_killpg.assert_called()
|
|
||||||
|
|
||||||
def test_default_owner_repo_fallback(self) -> None:
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
real_sleep = time.sleep
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.dict(
|
|
||||||
os.environ,
|
|
||||||
{
|
|
||||||
"GITEA_URL": "https://gitea.example",
|
|
||||||
"CI_GITEA_TOKEN": "token",
|
|
||||||
"RUN_ID": "123",
|
|
||||||
"JOB_NAME": "molecule-tests",
|
|
||||||
"MATRIX_INDEX": "0",
|
|
||||||
"GITEA_REPOSITORY": "invalid",
|
|
||||||
"PATH": os.environ.get("PATH", ""),
|
|
||||||
},
|
|
||||||
clear=True,
|
|
||||||
),
|
|
||||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
|
||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
|
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
|
||||||
):
|
|
||||||
mock_get_jobs.return_value = [{"name": "molecule-tests (1)", "conclusion": "success"}]
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.poll.return_value = 0
|
|
||||||
proc.returncode = 0
|
|
||||||
mock_popen.return_value = proc
|
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
|
|
||||||
def test_exits_when_other_runner_fails_with_process_lookup_error(self) -> None:
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
real_sleep = time.sleep
|
|
||||||
call_count = [0]
|
|
||||||
|
|
||||||
def get_jobs_side_effect(*args, **kwargs):
|
|
||||||
call_count[0] += 1
|
|
||||||
if call_count[0] < 2:
|
|
||||||
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
|
|
||||||
return [
|
|
||||||
{"name": "molecule-tests (0)", "conclusion": "running"},
|
|
||||||
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
|
||||||
]
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.dict(
|
|
||||||
os.environ,
|
|
||||||
{
|
|
||||||
"GITEA_URL": "https://gitea.example",
|
|
||||||
"CI_GITEA_TOKEN": "token",
|
|
||||||
"RUN_ID": "123",
|
|
||||||
"JOB_NAME": "molecule-tests",
|
|
||||||
"MATRIX_INDEX": "0",
|
|
||||||
"GITEA_REPOSITORY": "my-org/my-repo",
|
|
||||||
"PATH": os.environ.get("PATH", ""),
|
|
||||||
},
|
|
||||||
clear=True,
|
|
||||||
),
|
|
||||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
|
||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
|
||||||
patch("os.killpg") as mock_killpg,
|
|
||||||
patch("os.getpgid") as mock_getpgid,
|
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
|
||||||
):
|
|
||||||
mock_getpgid.return_value = 123
|
|
||||||
mock_killpg.side_effect = ProcessLookupError("no such process")
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.poll.return_value = None
|
|
||||||
proc.wait.return_value = 0
|
|
||||||
mock_popen.return_value = proc
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
|
|
||||||
def test_exits_when_other_runner_fails_with_timeout(self) -> None:
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
real_sleep = time.sleep
|
|
||||||
call_count = [0]
|
|
||||||
|
|
||||||
def get_jobs_side_effect(*args, **kwargs):
|
|
||||||
call_count[0] += 1
|
|
||||||
if call_count[0] < 2:
|
|
||||||
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
|
|
||||||
return [
|
|
||||||
{"name": "molecule-tests (0)", "conclusion": "running"},
|
|
||||||
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
|
||||||
]
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.dict(
|
|
||||||
os.environ,
|
|
||||||
{
|
|
||||||
"GITEA_URL": "https://gitea.example",
|
|
||||||
"CI_GITEA_TOKEN": "token",
|
|
||||||
"RUN_ID": "123",
|
|
||||||
"JOB_NAME": "molecule-tests",
|
|
||||||
"MATRIX_INDEX": "0",
|
|
||||||
"GITEA_REPOSITORY": "my-org/my-repo",
|
|
||||||
"PATH": os.environ.get("PATH", ""),
|
|
||||||
},
|
|
||||||
clear=True,
|
|
||||||
),
|
|
||||||
patch("devx.molecule.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
|
||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
|
||||||
patch("os.killpg") as mock_killpg,
|
|
||||||
patch("os.getpgid") as mock_getpgid,
|
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
|
||||||
):
|
|
||||||
mock_getpgid.return_value = 123
|
|
||||||
mock_killpg.side_effect = [None, ProcessLookupError("no such process")]
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.poll.return_value = None
|
|
||||||
proc.wait.side_effect = [subprocess.TimeoutExpired("cmd", 10)]
|
|
||||||
mock_popen.return_value = proc
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_main_module_block() -> None:
|
|
||||||
import devx.molecule.molecule_ci_guard as mg
|
|
||||||
|
|
||||||
with open(mg.__file__) as f:
|
|
||||||
source = f.read()
|
|
||||||
source = source.replace('if __name__ == "__main__":\n cli()\n', "")
|
|
||||||
namespace = dict(mg.__dict__)
|
|
||||||
exec(compile(source, mg.__file__, "exec"), namespace)
|
|
||||||
assert callable(namespace["cli"])
|
|
||||||
|
|
||||||
|
|
||||||
class TestParsePair:
|
|
||||||
def test_single_role_4_part(self) -> None:
|
|
||||||
role, scenario, name, image, cmd = parse_pair("default|ubuntu-2204|ubuntu:22.04|")
|
|
||||||
assert role == ""
|
|
||||||
assert scenario == "default"
|
|
||||||
assert name == "ubuntu-2204"
|
|
||||||
assert image == "ubuntu:22.04"
|
|
||||||
assert cmd == ""
|
|
||||||
|
|
||||||
def test_multi_role_5_part(self) -> None:
|
|
||||||
role, scenario, name, image, cmd = parse_pair("gitea-runner|default|ubuntu-2204|ubuntu:22.04|")
|
|
||||||
assert role == "gitea-runner"
|
|
||||||
assert scenario == "default"
|
|
||||||
assert name == "ubuntu-2204"
|
|
||||||
assert image == "ubuntu:22.04"
|
|
||||||
assert cmd == ""
|
|
||||||
|
|
||||||
def test_multi_role_with_command(self) -> None:
|
|
||||||
role, scenario, name, image, cmd = parse_pair(
|
|
||||||
"docker-base|lifecycle|archlinux|archlinux:latest|/usr/lib/systemd/systemd"
|
|
||||||
)
|
|
||||||
assert role == "docker-base"
|
|
||||||
assert scenario == "lifecycle"
|
|
||||||
assert cmd == "/usr/lib/systemd/systemd"
|
|
||||||
|
|
||||||
def test_invalid_pair_raises(self) -> None:
|
|
||||||
with pytest.raises(click.ClickException, match="Invalid pair format"):
|
|
||||||
parse_pair("only|two|parts")
|
|
||||||
|
|
||||||
def test_too_many_parts_raises(self) -> None:
|
|
||||||
with pytest.raises(click.ClickException, match="Invalid pair format"):
|
|
||||||
parse_pair("a|b|c|d|e|f")
|
|
||||||
|
|
||||||
|
|
||||||
class TestResolveRoleDir:
|
|
||||||
def test_multi_role_with_roles_root(self, tmp_path: Path) -> None:
|
|
||||||
roles_root = tmp_path / "ansible" / "roles"
|
|
||||||
roles_root.mkdir(parents=True)
|
|
||||||
result = resolve_role_dir("gitea-runner", roles_root, tmp_path)
|
|
||||||
assert result == roles_root / "gitea-runner"
|
|
||||||
|
|
||||||
def test_multi_role_default_roles_root(self, tmp_path: Path) -> None:
|
|
||||||
result = resolve_role_dir("docker-base", None, tmp_path)
|
|
||||||
assert result == tmp_path / "ansible" / "roles" / "docker-base"
|
|
||||||
|
|
||||||
def test_single_role_auto_discovers(self, tmp_path: Path) -> None:
|
|
||||||
"""Single-role mode auto-discovers first role with molecule/ dir."""
|
|
||||||
roles_dir = tmp_path / "ansible" / "roles"
|
|
||||||
(roles_dir / "my_role" / "molecule").mkdir(parents=True)
|
|
||||||
result = resolve_role_dir("", None, tmp_path)
|
|
||||||
assert result == roles_dir / "my_role"
|
|
||||||
|
|
||||||
def test_single_role_no_roles_returns_fallback(self, tmp_path: Path) -> None:
|
|
||||||
"""When no roles exist, returns a fallback path (will error at runtime)."""
|
|
||||||
result = resolve_role_dir("", None, tmp_path)
|
|
||||||
assert "roles" in str(result)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCliMultiRole:
|
|
||||||
def test_multi_role_pair_passes(self, tmp_path: Path) -> None:
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
roles_root = tmp_path / "ansible" / "roles"
|
|
||||||
(roles_root / "gitea-runner").mkdir(parents=True)
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.run"),
|
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
|
||||||
patch("time.sleep"),
|
|
||||||
):
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.poll.return_value = 0
|
|
||||||
proc.returncode = 0
|
|
||||||
mock_popen.return_value = proc
|
|
||||||
mock_run.return_value = MagicMock(returncode=0)
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(
|
|
||||||
cli,
|
|
||||||
["--roles-root", str(roles_root), "gitea-runner|default|ubuntu-2204|ubuntu:22.04|"],
|
|
||||||
)
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "All molecule tests passed" in result.output
|
|
||||||
@@ -14,7 +14,6 @@ from devx.tools.setup import (
|
|||||||
_install_pre_commit_hooks,
|
_install_pre_commit_hooks,
|
||||||
_install_python_deps,
|
_install_python_deps,
|
||||||
_run,
|
_run,
|
||||||
_try_gitea_mirror_install,
|
|
||||||
_verify,
|
_verify,
|
||||||
main,
|
main,
|
||||||
)
|
)
|
||||||
@@ -107,7 +106,6 @@ class TestInstallAnsibleCollections:
|
|||||||
with patch("devx.tools.setup.Path") as mock_path:
|
with patch("devx.tools.setup.Path") as mock_path:
|
||||||
mock_path.return_value.exists.return_value = True
|
mock_path.return_value.exists.return_value = True
|
||||||
mock_path.return_value.__str__ = lambda _: str(req)
|
mock_path.return_value.__str__ = lambda _: str(req)
|
||||||
mock_path.return_value.read_text = lambda: req.read_text()
|
|
||||||
_install_ansible_collections(".venv/bin")
|
_install_ansible_collections(".venv/bin")
|
||||||
mock_run.assert_called_once()
|
mock_run.assert_called_once()
|
||||||
|
|
||||||
@@ -136,7 +134,6 @@ class TestInstallAnsibleCollections:
|
|||||||
with patch("devx.tools.setup.Path") as mock_path:
|
with patch("devx.tools.setup.Path") as mock_path:
|
||||||
mock_path.return_value.exists.return_value = True
|
mock_path.return_value.exists.return_value = True
|
||||||
mock_path.return_value.__str__ = lambda _: str(req)
|
mock_path.return_value.__str__ = lambda _: str(req)
|
||||||
mock_path.return_value.read_text = lambda: req.read_text()
|
|
||||||
_install_ansible_collections(".venv/bin")
|
_install_ansible_collections(".venv/bin")
|
||||||
assert mock_run.call_count == 2
|
assert mock_run.call_count == 2
|
||||||
|
|
||||||
@@ -156,211 +153,10 @@ class TestInstallAnsibleCollections:
|
|||||||
with patch("devx.tools.setup.Path") as mock_path:
|
with patch("devx.tools.setup.Path") as mock_path:
|
||||||
mock_path.return_value.exists.return_value = True
|
mock_path.return_value.exists.return_value = True
|
||||||
mock_path.return_value.__str__ = lambda _: str(req)
|
mock_path.return_value.__str__ = lambda _: str(req)
|
||||||
mock_path.return_value.read_text = lambda: req.read_text()
|
|
||||||
with pytest.raises(_subprocess.CalledProcessError):
|
with pytest.raises(_subprocess.CalledProcessError):
|
||||||
_install_ansible_collections(".venv/bin")
|
_install_ansible_collections(".venv/bin")
|
||||||
assert mock_run.call_count == 3
|
assert mock_run.call_count == 3
|
||||||
|
|
||||||
@patch("devx.tools.setup._try_gitea_mirror_install", return_value=True)
|
|
||||||
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/ansible-galaxy")
|
|
||||||
@patch("devx.tools.setup._run")
|
|
||||||
def test_mirror_install_skips_galaxy_fallback(
|
|
||||||
self, mock_run: MagicMock, mock_which: MagicMock, mock_mirror: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
"""When mirror install succeeds, galaxy fallback is not called."""
|
|
||||||
req = tmp_path / "ansible" / "requirements.yml"
|
|
||||||
req.parent.mkdir(parents=True)
|
|
||||||
req.write_text("collections: []")
|
|
||||||
with patch("devx.tools.setup.Path") as mock_path:
|
|
||||||
mock_path.return_value.exists.return_value = True
|
|
||||||
mock_path.return_value.__str__ = lambda _: str(req)
|
|
||||||
mock_path.return_value.read_text = lambda: req.read_text()
|
|
||||||
_install_ansible_collections(".venv/bin")
|
|
||||||
# _run should not be called because mirror install returns True
|
|
||||||
mock_run.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
class TestTryGiteaMirrorInstall:
|
|
||||||
"""Tests for _try_gitea_mirror_install — Gitea mirror with auth + fallback."""
|
|
||||||
|
|
||||||
_GITEA_URL = "https://git.example.com/api/packages/org/generic/ansible-collections/1.0.0/ansible-posix-1.0.0.tar.gz"
|
|
||||||
|
|
||||||
@patch.dict(os.environ, {}, clear=True)
|
|
||||||
def test_no_url_entries_returns_false(self, tmp_path: Path) -> None:
|
|
||||||
"""Requirements without type: url entries should return False."""
|
|
||||||
req = tmp_path / "requirements.yml"
|
|
||||||
req.write_text("collections:\n - name: ansible.posix\n version: '1.0.0'\n")
|
|
||||||
result = _try_gitea_mirror_install("ansible-galaxy", req)
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
@patch.dict(os.environ, {}, clear=True)
|
|
||||||
def test_no_token_returns_false(self, tmp_path: Path) -> None:
|
|
||||||
"""No Gitea token set → return False to fall back to galaxy."""
|
|
||||||
req = tmp_path / "requirements.yml"
|
|
||||||
req.write_text(
|
|
||||||
"collections:\n"
|
|
||||||
" - name: ansible.posix\n"
|
|
||||||
" version: '1.0.0'\n"
|
|
||||||
" type: url\n"
|
|
||||||
f" source: '{self._GITEA_URL}'\n"
|
|
||||||
)
|
|
||||||
result = _try_gitea_mirror_install("ansible-galaxy", req)
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
@patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True)
|
|
||||||
def test_yaml_parse_error_returns_false(self, tmp_path: Path) -> None:
|
|
||||||
"""Malformed YAML → return False."""
|
|
||||||
req = tmp_path / "requirements.yml"
|
|
||||||
req.write_text("not: valid: yaml: [[")
|
|
||||||
result = _try_gitea_mirror_install("ansible-galaxy", req)
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
@patch("devx.tools.setup._run")
|
|
||||||
@patch("urllib.request.urlopen")
|
|
||||||
@patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True)
|
|
||||||
def test_successful_mirror_install(self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None:
|
|
||||||
"""Valid URL entries + token → downloads with auth and installs offline."""
|
|
||||||
req = tmp_path / "requirements.yml"
|
|
||||||
req.write_text(
|
|
||||||
f"collections:\n"
|
|
||||||
f" - name: ansible.posix\n"
|
|
||||||
f" version: '1.0.0'\n"
|
|
||||||
f" type: url\n"
|
|
||||||
f" source: '{self._GITEA_URL}'\n"
|
|
||||||
)
|
|
||||||
mock_resp = MagicMock()
|
|
||||||
mock_resp.read.return_value = b"fake-tarball"
|
|
||||||
mock_resp.__enter__ = lambda _: mock_resp
|
|
||||||
mock_resp.__exit__ = lambda *a: None
|
|
||||||
mock_urlopen.return_value = mock_resp
|
|
||||||
|
|
||||||
result = _try_gitea_mirror_install("ansible-galaxy", req)
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
# Verify auth header was added
|
|
||||||
call_args = mock_urlopen.call_args[0][0]
|
|
||||||
assert call_args.get_header("Authorization") == "token tok123"
|
|
||||||
|
|
||||||
# Verify offline install was called
|
|
||||||
install_cmd = mock_run.call_args[0][0]
|
|
||||||
assert "collection" in install_cmd
|
|
||||||
assert "install" in install_cmd
|
|
||||||
assert "--offline" in install_cmd
|
|
||||||
|
|
||||||
@patch("urllib.request.urlopen")
|
|
||||||
@patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True)
|
|
||||||
def test_download_failure_returns_false(self, mock_urlopen: MagicMock, tmp_path: Path) -> None:
|
|
||||||
"""Download failure → return False to fall back to galaxy."""
|
|
||||||
req = tmp_path / "requirements.yml"
|
|
||||||
req.write_text(
|
|
||||||
f"collections:\n"
|
|
||||||
f" - name: ansible.posix\n"
|
|
||||||
f" version: '1.0.0'\n"
|
|
||||||
f" type: url\n"
|
|
||||||
f" source: '{self._GITEA_URL}'\n"
|
|
||||||
)
|
|
||||||
mock_urlopen.side_effect = Exception("401 Unauthorized")
|
|
||||||
|
|
||||||
result = _try_gitea_mirror_install("ansible-galaxy", req)
|
|
||||||
assert result is False
|
|
||||||
|
|
||||||
@patch("devx.tools.setup._run")
|
|
||||||
@patch("urllib.request.urlopen")
|
|
||||||
@patch.dict(os.environ, {"CI_GITEA_API_TOKEN": "tok456"}, clear=True)
|
|
||||||
def test_prefers_api_token_over_legacy(self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None:
|
|
||||||
"""CI_GITEA_API_TOKEN takes priority over CI_GITEA_TOKEN."""
|
|
||||||
req = tmp_path / "requirements.yml"
|
|
||||||
req.write_text(
|
|
||||||
f"collections:\n"
|
|
||||||
f" - name: ansible.posix\n"
|
|
||||||
f" version: '1.0.0'\n"
|
|
||||||
f" type: url\n"
|
|
||||||
f" source: '{self._GITEA_URL}'\n"
|
|
||||||
)
|
|
||||||
mock_resp = MagicMock()
|
|
||||||
mock_resp.read.return_value = b"fake-tarball"
|
|
||||||
mock_resp.__enter__ = lambda _: mock_resp
|
|
||||||
mock_resp.__exit__ = lambda *a: None
|
|
||||||
mock_urlopen.return_value = mock_resp
|
|
||||||
|
|
||||||
result = _try_gitea_mirror_install("ansible-galaxy", req)
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
call_args = mock_urlopen.call_args[0][0]
|
|
||||||
assert call_args.get_header("Authorization") == "token tok456"
|
|
||||||
|
|
||||||
@patch("devx.tools.setup._run")
|
|
||||||
@patch("urllib.request.urlopen")
|
|
||||||
@patch.dict(os.environ, {"DEVELOPER_GITEA_API_TOKEN": "tok789"}, clear=True)
|
|
||||||
def test_developer_token_fallback(self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None:
|
|
||||||
"""DEVELOPER_GITEA_API_TOKEN is used when CI tokens are absent."""
|
|
||||||
req = tmp_path / "requirements.yml"
|
|
||||||
req.write_text(
|
|
||||||
f"collections:\n"
|
|
||||||
f" - name: ansible.posix\n"
|
|
||||||
f" version: '1.0.0'\n"
|
|
||||||
f" type: url\n"
|
|
||||||
f" source: '{self._GITEA_URL}'\n"
|
|
||||||
)
|
|
||||||
mock_resp = MagicMock()
|
|
||||||
mock_resp.read.return_value = b"fake-tarball"
|
|
||||||
mock_resp.__enter__ = lambda _: mock_resp
|
|
||||||
mock_resp.__exit__ = lambda *a: None
|
|
||||||
mock_urlopen.return_value = mock_resp
|
|
||||||
|
|
||||||
result = _try_gitea_mirror_install("ansible-galaxy", req)
|
|
||||||
assert result is True
|
|
||||||
|
|
||||||
call_args = mock_urlopen.call_args[0][0]
|
|
||||||
assert call_args.get_header("Authorization") == "token tok789"
|
|
||||||
|
|
||||||
@patch("devx.tools.setup._run")
|
|
||||||
@patch("urllib.request.urlopen")
|
|
||||||
@patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True)
|
|
||||||
def test_non_gitea_url_passed_through(self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None:
|
|
||||||
"""URL entries not pointing to /api/packages/ are kept as-is (no download)."""
|
|
||||||
external_url = "https://galaxy.ansible.com/download/ansible-posix-1.0.0.tar.gz"
|
|
||||||
req = tmp_path / "requirements.yml"
|
|
||||||
req.write_text(
|
|
||||||
f"collections:\n"
|
|
||||||
f" - name: ansible.posix\n"
|
|
||||||
f" version: '1.0.0'\n"
|
|
||||||
f" type: url\n"
|
|
||||||
f" source: '{external_url}'\n"
|
|
||||||
)
|
|
||||||
# Should not call urlopen since the URL is not a Gitea package URL
|
|
||||||
result = _try_gitea_mirror_install("ansible-galaxy", req)
|
|
||||||
assert result is True
|
|
||||||
mock_urlopen.assert_not_called()
|
|
||||||
|
|
||||||
@patch("devx.tools.setup._run")
|
|
||||||
@patch("urllib.request.urlopen")
|
|
||||||
@patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True)
|
|
||||||
def test_mixed_entries_gitea_and_non_gitea(
|
|
||||||
self, mock_urlopen: MagicMock, mock_run: MagicMock, tmp_path: Path
|
|
||||||
) -> None:
|
|
||||||
"""Mix of Gitea URL entries and regular galaxy entries."""
|
|
||||||
req = tmp_path / "requirements.yml"
|
|
||||||
req.write_text(
|
|
||||||
f"collections:\n"
|
|
||||||
f" - name: ansible.posix\n"
|
|
||||||
f" version: '1.0.0'\n"
|
|
||||||
f" type: url\n"
|
|
||||||
f" source: '{self._GITEA_URL}'\n"
|
|
||||||
f" - name: community.general\n"
|
|
||||||
f" version: '13.0.0'\n"
|
|
||||||
)
|
|
||||||
mock_resp = MagicMock()
|
|
||||||
mock_resp.read.return_value = b"fake-tarball"
|
|
||||||
mock_resp.__enter__ = lambda _: mock_resp
|
|
||||||
mock_resp.__exit__ = lambda *a: None
|
|
||||||
mock_urlopen.return_value = mock_resp
|
|
||||||
|
|
||||||
result = _try_gitea_mirror_install("ansible-galaxy", req)
|
|
||||||
assert result is True
|
|
||||||
# Only the Gitea URL entry should trigger a download
|
|
||||||
mock_urlopen.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
class TestConfigureTeaLogin:
|
class TestConfigureTeaLogin:
|
||||||
@patch("devx.tools.setup.shutil.which", return_value=None)
|
@patch("devx.tools.setup.shutil.which", return_value=None)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class TestInstallInImage:
|
|||||||
mock_run.assert_called_once()
|
mock_run.assert_called_once()
|
||||||
cmd = mock_run.call_args[0][0]
|
cmd = mock_run.call_args[0][0]
|
||||||
assert "--no-cache-dir" in cmd
|
assert "--no-cache-dir" in cmd
|
||||||
|
assert "--no-deps" in cmd
|
||||||
assert "-e" in cmd
|
assert "-e" in cmd
|
||||||
assert "." in cmd
|
assert "." in cmd
|
||||||
# No extras → spec is "."
|
# No extras → spec is "."
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Unit tests for devx.tools.check_alert_rules."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.tools.check_alert_rules import main
|
||||||
|
|
||||||
|
|
||||||
|
class TestMain:
|
||||||
|
def test_skip_when_promtool_not_found(self, tmp_path: Path):
|
||||||
|
"""Should exit 0 and print skip message when promtool is not on PATH."""
|
||||||
|
with patch("shutil.which", return_value=None):
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--template-path", str(tmp_path)])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "promtool not found" in result.output
|
||||||
|
|
||||||
|
def test_validates_rules_successfully(self, tmp_path: Path):
|
||||||
|
"""Should exit 0 when promtool reports SUCCESS."""
|
||||||
|
(tmp_path / "alert-rules.yml.j2").write_text("groups: []")
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.returncode = 0
|
||||||
|
mock_result.stdout = "Checking /tmp/test.yml\n SUCCESS: 60 rules found\n"
|
||||||
|
mock_result.stderr = ""
|
||||||
|
with patch("shutil.which", return_value="/usr/bin/promtool"):
|
||||||
|
with patch("subprocess.run", return_value=mock_result):
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--template-path", str(tmp_path)])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_fails_on_promtool_error(self, tmp_path: Path):
|
||||||
|
"""Should exit non-zero when promtool reports an error."""
|
||||||
|
(tmp_path / "alert-rules.yml.j2").write_text("groups: []")
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.returncode = 1
|
||||||
|
mock_result.stdout = ""
|
||||||
|
mock_result.stderr = "Error: invalid template function 'default'\n"
|
||||||
|
with patch("shutil.which", return_value="/usr/bin/promtool"):
|
||||||
|
with patch("subprocess.run", return_value=mock_result):
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--template-path", str(tmp_path)])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
|
||||||
|
def test_uses_correct_template_path(self, tmp_path: Path):
|
||||||
|
"""Should render the specified template from the given path."""
|
||||||
|
(tmp_path / "alert-rules.yml.j2").write_text("groups: []")
|
||||||
|
captured_args = []
|
||||||
|
|
||||||
|
def fake_run(args, **kwargs):
|
||||||
|
captured_args.append(args)
|
||||||
|
mock = MagicMock()
|
||||||
|
mock.returncode = 0
|
||||||
|
mock.stdout = "SUCCESS"
|
||||||
|
mock.stderr = ""
|
||||||
|
return mock
|
||||||
|
|
||||||
|
with patch("shutil.which", return_value="/usr/bin/promtool"):
|
||||||
|
with patch("subprocess.run", side_effect=fake_run):
|
||||||
|
runner = CliRunner()
|
||||||
|
runner.invoke(main, ["--template-path", str(tmp_path)])
|
||||||
|
assert captured_args[0][0] == "promtool"
|
||||||
|
assert captured_args[0][1] == "check"
|
||||||
|
assert captured_args[0][2] == "rules"
|
||||||
|
|
||||||
|
def test_custom_template_name(self, tmp_path: Path):
|
||||||
|
"""Should render a custom template name."""
|
||||||
|
(tmp_path / "custom-rules.yml.j2").write_text("groups: []")
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.returncode = 0
|
||||||
|
mock_result.stdout = "SUCCESS"
|
||||||
|
mock_result.stderr = ""
|
||||||
|
with patch("shutil.which", return_value="/usr/bin/promtool"):
|
||||||
|
with patch("subprocess.run", return_value=mock_result):
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main, ["--template-path", str(tmp_path), "--template-name", "custom-rules.yml.j2"]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_template_vars_passed(self, tmp_path: Path):
|
||||||
|
"""Should pass template variables to the render call."""
|
||||||
|
(tmp_path / "alert-rules.yml.j2").write_text("grafana: {{ grafana_base_url }}\ngroups: []")
|
||||||
|
mock_result = MagicMock()
|
||||||
|
mock_result.returncode = 0
|
||||||
|
mock_result.stdout = "SUCCESS"
|
||||||
|
mock_result.stderr = ""
|
||||||
|
with patch("shutil.which", return_value="/usr/bin/promtool"):
|
||||||
|
with patch("subprocess.run", return_value=mock_result):
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
main,
|
||||||
|
[
|
||||||
|
"--template-path",
|
||||||
|
str(tmp_path),
|
||||||
|
"--var",
|
||||||
|
"grafana_base_url=https://grafana.test.example.com",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
"""Unit tests for devx.tools.check_ansible_set_fact_to_json."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import textwrap
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.tools.check_ansible_set_fact_to_json import (
|
||||||
|
_check_file,
|
||||||
|
_check_task,
|
||||||
|
_check_task_list,
|
||||||
|
_find_task_files,
|
||||||
|
main,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindTaskFiles:
|
||||||
|
def test_single_file(self, tmp_path: Path):
|
||||||
|
f = tmp_path / "tasks.yml"
|
||||||
|
f.write_text("tasks: []")
|
||||||
|
assert _find_task_files(f) == [f]
|
||||||
|
|
||||||
|
def test_directory_recursive(self, tmp_path: Path):
|
||||||
|
(tmp_path / "sub").mkdir()
|
||||||
|
f1 = tmp_path / "a.yml"
|
||||||
|
f2 = tmp_path / "sub" / "b.yml"
|
||||||
|
f1.write_text("tasks: []")
|
||||||
|
f2.write_text("tasks: []")
|
||||||
|
result = _find_task_files(tmp_path)
|
||||||
|
assert f1 in result
|
||||||
|
assert f2 in result
|
||||||
|
|
||||||
|
def test_nonexistent_path(self, tmp_path: Path):
|
||||||
|
assert _find_task_files(tmp_path / "nonexistent") == []
|
||||||
|
|
||||||
|
def test_non_yaml_file_skipped(self, tmp_path: Path):
|
||||||
|
f = tmp_path / "readme.txt"
|
||||||
|
f.write_text("not yaml")
|
||||||
|
assert _find_task_files(f) == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckTask:
|
||||||
|
def test_set_fact_with_to_json_flagged(self, tmp_path: Path):
|
||||||
|
task = {"name": "Set targets", "set_fact": {"customer_hosts": "{{ targets | to_json }}"}}
|
||||||
|
errors: list[str] = []
|
||||||
|
_check_task(task, tmp_path / "test.yml", errors, tmp_path)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "customer_hosts" in errors[0]
|
||||||
|
assert "to_json" in errors[0]
|
||||||
|
|
||||||
|
def test_set_fact_without_to_json_ok(self, tmp_path: Path):
|
||||||
|
task = {"name": "Set targets", "set_fact": {"customer_hosts": "{{ targets }}"}}
|
||||||
|
errors: list[str] = []
|
||||||
|
_check_task(task, tmp_path / "test.yml", errors, tmp_path)
|
||||||
|
assert errors == []
|
||||||
|
|
||||||
|
def test_ansible_builtin_set_fact(self, tmp_path: Path):
|
||||||
|
task = {"name": "Set targets", "ansible.builtin.set_fact": {"my_list": "{{ items | to_nice_json }}"}}
|
||||||
|
errors: list[str] = []
|
||||||
|
_check_task(task, tmp_path / "test.yml", errors, tmp_path)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "to_nice_json" in errors[0]
|
||||||
|
|
||||||
|
def test_non_set_fact_task_ignored(self, tmp_path: Path):
|
||||||
|
task = {"name": "Render config", "copy": {"content": "{{ data | to_json }}"}}
|
||||||
|
errors: list[str] = []
|
||||||
|
_check_task(task, tmp_path / "test.yml", errors, tmp_path)
|
||||||
|
assert errors == []
|
||||||
|
|
||||||
|
def test_cacheable_key_ignored(self, tmp_path: Path):
|
||||||
|
task = {"name": "Set fact", "set_fact": {"my_var": "{{ value }}", "cacheable": True}}
|
||||||
|
errors: list[str] = []
|
||||||
|
_check_task(task, tmp_path / "test.yml", errors, tmp_path)
|
||||||
|
assert errors == []
|
||||||
|
|
||||||
|
def test_unnamed_task(self, tmp_path: Path):
|
||||||
|
task = {"set_fact": {"my_var": "{{ value | to_json }}"}}
|
||||||
|
errors: list[str] = []
|
||||||
|
_check_task(task, tmp_path / "test.yml", errors, tmp_path)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "(unnamed)" in errors[0]
|
||||||
|
|
||||||
|
def test_set_fact_not_dict_ignored(self, tmp_path: Path):
|
||||||
|
task = {"set_fact": "not a dict"}
|
||||||
|
errors: list[str] = []
|
||||||
|
_check_task(task, tmp_path / "test.yml", errors, tmp_path)
|
||||||
|
assert errors == []
|
||||||
|
|
||||||
|
def test_to_json_no_spaces(self, tmp_path: Path):
|
||||||
|
task = {"set_fact": {"my_var": "{{ items|to_json }}"}}
|
||||||
|
errors: list[str] = []
|
||||||
|
_check_task(task, tmp_path / "test.yml", errors, tmp_path)
|
||||||
|
assert len(errors) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckTaskList:
|
||||||
|
def test_block_tasks_checked(self, tmp_path: Path):
|
||||||
|
tasks = [{"name": "Block", "block": [{"name": "Set in block", "set_fact": {"x": "{{ y | to_json }}"}}]}]
|
||||||
|
errors: list[str] = []
|
||||||
|
_check_task_list(tasks, tmp_path / "test.yml", errors, tmp_path)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "x" in errors[0]
|
||||||
|
|
||||||
|
def test_non_dict_task_ignored(self, tmp_path: Path):
|
||||||
|
tasks = ["just a string", 42, None]
|
||||||
|
errors: list[str] = []
|
||||||
|
_check_task_list(tasks, tmp_path / "test.yml", errors, tmp_path)
|
||||||
|
assert errors == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckFile:
|
||||||
|
def test_playbook_with_set_fact_to_json(self, tmp_path: Path):
|
||||||
|
content = textwrap.dedent("""
|
||||||
|
- name: Deploy
|
||||||
|
hosts: all
|
||||||
|
tasks:
|
||||||
|
- name: Set targets
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
customer_hosts: "{{ targets | to_json }}"
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "playbook.yml"
|
||||||
|
f.write_text(content)
|
||||||
|
errors = _check_file(f, tmp_path)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "customer_hosts" in errors[0]
|
||||||
|
|
||||||
|
def test_playbook_without_set_fact(self, tmp_path: Path):
|
||||||
|
content = textwrap.dedent("""
|
||||||
|
- name: Deploy
|
||||||
|
hosts: all
|
||||||
|
tasks:
|
||||||
|
- name: Debug
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: "hello"
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "playbook.yml"
|
||||||
|
f.write_text(content)
|
||||||
|
assert _check_file(f, tmp_path) == []
|
||||||
|
|
||||||
|
def test_role_tasks_file(self, tmp_path: Path):
|
||||||
|
content = textwrap.dedent("""
|
||||||
|
- name: Set config
|
||||||
|
set_fact:
|
||||||
|
my_data: "{{ data | to_json }}"
|
||||||
|
- name: Copy config
|
||||||
|
copy:
|
||||||
|
content: "{{ config | to_json }}"
|
||||||
|
dest: /etc/config.json
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "main.yml"
|
||||||
|
f.write_text(content)
|
||||||
|
errors = _check_file(f, tmp_path)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "my_data" in errors[0]
|
||||||
|
|
||||||
|
def test_pre_tasks_and_post_tasks(self, tmp_path: Path):
|
||||||
|
content = textwrap.dedent("""
|
||||||
|
- name: Play
|
||||||
|
hosts: all
|
||||||
|
pre_tasks:
|
||||||
|
- name: Pre set
|
||||||
|
set_fact:
|
||||||
|
pre_var: "{{ x | to_json }}"
|
||||||
|
post_tasks:
|
||||||
|
- name: Post set
|
||||||
|
set_fact:
|
||||||
|
post_var: "{{ y | to_json }}"
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "playbook.yml"
|
||||||
|
f.write_text(content)
|
||||||
|
errors = _check_file(f, tmp_path)
|
||||||
|
assert len(errors) == 2
|
||||||
|
|
||||||
|
def test_handlers_checked(self, tmp_path: Path):
|
||||||
|
content = textwrap.dedent("""
|
||||||
|
- name: Play
|
||||||
|
hosts: all
|
||||||
|
handlers:
|
||||||
|
- name: Restart service
|
||||||
|
set_fact:
|
||||||
|
restart_data: "{{ data | to_json }}"
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "playbook.yml"
|
||||||
|
f.write_text(content)
|
||||||
|
errors = _check_file(f, tmp_path)
|
||||||
|
assert len(errors) == 1
|
||||||
|
|
||||||
|
def test_invalid_yaml(self, tmp_path: Path):
|
||||||
|
f = tmp_path / "bad.yml"
|
||||||
|
f.write_text("tasks: [invalid: {")
|
||||||
|
errors = _check_file(f, tmp_path)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "cannot parse YAML" in errors[0]
|
||||||
|
|
||||||
|
def test_non_dict_doc_skipped(self, tmp_path: Path):
|
||||||
|
f = tmp_path / "list.yml"
|
||||||
|
f.write_text("- just\n- a\n- list\n")
|
||||||
|
assert _check_file(f, tmp_path) == []
|
||||||
|
|
||||||
|
def test_multi_doc_yaml(self, tmp_path: Path):
|
||||||
|
content = textwrap.dedent("""
|
||||||
|
---
|
||||||
|
- name: Play 1
|
||||||
|
hosts: all
|
||||||
|
tasks:
|
||||||
|
- name: Set in play 1
|
||||||
|
set_fact:
|
||||||
|
var1: "{{ x | to_json }}"
|
||||||
|
---
|
||||||
|
- name: Play 2
|
||||||
|
hosts: all
|
||||||
|
tasks:
|
||||||
|
- name: Set in play 2
|
||||||
|
set_fact:
|
||||||
|
var2: "{{ y }}"
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "multi.yml"
|
||||||
|
f.write_text(content)
|
||||||
|
errors = _check_file(f, tmp_path)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "var1" in errors[0]
|
||||||
|
|
||||||
|
def test_dict_doc_role_tasks_file(self, tmp_path: Path):
|
||||||
|
content = textwrap.dedent("""
|
||||||
|
tasks:
|
||||||
|
- name: Set var
|
||||||
|
set_fact:
|
||||||
|
my_var: "{{ value | to_json }}"
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "main.yml"
|
||||||
|
f.write_text(content)
|
||||||
|
errors = _check_file(f, tmp_path)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "my_var" in errors[0]
|
||||||
|
|
||||||
|
def test_play_with_roles_key(self, tmp_path: Path):
|
||||||
|
content = textwrap.dedent("""
|
||||||
|
- name: Play
|
||||||
|
hosts: all
|
||||||
|
roles:
|
||||||
|
- role: my_role
|
||||||
|
tasks:
|
||||||
|
- name: Set in role
|
||||||
|
set_fact:
|
||||||
|
role_var: "{{ x | to_json }}"
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "playbook.yml"
|
||||||
|
f.write_text(content)
|
||||||
|
errors = _check_file(f, tmp_path)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "role_var" in errors[0]
|
||||||
|
|
||||||
|
def test_bare_task_in_list_with_block(self, tmp_path: Path):
|
||||||
|
content = textwrap.dedent("""
|
||||||
|
- name: Outer task
|
||||||
|
set_fact:
|
||||||
|
outer: "{{ x | to_json }}"
|
||||||
|
- name: Block
|
||||||
|
block:
|
||||||
|
- name: Inner task
|
||||||
|
set_fact:
|
||||||
|
inner: "{{ y | to_json }}"
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "tasks.yml"
|
||||||
|
f.write_text(content)
|
||||||
|
errors = _check_file(f, tmp_path)
|
||||||
|
assert len(errors) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestMain:
|
||||||
|
def test_passes_when_clean(self, tmp_path: Path):
|
||||||
|
content = textwrap.dedent("""
|
||||||
|
- name: Play
|
||||||
|
hosts: all
|
||||||
|
tasks:
|
||||||
|
- name: Set var
|
||||||
|
set_fact:
|
||||||
|
my_var: "{{ value }}"
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "playbook.yml"
|
||||||
|
f.write_text(content)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--path", str(f)])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "OK" in result.output
|
||||||
|
|
||||||
|
def test_fails_when_to_json_found(self, tmp_path: Path):
|
||||||
|
content = textwrap.dedent("""
|
||||||
|
- name: Play
|
||||||
|
hosts: all
|
||||||
|
tasks:
|
||||||
|
- name: Set var
|
||||||
|
set_fact:
|
||||||
|
my_var: "{{ value | to_json }}"
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "playbook.yml"
|
||||||
|
f.write_text(content)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--path", str(f)])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "FAIL" in result.output
|
||||||
|
assert "my_var" in result.output
|
||||||
|
|
||||||
|
def test_directory_scan(self, tmp_path: Path):
|
||||||
|
(tmp_path / "good.yml").write_text(
|
||||||
|
textwrap.dedent("""
|
||||||
|
- name: Play
|
||||||
|
hosts: all
|
||||||
|
tasks:
|
||||||
|
- name: Set
|
||||||
|
set_fact:
|
||||||
|
x: "{{ y }}"
|
||||||
|
""").strip()
|
||||||
|
)
|
||||||
|
(tmp_path / "bad.yml").write_text(
|
||||||
|
textwrap.dedent("""
|
||||||
|
- name: Play
|
||||||
|
hosts: all
|
||||||
|
tasks:
|
||||||
|
- name: Set
|
||||||
|
set_fact:
|
||||||
|
x: "{{ y | to_json }}"
|
||||||
|
""").strip()
|
||||||
|
)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--path", str(tmp_path)])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "bad.yml" in result.output
|
||||||
|
|
||||||
|
def test_custom_ansible_dirs(self, tmp_path: Path):
|
||||||
|
(tmp_path / "playbook.yml").write_text(
|
||||||
|
textwrap.dedent("""
|
||||||
|
- name: Play
|
||||||
|
hosts: all
|
||||||
|
tasks:
|
||||||
|
- name: Set
|
||||||
|
set_fact:
|
||||||
|
x: "{{ y | to_json }}"
|
||||||
|
""").strip()
|
||||||
|
)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--ansible-dir", str(tmp_path)])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "playbook.yml" in result.output
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
"""Unit tests for devx.tools.check_docker_init."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import textwrap
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.tools.check_docker_init import _check_template, _find_compose_templates, _parse_services, main
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindComposeTemplates:
|
||||||
|
def test_finds_docker_compose_templates(self, tmp_path: Path):
|
||||||
|
(tmp_path / "docker-compose.observability.yml.j2").write_text("services:")
|
||||||
|
(tmp_path / "docker-compose.service.yml.j2").write_text("services:")
|
||||||
|
result = _find_compose_templates(tmp_path)
|
||||||
|
assert len(result) == 2
|
||||||
|
|
||||||
|
def test_finds_exporters_compose(self, tmp_path: Path):
|
||||||
|
(tmp_path / "exporters-compose.yml.j2").write_text("services:")
|
||||||
|
result = _find_compose_templates(tmp_path)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert "exporters-compose" in str(result[0])
|
||||||
|
|
||||||
|
def test_finds_compose_yaml_templates(self, tmp_path: Path):
|
||||||
|
(tmp_path / "compose.yaml.j2").write_text("services:")
|
||||||
|
result = _find_compose_templates(tmp_path)
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
def test_single_file(self, tmp_path: Path):
|
||||||
|
f = tmp_path / "docker-compose.test.yml.j2"
|
||||||
|
f.write_text("services:")
|
||||||
|
result = _find_compose_templates(f)
|
||||||
|
assert result == [f]
|
||||||
|
|
||||||
|
def test_nonexistent_path(self, tmp_path: Path):
|
||||||
|
assert _find_compose_templates(tmp_path / "nonexistent") == []
|
||||||
|
|
||||||
|
def test_deduplicates(self, tmp_path: Path):
|
||||||
|
(tmp_path / "docker-compose.yml.j2").write_text("services:")
|
||||||
|
result = _find_compose_templates(tmp_path)
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
def test_recursive(self, tmp_path: Path):
|
||||||
|
(tmp_path / "sub").mkdir()
|
||||||
|
(tmp_path / "sub" / "docker-compose.yml.j2").write_text("services:")
|
||||||
|
result = _find_compose_templates(tmp_path)
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestParseServices:
|
||||||
|
def test_basic_services(self):
|
||||||
|
content = textwrap.dedent("""
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: nginx
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "localhost"]
|
||||||
|
db:
|
||||||
|
image: postgres
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
""").strip()
|
||||||
|
services = _parse_services(content)
|
||||||
|
assert "web" in services
|
||||||
|
assert "db" in services
|
||||||
|
assert any("image: nginx" in line for line in services["web"])
|
||||||
|
|
||||||
|
def test_jinja2_service_names(self):
|
||||||
|
content = textwrap.dedent("""
|
||||||
|
services:
|
||||||
|
{{ app_name }}:
|
||||||
|
image: {{ app_image }}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl"]
|
||||||
|
{{ app_name }}-db:
|
||||||
|
image: postgres
|
||||||
|
networks:
|
||||||
|
traefik:
|
||||||
|
""").strip()
|
||||||
|
services = _parse_services(content)
|
||||||
|
assert "{{ app_name }}" in services
|
||||||
|
assert "{{ app_name }}-db" in services
|
||||||
|
|
||||||
|
def test_no_services_section(self):
|
||||||
|
content = "version: '3'\nvolumes:\n data:"
|
||||||
|
assert _parse_services(content) == {}
|
||||||
|
|
||||||
|
def test_service_at_end_of_file(self):
|
||||||
|
content = textwrap.dedent("""
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: nginx
|
||||||
|
""").strip()
|
||||||
|
services = _parse_services(content)
|
||||||
|
assert "web" in services
|
||||||
|
|
||||||
|
def test_volumes_ends_services(self):
|
||||||
|
content = textwrap.dedent("""
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: nginx
|
||||||
|
volumes:
|
||||||
|
data:
|
||||||
|
""").strip()
|
||||||
|
services = _parse_services(content)
|
||||||
|
assert "web" in services
|
||||||
|
assert "data" not in services
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckTemplate:
|
||||||
|
def test_service_with_healthcheck_and_init_ok(self, tmp_path: Path):
|
||||||
|
content = textwrap.dedent("""
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: nginx
|
||||||
|
init: true
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "localhost"]
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "docker-compose.yml.j2"
|
||||||
|
f.write_text(content)
|
||||||
|
assert _check_template(f, tmp_path) == []
|
||||||
|
|
||||||
|
def test_service_with_healthcheck_no_init_flagged(self, tmp_path: Path):
|
||||||
|
content = textwrap.dedent("""
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: nginx
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "localhost"]
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "docker-compose.yml.j2"
|
||||||
|
f.write_text(content)
|
||||||
|
errors = _check_template(f, tmp_path)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "web" in errors[0]
|
||||||
|
assert "init: true" in errors[0]
|
||||||
|
|
||||||
|
def test_service_without_healthcheck_ok(self, tmp_path: Path):
|
||||||
|
content = textwrap.dedent("""
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: nginx
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "docker-compose.yml.j2"
|
||||||
|
f.write_text(content)
|
||||||
|
assert _check_template(f, tmp_path) == []
|
||||||
|
|
||||||
|
def test_multiple_services_some_missing(self, tmp_path: Path):
|
||||||
|
content = textwrap.dedent("""
|
||||||
|
services:
|
||||||
|
good:
|
||||||
|
image: nginx
|
||||||
|
init: true
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl"]
|
||||||
|
bad:
|
||||||
|
image: redis
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "docker-compose.yml.j2"
|
||||||
|
f.write_text(content)
|
||||||
|
errors = _check_template(f, tmp_path)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "bad" in errors[0]
|
||||||
|
assert "good" not in errors[0]
|
||||||
|
|
||||||
|
def test_no_services_section(self, tmp_path: Path):
|
||||||
|
content = "version: '3'\nvolumes:\n data:"
|
||||||
|
f = tmp_path / "docker-compose.yml.j2"
|
||||||
|
f.write_text(content)
|
||||||
|
assert _check_template(f, tmp_path) == []
|
||||||
|
|
||||||
|
def test_jinja2_conditional_service(self, tmp_path: Path):
|
||||||
|
content = textwrap.dedent("""
|
||||||
|
services:
|
||||||
|
{% if backup_enabled %}
|
||||||
|
backup:
|
||||||
|
image: backup
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pgrep backup"]
|
||||||
|
{% endif %}
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "docker-compose.yml.j2"
|
||||||
|
f.write_text(content)
|
||||||
|
errors = _check_template(f, tmp_path)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "backup" in errors[0]
|
||||||
|
|
||||||
|
def test_relative_path_in_error(self, tmp_path: Path):
|
||||||
|
content = textwrap.dedent("""
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: nginx
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD"]
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "docker-compose.yml.j2"
|
||||||
|
f.write_text(content)
|
||||||
|
errors = _check_template(f, tmp_path)
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "docker-compose.yml.j2" in errors[0]
|
||||||
|
assert str(tmp_path) not in errors[0]
|
||||||
|
|
||||||
|
|
||||||
|
class TestMain:
|
||||||
|
def test_passes_when_all_ok(self, tmp_path: Path):
|
||||||
|
content = textwrap.dedent("""
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: nginx
|
||||||
|
init: true
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD"]
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "docker-compose.yml.j2"
|
||||||
|
f.write_text(content)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--path", str(f)])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "OK" in result.output
|
||||||
|
|
||||||
|
def test_fails_when_missing_init(self, tmp_path: Path):
|
||||||
|
content = textwrap.dedent("""
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: nginx
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD"]
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
""").strip()
|
||||||
|
f = tmp_path / "docker-compose.yml.j2"
|
||||||
|
f.write_text(content)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--path", str(f)])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "FAIL" in result.output
|
||||||
|
assert "web" in result.output
|
||||||
|
|
||||||
|
def test_default_dir(self, tmp_path: Path):
|
||||||
|
(tmp_path / "docker-compose.good.yml.j2").write_text(
|
||||||
|
textwrap.dedent("""
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: nginx
|
||||||
|
init: true
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD"]
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
""").strip()
|
||||||
|
)
|
||||||
|
(tmp_path / "docker-compose.bad.yml.j2").write_text(
|
||||||
|
textwrap.dedent("""
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD"]
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
""").strip()
|
||||||
|
)
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--templates-dir", str(tmp_path)])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "db" in result.output
|
||||||
|
|
||||||
|
def test_no_templates_found(self, tmp_path: Path):
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--templates-dir", str(tmp_path)])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "OK" in result.output
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Unit tests for devx.utils.api.APIClient."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from devx.utils.api import APIClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestAPIClient:
|
||||||
|
@patch("devx.utils.api.requests.request")
|
||||||
|
def test_get(self, mock_req):
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.raise_for_status.return_value = None
|
||||||
|
mock_req.return_value = mock_resp
|
||||||
|
client = APIClient("https://api.example.com", {"Authorization": "Bearer token"})
|
||||||
|
result = client.get("/users")
|
||||||
|
assert result is mock_resp
|
||||||
|
mock_req.assert_called_once_with(
|
||||||
|
"GET",
|
||||||
|
"https://api.example.com/users",
|
||||||
|
headers={"Authorization": "Bearer token"},
|
||||||
|
timeout=30,
|
||||||
|
verify=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("devx.utils.api.requests.request")
|
||||||
|
def test_post(self, mock_req):
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.raise_for_status.return_value = None
|
||||||
|
mock_req.return_value = mock_resp
|
||||||
|
client = APIClient("https://api.example.com", {})
|
||||||
|
client.post("/users", json={"name": "alice"})
|
||||||
|
mock_req.assert_called_once_with(
|
||||||
|
"POST",
|
||||||
|
"https://api.example.com/users",
|
||||||
|
headers={},
|
||||||
|
timeout=30,
|
||||||
|
verify=True,
|
||||||
|
json={"name": "alice"},
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("devx.utils.api.requests.request")
|
||||||
|
def test_put(self, mock_req):
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.raise_for_status.return_value = None
|
||||||
|
mock_req.return_value = mock_resp
|
||||||
|
client = APIClient("https://api.example.com", {})
|
||||||
|
client.put("/users/1", json={"name": "bob"})
|
||||||
|
mock_req.assert_called_once_with(
|
||||||
|
"PUT",
|
||||||
|
"https://api.example.com/users/1",
|
||||||
|
headers={},
|
||||||
|
timeout=30,
|
||||||
|
verify=True,
|
||||||
|
json={"name": "bob"},
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("devx.utils.api.requests.request")
|
||||||
|
def test_delete(self, mock_req):
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.raise_for_status.return_value = None
|
||||||
|
mock_req.return_value = mock_resp
|
||||||
|
client = APIClient("https://api.example.com", {})
|
||||||
|
client.delete("/users/1")
|
||||||
|
mock_req.assert_called_once_with(
|
||||||
|
"DELETE",
|
||||||
|
"https://api.example.com/users/1",
|
||||||
|
headers={},
|
||||||
|
timeout=30,
|
||||||
|
verify=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("devx.utils.api.requests.request")
|
||||||
|
def test_patch(self, mock_req):
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.raise_for_status.return_value = None
|
||||||
|
mock_req.return_value = mock_resp
|
||||||
|
client = APIClient("https://api.example.com", {})
|
||||||
|
client.patch("/users/1", json={"name": "carol"})
|
||||||
|
mock_req.assert_called_once_with(
|
||||||
|
"PATCH",
|
||||||
|
"https://api.example.com/users/1",
|
||||||
|
headers={},
|
||||||
|
timeout=30,
|
||||||
|
verify=True,
|
||||||
|
json={"name": "carol"},
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("devx.utils.api.requests.request")
|
||||||
|
def test_auth_tuple(self, mock_req):
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.raise_for_status.return_value = None
|
||||||
|
mock_req.return_value = mock_resp
|
||||||
|
client = APIClient("https://api.example.com", {}, auth=("admin", "pass"))
|
||||||
|
client.get("/data")
|
||||||
|
mock_req.assert_called_once_with(
|
||||||
|
"GET",
|
||||||
|
"https://api.example.com/data",
|
||||||
|
headers={},
|
||||||
|
timeout=30,
|
||||||
|
verify=True,
|
||||||
|
auth=("admin", "pass"),
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("devx.utils.api.requests.request")
|
||||||
|
def test_custom_timeout_and_verify(self, mock_req):
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.raise_for_status.return_value = None
|
||||||
|
mock_req.return_value = mock_resp
|
||||||
|
client = APIClient("https://api.example.com", {}, timeout=60, verify=False)
|
||||||
|
client.get("/data")
|
||||||
|
mock_req.assert_called_once_with(
|
||||||
|
"GET",
|
||||||
|
"https://api.example.com/data",
|
||||||
|
headers={},
|
||||||
|
timeout=60,
|
||||||
|
verify=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("devx.utils.api.requests.request")
|
||||||
|
def test_raises_on_error(self, mock_req):
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.raise_for_status.side_effect = requests.HTTPError("500")
|
||||||
|
mock_req.return_value = mock_resp
|
||||||
|
client = APIClient("https://api.example.com", {})
|
||||||
|
with pytest.raises(requests.HTTPError):
|
||||||
|
client.get("/fail")
|
||||||
|
|
||||||
|
@patch("devx.utils.api.requests.request")
|
||||||
|
def test_strips_trailing_slash(self, mock_req):
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.raise_for_status.return_value = None
|
||||||
|
mock_req.return_value = mock_resp
|
||||||
|
client = APIClient("https://api.example.com/", {})
|
||||||
|
client.get("/users")
|
||||||
|
mock_req.assert_called_once_with(
|
||||||
|
"GET",
|
||||||
|
"https://api.example.com/users",
|
||||||
|
headers={},
|
||||||
|
timeout=30,
|
||||||
|
verify=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("devx.utils.api.requests.request")
|
||||||
|
def test_kwargs_override_defaults(self, mock_req):
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.raise_for_status.return_value = None
|
||||||
|
mock_req.return_value = mock_resp
|
||||||
|
client = APIClient("https://api.example.com", {}, timeout=30)
|
||||||
|
client.get("/slow", timeout=120)
|
||||||
|
mock_req.assert_called_once_with(
|
||||||
|
"GET",
|
||||||
|
"https://api.example.com/slow",
|
||||||
|
headers={},
|
||||||
|
timeout=120,
|
||||||
|
verify=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch("devx.utils.api.requests.request")
|
||||||
|
def test_no_auth_when_not_set(self, mock_req):
|
||||||
|
mock_resp = MagicMock()
|
||||||
|
mock_resp.raise_for_status.return_value = None
|
||||||
|
mock_req.return_value = mock_resp
|
||||||
|
client = APIClient("https://api.example.com", {})
|
||||||
|
client.get("/data")
|
||||||
|
call_kwargs = mock_req.call_args.kwargs
|
||||||
|
assert "auth" not in call_kwargs
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Unit tests for devx.utils.jinja."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import jinja2
|
||||||
|
|
||||||
|
from devx.utils.jinja import (
|
||||||
|
make_env,
|
||||||
|
make_value_env,
|
||||||
|
regex_escape,
|
||||||
|
regex_replace,
|
||||||
|
regex_search,
|
||||||
|
render_manifest_values,
|
||||||
|
render_template,
|
||||||
|
render_value,
|
||||||
|
to_bool,
|
||||||
|
to_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFilters:
|
||||||
|
def test_to_json(self):
|
||||||
|
assert to_json({"a": 1}) == '{"a": 1}'
|
||||||
|
|
||||||
|
def test_to_json_list(self):
|
||||||
|
assert to_json([1, 2]) == "[1, 2]"
|
||||||
|
|
||||||
|
def test_to_bool_true(self):
|
||||||
|
assert to_bool(True) is True
|
||||||
|
|
||||||
|
def test_to_bool_false(self):
|
||||||
|
assert to_bool(False) is False
|
||||||
|
|
||||||
|
def test_to_bool_string_true(self):
|
||||||
|
assert to_bool("yes") is True
|
||||||
|
|
||||||
|
def test_to_bool_string_false(self):
|
||||||
|
assert to_bool("false") is False
|
||||||
|
|
||||||
|
def test_to_bool_empty_string(self):
|
||||||
|
assert to_bool("") is False
|
||||||
|
|
||||||
|
def test_to_bool_none(self):
|
||||||
|
assert to_bool(None) is False
|
||||||
|
|
||||||
|
def test_to_bool_int(self):
|
||||||
|
assert to_bool(1) is True
|
||||||
|
assert to_bool(0) is False
|
||||||
|
|
||||||
|
def test_regex_replace(self):
|
||||||
|
assert regex_replace("hello world", "world", "there") == "hello there"
|
||||||
|
|
||||||
|
def test_regex_replace_with_pattern(self):
|
||||||
|
assert regex_replace("abc123", r"\d+", "X") == "abcX"
|
||||||
|
|
||||||
|
def test_regex_escape(self):
|
||||||
|
assert regex_escape("a.b*c") == "a\\.b\\*c"
|
||||||
|
|
||||||
|
def test_regex_search_found(self):
|
||||||
|
assert regex_search("hello world", r"world") == "world"
|
||||||
|
|
||||||
|
def test_regex_search_not_found(self):
|
||||||
|
assert regex_search("hello", r"world") is None
|
||||||
|
|
||||||
|
def test_regex_search_group(self):
|
||||||
|
assert regex_search("abc123", r"\d+") == "123"
|
||||||
|
|
||||||
|
|
||||||
|
class TestMakeEnv:
|
||||||
|
def test_returns_environment(self, tmp_path):
|
||||||
|
(tmp_path / "test.j2").write_text("hello {{ name }}")
|
||||||
|
env = make_env(str(tmp_path))
|
||||||
|
assert isinstance(env, jinja2.Environment)
|
||||||
|
|
||||||
|
def test_has_filters(self, tmp_path):
|
||||||
|
env = make_env(str(tmp_path))
|
||||||
|
assert "to_json" in env.filters
|
||||||
|
assert "bool" in env.filters
|
||||||
|
assert "regex_replace" in env.filters
|
||||||
|
assert "regex_escape" in env.filters
|
||||||
|
assert "regex_search" in env.filters
|
||||||
|
|
||||||
|
def test_cached(self, tmp_path):
|
||||||
|
env1 = make_env(str(tmp_path))
|
||||||
|
env2 = make_env(str(tmp_path))
|
||||||
|
assert env1 is env2
|
||||||
|
|
||||||
|
def test_auto_reload_disabled(self, tmp_path):
|
||||||
|
env = make_env(str(tmp_path))
|
||||||
|
assert env.auto_reload is False
|
||||||
|
|
||||||
|
def test_strict_undefined(self, tmp_path):
|
||||||
|
env = make_env(str(tmp_path))
|
||||||
|
assert env.undefined is jinja2.StrictUndefined
|
||||||
|
|
||||||
|
|
||||||
|
class TestMakeValueEnv:
|
||||||
|
def test_returns_environment(self):
|
||||||
|
env = make_value_env()
|
||||||
|
assert isinstance(env, jinja2.Environment)
|
||||||
|
|
||||||
|
def test_chainable_undefined(self):
|
||||||
|
env = make_value_env()
|
||||||
|
assert env.undefined is jinja2.ChainableUndefined
|
||||||
|
|
||||||
|
def test_cached(self):
|
||||||
|
assert make_value_env() is make_value_env()
|
||||||
|
|
||||||
|
def test_has_filters(self):
|
||||||
|
env = make_value_env()
|
||||||
|
assert "to_json" in env.filters
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderTemplate:
|
||||||
|
def test_renders_named_template(self, tmp_path):
|
||||||
|
(tmp_path / "test.j2").write_text("hello {{ name }}")
|
||||||
|
env = make_env(str(tmp_path))
|
||||||
|
assert render_template(env, "test.j2", name="world") == "hello world"
|
||||||
|
|
||||||
|
def test_renders_with_filters(self, tmp_path):
|
||||||
|
(tmp_path / "test.j2").write_text("{{ data | to_json }}")
|
||||||
|
env = make_env(str(tmp_path))
|
||||||
|
assert render_template(env, "test.j2", data={"a": 1}) == '{"a": 1}'
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderValue:
|
||||||
|
def test_renders_string_with_expressions(self):
|
||||||
|
assert render_value("hello {{ name }}", {"name": "world"}) == "hello world"
|
||||||
|
|
||||||
|
def test_passes_through_non_string(self):
|
||||||
|
assert render_value(42, {}) == 42
|
||||||
|
|
||||||
|
def test_passes_through_string_without_expressions(self):
|
||||||
|
assert render_value("plain text", {}) == "plain text"
|
||||||
|
|
||||||
|
def test_passes_through_none(self):
|
||||||
|
assert render_value(None, {}) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderManifestValues:
|
||||||
|
def test_renders_dict_values(self):
|
||||||
|
result = render_manifest_values({"key": "{{ value }}"}, {"value": "rendered"})
|
||||||
|
assert result == {"key": "rendered"}
|
||||||
|
|
||||||
|
def test_renders_list_values(self):
|
||||||
|
result = render_manifest_values(["{{ a }}", "{{ b }}"], {"a": "1", "b": "2"})
|
||||||
|
assert result == ["1", "2"]
|
||||||
|
|
||||||
|
def test_renders_nested(self):
|
||||||
|
result = render_manifest_values({"outer": {"inner": "{{ x }}"}}, {"x": "yes"})
|
||||||
|
assert result == {"outer": {"inner": "yes"}}
|
||||||
|
|
||||||
|
def test_passes_through_non_string(self):
|
||||||
|
result = render_manifest_values({"n": 42, "b": True, "l": [1, 2]}, {})
|
||||||
|
assert result == {"n": 42, "b": True, "l": [1, 2]}
|
||||||
|
|
||||||
|
def test_empty_dict(self):
|
||||||
|
assert render_manifest_values({}, {}) == {}
|
||||||
|
|
||||||
|
def test_empty_list(self):
|
||||||
|
assert render_manifest_values([], {}) == []
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Unit tests for devx.utils.ui."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import devx.utils.ui as ui_mod
|
||||||
|
from devx.utils.ui import _console_level, configure_ui, say
|
||||||
|
|
||||||
|
|
||||||
|
class TestConsoleLevel:
|
||||||
|
def test_default_is_info(self) -> None:
|
||||||
|
with patch.dict("os.environ", {}, clear=True):
|
||||||
|
assert _console_level() == logging.INFO
|
||||||
|
|
||||||
|
def test_env_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setenv("DEVX_LOG_LEVEL", "DEBUG")
|
||||||
|
assert _console_level() == logging.DEBUG
|
||||||
|
|
||||||
|
def test_invalid_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setenv("DEVX_LOG_LEVEL", "VERBOSE")
|
||||||
|
assert _console_level() == logging.INFO
|
||||||
|
|
||||||
|
def test_custom_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
configure_ui(log_level_env_var="GRM_LOG_LEVEL")
|
||||||
|
try:
|
||||||
|
monkeypatch.setenv("GRM_LOG_LEVEL", "DEBUG")
|
||||||
|
monkeypatch.delenv("DEVX_LOG_LEVEL", raising=False)
|
||||||
|
assert _console_level() == logging.DEBUG
|
||||||
|
finally:
|
||||||
|
configure_ui()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSay:
|
||||||
|
def test_echoes_to_console(self) -> None:
|
||||||
|
with patch("devx.utils.ui.click.echo") as mock_echo:
|
||||||
|
say("hello")
|
||||||
|
mock_echo.assert_called_once_with("hello", err=False)
|
||||||
|
|
||||||
|
def test_logs_at_info_level(self) -> None:
|
||||||
|
with (
|
||||||
|
patch("devx.utils.ui.click.echo"),
|
||||||
|
patch("devx.utils.ui.logging.getLogger") as mock_get_logger,
|
||||||
|
):
|
||||||
|
mock_logger = mock_get_logger.return_value
|
||||||
|
say("hello")
|
||||||
|
mock_logger.log.assert_called_once_with(logging.INFO, "hello")
|
||||||
|
|
||||||
|
def test_passes_level_and_err(self) -> None:
|
||||||
|
with (
|
||||||
|
patch("devx.utils.ui.click.echo") as mock_echo,
|
||||||
|
patch("devx.utils.ui.logging.getLogger") as mock_get_logger,
|
||||||
|
):
|
||||||
|
mock_logger = mock_get_logger.return_value
|
||||||
|
say("error msg", level=logging.ERROR, err=True)
|
||||||
|
mock_echo.assert_called_once_with("error msg", err=True)
|
||||||
|
mock_logger.log.assert_called_once_with(logging.ERROR, "error msg")
|
||||||
|
|
||||||
|
def test_suppresses_console_below_level(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setenv("DEVX_LOG_LEVEL", "WARNING")
|
||||||
|
with (
|
||||||
|
patch("devx.utils.ui.click.echo") as mock_echo,
|
||||||
|
patch("devx.utils.ui.logging.getLogger") as mock_get_logger,
|
||||||
|
):
|
||||||
|
mock_logger = mock_get_logger.return_value
|
||||||
|
say("debug msg", level=logging.DEBUG)
|
||||||
|
mock_echo.assert_not_called()
|
||||||
|
mock_logger.log.assert_called_once_with(logging.DEBUG, "debug msg")
|
||||||
|
|
||||||
|
def test_color_applied(self) -> None:
|
||||||
|
with (
|
||||||
|
patch("devx.utils.ui.click.echo") as mock_echo,
|
||||||
|
patch("devx.utils.ui.click.style") as mock_style,
|
||||||
|
):
|
||||||
|
mock_style.return_value = "styled-output"
|
||||||
|
say("success", color="green")
|
||||||
|
mock_style.assert_called_once_with("success", fg="green")
|
||||||
|
mock_echo.assert_called_once_with("styled-output", err=False)
|
||||||
|
|
||||||
|
def test_custom_logger_name(self) -> None:
|
||||||
|
configure_ui(logger_name="grm")
|
||||||
|
try:
|
||||||
|
with (
|
||||||
|
patch("devx.utils.ui.click.echo"),
|
||||||
|
patch("devx.utils.ui.logging.getLogger") as mock_get_logger,
|
||||||
|
):
|
||||||
|
say("hello")
|
||||||
|
mock_get_logger.assert_called_with("grm")
|
||||||
|
finally:
|
||||||
|
configure_ui()
|
||||||
|
|
||||||
|
|
||||||
|
class TestConfigureUi:
|
||||||
|
def test_reset_to_defaults(self) -> None:
|
||||||
|
configure_ui(log_level_env_var="GRM_LOG_LEVEL", logger_name="grm")
|
||||||
|
configure_ui()
|
||||||
|
assert ui_mod._LOG_LEVEL_ENV_VAR == "DEVX_LOG_LEVEL"
|
||||||
|
assert ui_mod._LOGGER_NAME == "devx"
|
||||||
Reference in New Issue
Block a user