Public Access
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
269699fd6f | ||
|
|
b18fef3f33 | ||
|
|
dd8e6c69e9 | ||
|
|
ccb7023965 | ||
|
|
7dd15f1461 | ||
|
|
d4e4621fa1 | ||
|
|
a6f814c446 | ||
|
|
04aa5acb1f | ||
|
|
6973f9d851 | ||
|
|
2669a0ea73 | ||
|
|
03f057b55a | ||
|
|
706d6dafe0 | ||
|
|
03ddce427c | ||
|
|
9642d6884c | ||
|
|
b2074d6635 | ||
|
|
a6dddf25e7 | ||
|
|
01130a7385 | ||
|
|
07580c9280 | ||
|
|
6601d90bee | ||
|
|
0df79fed53 | ||
|
|
cf8287e683 | ||
|
|
9f1bdc4cf1 |
@@ -1,9 +0,0 @@
|
||||
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]\.'
|
||||
@@ -1,64 +0,0 @@
|
||||
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
|
||||
@@ -1,12 +0,0 @@
|
||||
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
|
||||
@@ -1,13 +0,0 @@
|
||||
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+'
|
||||
@@ -1,30 +0,0 @@
|
||||
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
|
||||
@@ -1,9 +0,0 @@
|
||||
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}'
|
||||
@@ -1,9 +0,0 @@
|
||||
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:
|
||||
- '\.\.\.'
|
||||
@@ -1,13 +0,0 @@
|
||||
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'
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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?
|
||||
@@ -1,12 +0,0 @@
|
||||
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|$)'
|
||||
@@ -1,15 +0,0 @@
|
||||
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
|
||||
@@ -1,9 +0,0 @@
|
||||
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
|
||||
@@ -1,43 +0,0 @@
|
||||
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)
|
||||
@@ -1,13 +0,0 @@
|
||||
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*$'
|
||||
@@ -1,32 +0,0 @@
|
||||
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
|
||||
@@ -1,13 +0,0 @@
|
||||
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
|
||||
@@ -1,15 +0,0 @@
|
||||
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
|
||||
@@ -1,14 +0,0 @@
|
||||
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'
|
||||
@@ -1,12 +0,0 @@
|
||||
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\)'
|
||||
@@ -1,7 +0,0 @@
|
||||
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)
|
||||
@@ -1,28 +0,0 @@
|
||||
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+(?:[.?!]|$)'
|
||||
@@ -1,15 +0,0 @@
|
||||
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}\))[^)]+\)'
|
||||
@@ -1,184 +0,0 @@
|
||||
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
|
||||
@@ -1,7 +0,0 @@
|
||||
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,}'
|
||||
@@ -1,7 +0,0 @@
|
||||
extends: existence
|
||||
message: "Commas and periods go inside quotation marks."
|
||||
link: 'https://developers.google.com/style/quotation-marks'
|
||||
level: error
|
||||
nonword: true
|
||||
tokens:
|
||||
- '"[^"]+"[.,?]'
|
||||
@@ -1,7 +0,0 @@
|
||||
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+'
|
||||
@@ -1,8 +0,0 @@
|
||||
extends: existence
|
||||
message: "Use semicolons judiciously."
|
||||
link: 'https://developers.google.com/style/semicolons'
|
||||
nonword: true
|
||||
scope: sentence
|
||||
level: suggestion
|
||||
tokens:
|
||||
- ';'
|
||||
@@ -1,11 +0,0 @@
|
||||
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
|
||||
@@ -1,10 +0,0 @@
|
||||
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]'
|
||||
@@ -1,10 +0,0 @@
|
||||
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'
|
||||
@@ -1,13 +0,0 @@
|
||||
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
|
||||
@@ -1,10 +0,0 @@
|
||||
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)'
|
||||
@@ -1,11 +0,0 @@
|
||||
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
|
||||
@@ -1,7 +0,0 @@
|
||||
extends: existence
|
||||
message: "Avoid using '%s'."
|
||||
link: 'https://developers.google.com/style/tense'
|
||||
ignorecase: true
|
||||
level: warning
|
||||
tokens:
|
||||
- will
|
||||
@@ -1,29 +0,0 @@
|
||||
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
|
||||
@@ -1,68 +0,0 @@
|
||||
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
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"feed": "https://github.com/errata-ai/Google/releases.atom",
|
||||
"vale_version": ">=1.0.0"
|
||||
}
|
||||
@@ -124,10 +124,6 @@ src/devx/
|
||||
│ ├── 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
|
||||
│ ├── check_ansible_no_log.py # Check Ansible tasks for missing no_log on secrets
|
||||
│ ├── check_ansible_patterns.py # Detect dangerous failure-masking patterns
|
||||
│ ├── check_jinja_expr.py # Validate Jinja2 expressions in Ansible files
|
||||
│ ├── check_ansible_no_state_absent_on_db.py # Prevent state:absent on DB paths
|
||||
│ └── _shared.py # Shared tool utilities
|
||||
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
|
||||
├── utils/ # Shared utilities (reusable across projects)
|
||||
@@ -145,8 +141,8 @@ src/devx/
|
||||
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
||||
├── discover_runners.py # Dynamic Gitea runner discovery
|
||||
├── 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_changed.py # Detect which Ansible roles changed and output molecule scenarios
|
||||
├── start_docker.py # Ensure Docker daemon is running for molecule tests
|
||||
└── platforms.py # Supported molecule platforms
|
||||
```
|
||||
|
||||
+50
-73
@@ -2,96 +2,73 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.50.1] - 2026-08-15
|
||||
## [0.49.5] - 2026-08-07
|
||||
|
||||
### Performance
|
||||
|
||||
- Skip dep resolution in setup-image with --no-deps
|
||||
|
||||
## [0.49.4] - 2026-08-07
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Prefer rootless Docker socket over low-space inner DinD daemon
|
||||
- Check /run/host-docker.sock for host Docker daemon
|
||||
- Use /dev/shm/docker as data-root for inner dockerd
|
||||
- Kill existing dockerd before starting /dev/shm/docker daemon
|
||||
- Use /dev/shm/docker.sock socket for local dockerd
|
||||
- Add --iptables=false to local dockerd
|
||||
- Disable bridge and ip6tables for local dockerd
|
||||
- Use host Docker when root dir is inaccessible (free=0)
|
||||
- Prefer /run/host-docker.sock over inner dockerd
|
||||
- Start local dockerd instead of using low-space inner dockerd
|
||||
- Use container overlay for local dockerd data root
|
||||
- Kill inner dockerd with SIGKILL, use alt socket if alive
|
||||
- Kill dockerd by PID when pkill fails, use /dev/shm for alive daemon
|
||||
- Trust /var/run/docker.sock with free=0 when no inner dockerd exists
|
||||
- Add container.credentials for private registry auth
|
||||
|
||||
## [0.50.0] - 2026-08-09
|
||||
## [0.49.3] - 2026-08-07
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Retry ansible-galaxy collection install on transient timeouts
|
||||
|
||||
## [0.49.2] - 2026-08-07
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add fallback URL for tea download
|
||||
|
||||
## [0.49.1] - 2026-08-07
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add container images to build-images workflow
|
||||
|
||||
## [0.49.0] - 2026-08-07
|
||||
|
||||
### Features
|
||||
|
||||
- Add 5 standalone lint scripts from infra
|
||||
|
||||
## [0.49.0] - 2026-08-09
|
||||
- Add --include-roles and --exclude-roles to distribute_molecule
|
||||
## [0.48.0] - 2026-07-22
|
||||
|
||||
### Features
|
||||
|
||||
- Sync missing features from v0.49.x line to master
|
||||
- Extract reusable components from infra and grm into devx
|
||||
|
||||
## [0.48.2] - 2026-08-09
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Remove dead translation keys and add missing one
|
||||
|
||||
## [0.48.1] - 2026-08-08
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- *(setup)* Extract version from filename for mirror installs
|
||||
|
||||
## [0.48.0] - 2026-08-08
|
||||
## [0.48.0] - 2026-07-22
|
||||
|
||||
### Features
|
||||
|
||||
- *(setup)* Mirror Ansible collections from Gitea registry with auth
|
||||
- Extract reusable components from infra and grm into devx
|
||||
|
||||
## [0.47.10] - 2026-08-05
|
||||
## [Unreleased]
|
||||
|
||||
### Bug Fixes
|
||||
### Features
|
||||
|
||||
- Unique molecule container names per CI runner
|
||||
|
||||
## [0.47.9] - 2026-08-03
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Unique molecule container names per CI runner
|
||||
|
||||
## [0.47.8] - 2026-08-03
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Increase CI_SCALE_FACTOR default from 4 to 6
|
||||
|
||||
## [0.47.7] - 2026-08-03
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Scale check_test_speed limits on CI runners
|
||||
|
||||
## [0.47.6] - 2026-08-03
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Configure git auth in setup_image for git+https deps
|
||||
|
||||
## [0.47.5] - 2026-08-03
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Push wiki to main branch instead of master
|
||||
|
||||
## [0.47.4] - 2026-08-03
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add User-Agent header to _download in install_tools
|
||||
- Extract reusable components from infra and grm into devx:
|
||||
- `devx.utils.ui.say()` — unified click.echo + logging output
|
||||
- `devx.utils.api.APIClient` — base HTTP API client class with retry logic
|
||||
- `devx.utils.jinja` — Jinja2 environment helpers with Ansible-compatible filters
|
||||
- `devx.i18n.configure_i18n()` — configurable `lang_env_var` and `translations_path_env_var`
|
||||
- `devx.ci.cancel_superseded_runs` — cancel in-flight CI runs for the same PR branch
|
||||
- `devx.ci.check_workflow_artifact_deps` — verify artifact download jobs depend on upload jobs
|
||||
- `devx.ci.check_workflow_tofu_init` — verify tofu-state jobs have a tofu-init step
|
||||
- `devx.tools.check_docker_init` — check Docker Compose services with healthchecks have init: true
|
||||
- `devx.tools.check_ansible_set_fact_to_json` — check set_fact tasks don't misuse to_json
|
||||
- `devx.tools.check_alert_rules` — validate Prometheus alert rules with promtool
|
||||
- Add `jinja2` and `pyyaml` as core dependencies (previously in `deploy` extras only)
|
||||
- Register new CLI commands: `devx ci cancel-superseded-runs`, `devx ci check-workflow-artifact-deps`,
|
||||
`devx ci check-workflow-tofu-init`, `devx tools check-docker-init`,
|
||||
`devx tools check-ansible-set-fact-to-json`, `devx tools check-alert-rules`
|
||||
- Add Makefile targets for all new check tools
|
||||
|
||||
## [0.47.3] - 2026-07-17
|
||||
|
||||
|
||||
@@ -16,12 +16,12 @@ quality badges.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Why devx?
|
||||
|
||||
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.50.1",
|
||||
"devx>=0.49.5",
|
||||
]
|
||||
|
||||
[tool.pip]
|
||||
@@ -101,8 +101,8 @@ pip install -e .
|
||||
```
|
||||
|
||||
> **Note:** If your project requires a specific devx version, pin it in
|
||||
> `dependencies` (for example, `"devx==0.50.1"`) or use a version constraint
|
||||
> (for example, `"devx>=0.50.1,<0.51"`).
|
||||
> `dependencies` (for example, `"devx==0.49.5"`) or use a version constraint
|
||||
> (for example, `"devx>=0.49.5,<0.50"`).
|
||||
|
||||
### Optional extras
|
||||
|
||||
@@ -226,6 +226,10 @@ 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-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)
|
||||
python -m devx.molecule.molecule_all
|
||||
python -m devx.molecule.molecule_all --bin .venv/bin
|
||||
@@ -299,6 +303,7 @@ devx --version
|
||||
| `devx molecule all` | Run all molecule scenarios on all supported platforms |
|
||||
| `devx molecule discover-runners` | Discover available Gitea Actions 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)
|
||||
in the wiki for full command documentation with examples.
|
||||
|
||||
+11
-11
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.50.1",
|
||||
"devx>=0.49.5",
|
||||
]
|
||||
|
||||
[tool.pip]
|
||||
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
||||
```
|
||||
|
||||
Pin a specific version if needed: `"devx==0.50.1"` or `"devx>=0.50.1,<0.51"`.
|
||||
Pin a specific version if needed: `"devx==0.49.5"` or `"devx>=0.49.5,<0.50"`.
|
||||
|
||||
### 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,
|
||||
configure_repo, generate_badges, generate_cliff_config, install_checkmake
|
||||
- **Molecule tools** (`devx.molecule`) — Optional, for projects with Ansible
|
||||
roles: distribute_molecule, molecule_all, discover_runners, start_docker,
|
||||
platforms
|
||||
roles: distribute_molecule, molecule_ci_guard, molecule_all, discover_runners,
|
||||
start_docker, platforms
|
||||
|
||||
See [Architecture](Architecture) for the full package structure, module
|
||||
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 tools <command>` — Developer tools (9 commands)
|
||||
- `devx molecule <command>` — Molecule testing (3 commands, optional)
|
||||
- `devx molecule <command>` — Molecule testing (4 commands, optional)
|
||||
|
||||
See [CLI Commands](CLI-Commands) for full command documentation with examples.
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ src/devx/
|
||||
├── __init__.py
|
||||
├── discover_runners.py # Dynamic Gitea runner discovery
|
||||
├── 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
|
||||
├── start_docker.py # Ensure Docker is available for molecule
|
||||
└── platforms.py # Supported molecule platforms
|
||||
@@ -299,9 +300,9 @@ Distributes files matching a glob pattern across N parallel runners
|
||||
|
||||
### `integration_guard.py`
|
||||
|
||||
Runs pytest with cross-runner failure detection. A background thread polls
|
||||
the Gitea API. If any other integration-tests matrix runner reports failure,
|
||||
the current pytest subprocess is killed and this runner exits early.
|
||||
Runs pytest with the same cross-runner failure detection mechanism used by
|
||||
`molecule_ci_guard`. If any other integration-tests matrix runner reports
|
||||
failure, the current pytest subprocess is killed and this runner exits early.
|
||||
|
||||
## Developer tools (`devx.tools`)
|
||||
|
||||
@@ -381,6 +382,13 @@ the supported OS platform matrix. Supports `--roles-root` for multi-role
|
||||
repositories, `--list` to list scenarios, and `--list-platforms` to list
|
||||
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`
|
||||
|
||||
Runs all molecule scenarios on all supported OS platforms sequentially.
|
||||
|
||||
@@ -478,6 +478,15 @@ python -m devx.molecule.distribute_molecule --list
|
||||
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`
|
||||
|
||||
Validates commit messages. On feature branches: conventional commits only
|
||||
|
||||
@@ -631,3 +631,29 @@ Options:
|
||||
- `--list-platforms` — list all platforms, one per line
|
||||
- `--roles-root <dir>` — roles root directory for multi-role repos (default:
|
||||
`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
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.50.1",
|
||||
"devx>=0.49.5",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"devx>=0.50.1",
|
||||
"devx>=0.49.5",
|
||||
]
|
||||
```
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||
|
||||
__version__ = "0.50.1"
|
||||
__version__ = "0.49.5"
|
||||
|
||||
@@ -52,6 +52,7 @@ REQUIRED_SCRIPTS = [
|
||||
"detect_release_commit.py",
|
||||
"push_badges.py",
|
||||
"distribute_molecule.py",
|
||||
"molecule_ci_guard.py",
|
||||
"validate_commit_msg.py",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run integration tests with cross-runner failure detection.
|
||||
|
||||
Wraps ``pytest`` with Gitea API polling. If any other integration-tests
|
||||
matrix runner reports failure, the current pytest subprocess is killed
|
||||
and this runner exits early with code 1.
|
||||
Wraps ``pytest`` with the same Gitea API polling mechanism used by
|
||||
``molecule_ci_guard``. If any other integration-tests matrix runner
|
||||
reports failure, the current pytest subprocess is killed and this runner
|
||||
exits early with code 1.
|
||||
|
||||
Usage::
|
||||
|
||||
@@ -34,62 +35,17 @@ import threading
|
||||
import time
|
||||
|
||||
import click
|
||||
import requests
|
||||
|
||||
from devx.config import REPO_NAME, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
from devx.molecule.molecule_ci_guard import (
|
||||
poll_for_other_failures,
|
||||
)
|
||||
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 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.argument("pytest_args", nargs=-1, type=click.UNPROCESSED, required=True)
|
||||
def cli(pytest_args: tuple[str, ...]) -> None:
|
||||
|
||||
@@ -301,6 +301,13 @@ def molecule_discover_runners(args: tuple[str, ...]) -> None:
|
||||
_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")
|
||||
@click.argument("args", nargs=-1)
|
||||
def molecule_all(args: tuple[str, ...]) -> None:
|
||||
|
||||
@@ -1,158 +0,0 @@
|
||||
"""Detect which Ansible roles changed and output their molecule scenarios.
|
||||
|
||||
Usage::
|
||||
|
||||
python -m devx.molecule.molecule_changed --print-targets
|
||||
python -m devx.molecule.molecule_changed --base origin/master --print-roles
|
||||
|
||||
Outputs the list of make targets (e.g. molecule-docker-base) for roles
|
||||
that have changed files vs the base ref. Used by ``make molecule-changed``
|
||||
to run only the molecule scenarios affected by the current diff.
|
||||
|
||||
Role-to-target mapping is derived from the directory structure:
|
||||
ansible/roles/<role>/ → molecule-<role>
|
||||
|
||||
For roles with multiple scenarios (e.g. app_container has customer-apps,
|
||||
nextcloud, postgres-upgrade, simple-app), the base target runs all
|
||||
scenarios for that role.
|
||||
|
||||
Playbooks that change also trigger molecule for the roles they include.
|
||||
Shared infrastructure changes (ansible.cfg, requirements.yml, molecule/)
|
||||
trigger all scenarios.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess # nosec B404 — used to run git, a trusted binary
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
REPO_ROOT = Path.cwd()
|
||||
|
||||
# Map role names to make targets.
|
||||
ROLE_TARGET_MAP: dict[str, str] = {
|
||||
"app_container": "molecule-app-container",
|
||||
"app_hardening": "molecule-app-hardening",
|
||||
"crowdsec": "molecule-crowdsec",
|
||||
"disk_cleanup": "molecule-disk-cleanup",
|
||||
"docker_base": "molecule-docker-base",
|
||||
"observability": "molecule-observability",
|
||||
"restore": "molecule-restore",
|
||||
"sso_config": "molecule-sso-config",
|
||||
"storage": "molecule-storage",
|
||||
"zitadel": "molecule-zitadel",
|
||||
}
|
||||
|
||||
# Playbooks that map to molecule scenarios (via roles they include).
|
||||
PLAYBOOK_ROLE_MAP: dict[str, list[str]] = {
|
||||
"ansible/playbooks/deploy-observability.yml": ["observability", "docker_base", "zitadel", "crowdsec"],
|
||||
"ansible/playbooks/deploy-customer.yml": ["app_container", "docker_base", "app_hardening", "sso_config"],
|
||||
"ansible/playbooks/configure-oidc.yml": ["sso_config", "app_container"],
|
||||
"ansible/playbooks/prepare-vms.yml": ["docker_base", "app_hardening", "storage", "disk_cleanup", "crowdsec"],
|
||||
}
|
||||
|
||||
# Shared infrastructure that affects all molecule tests.
|
||||
SHARED_PATHS = (
|
||||
"ansible/ansible.cfg",
|
||||
"ansible/requirements.yml",
|
||||
"ansible/molecule/",
|
||||
)
|
||||
|
||||
# Minimum path parts for a role file: ansible/roles/<role> (3 parts).
|
||||
# Files inside the role have more parts, but we only need the role name.
|
||||
_MIN_ROLE_PATH_PARTS = 3
|
||||
|
||||
|
||||
def _run_git(args: list[str]) -> str: # pragma: no cover
|
||||
"""Run a git command and return stdout."""
|
||||
result = subprocess.run( # nosec
|
||||
["git", *args],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def get_changed_files(base: str) -> list[str]:
|
||||
"""Get list of changed files vs base ref."""
|
||||
for ref in [base, "master"]:
|
||||
output = _run_git(["diff", "--name-only", f"{ref}...HEAD"])
|
||||
if output.strip():
|
||||
return sorted(output.strip().splitlines())
|
||||
return []
|
||||
|
||||
|
||||
def detect_changed_roles(changed_files: list[str]) -> set[str]:
|
||||
"""Detect which roles have changed files."""
|
||||
roles: set[str] = set()
|
||||
|
||||
for filepath in changed_files:
|
||||
# Check if file is in a role directory
|
||||
if filepath.startswith("ansible/roles/"):
|
||||
parts = filepath.split("/")
|
||||
if len(parts) >= _MIN_ROLE_PATH_PARTS:
|
||||
roles.add(parts[2])
|
||||
|
||||
# Check if file is a playbook that maps to roles
|
||||
if filepath in PLAYBOOK_ROLE_MAP:
|
||||
roles.update(PLAYBOOK_ROLE_MAP[filepath])
|
||||
|
||||
# Check shared infrastructure — triggers all roles
|
||||
for shared in SHARED_PATHS:
|
||||
if filepath.startswith(shared):
|
||||
return set(ROLE_TARGET_MAP.keys())
|
||||
|
||||
return roles
|
||||
|
||||
|
||||
def roles_to_targets(roles: set[str]) -> list[str]:
|
||||
"""Convert role names to make targets."""
|
||||
targets = []
|
||||
for role in sorted(roles):
|
||||
target = ROLE_TARGET_MAP.get(role)
|
||||
if target:
|
||||
targets.append(target)
|
||||
return targets
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--base",
|
||||
default="origin/master",
|
||||
help="Base ref to compare against (default: origin/master).",
|
||||
)
|
||||
@click.option(
|
||||
"--print-targets",
|
||||
is_flag=True,
|
||||
help="Print make targets (e.g. molecule-docker-base).",
|
||||
)
|
||||
@click.option(
|
||||
"--print-roles",
|
||||
is_flag=True,
|
||||
help="Print role names (default if no --print-targets).",
|
||||
)
|
||||
def main(base: str, print_targets: bool, print_roles: bool) -> None:
|
||||
"""Detect which Ansible roles changed and output molecule scenarios."""
|
||||
changed_files = get_changed_files(base)
|
||||
if not changed_files:
|
||||
click.echo("No changed files detected.", err=True)
|
||||
return
|
||||
|
||||
roles = detect_changed_roles(changed_files)
|
||||
if not roles:
|
||||
click.echo("No molecule scenarios affected by changes.", err=True)
|
||||
return
|
||||
|
||||
if print_targets:
|
||||
for target in roles_to_targets(roles):
|
||||
click.echo(target)
|
||||
else:
|
||||
for role in sorted(roles):
|
||||
click.echo(role)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -0,0 +1,333 @@
|
||||
#!/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()
|
||||
@@ -10,12 +10,6 @@ If the host socket is not available, it tries the rootless socket, then
|
||||
starts a local ``dockerd`` with the vfs storage driver (requires
|
||||
privileged container).
|
||||
|
||||
When the host socket IS available but has limited disk space (e.g. an
|
||||
inner DinD daemon writing to a 38 GB container overlay), the script
|
||||
prefers a rootless socket that has more available space. This prevents
|
||||
"no space left on device" errors during molecule tests that pull images
|
||||
and create containers via the Docker daemon.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.molecule.start_docker [--timeout 30]
|
||||
@@ -23,10 +17,8 @@ Usage::
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import glob
|
||||
import os
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -40,16 +32,6 @@ DEFAULT_TIMEOUT = 30
|
||||
DOCKER_SOCK = "/var/run/docker.sock"
|
||||
# Rootless socket fallback (e.g. /run/user/994/docker.sock)
|
||||
ROOTLESS_SOCK = f"/run/user/{os.getuid()}/docker.sock"
|
||||
# Host Docker socket mounted by gitea_runner config (see runner config
|
||||
# ``options: "-v /run/user/<uid>/docker.sock:/run/host-docker.sock"``).
|
||||
# This gives CI containers access to the host's rootless Docker daemon,
|
||||
# which has the full host filesystem (e.g. 455 GB) instead of the
|
||||
# container's limited overlay (e.g. 38 GB).
|
||||
HOST_DOCKER_SOCK = "/run/host-docker.sock"
|
||||
# Minimum free bytes for a Docker daemon to be considered usable.
|
||||
# Below this, image pulls and container creation will fail with ENOSPC.
|
||||
# 20 GB leaves room for molecule-test-base (~500 MB) + a few containers.
|
||||
MIN_FREE_BYTES = 20 * 1024**3 # 20 GB
|
||||
|
||||
|
||||
def is_docker_ready() -> bool:
|
||||
@@ -64,46 +46,6 @@ def is_docker_ready() -> bool:
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def _get_docker_free_bytes() -> int:
|
||||
"""Get free disk space (bytes) at the Docker daemon's data root.
|
||||
|
||||
Returns 0 if the daemon is not reachable or the data root cannot be
|
||||
determined.
|
||||
"""
|
||||
docker_host = os.environ.get("DOCKER_HOST", f"unix://{DOCKER_SOCK}")
|
||||
try:
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
[
|
||||
"docker",
|
||||
"info",
|
||||
"--format",
|
||||
"{{.DockerRootDir}}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
env={**os.environ, "DOCKER_HOST": docker_host},
|
||||
)
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
return 0
|
||||
data_root = result.stdout.strip()
|
||||
if not os.path.exists(data_root):
|
||||
return 0
|
||||
return shutil.disk_usage(data_root).free
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||
return 0
|
||||
|
||||
|
||||
def _try_socket(sock_path: str) -> bool:
|
||||
"""Set DOCKER_HOST to *sock_path* and check if the daemon is ready.
|
||||
|
||||
Returns ``True`` if the daemon responds, ``False`` otherwise.
|
||||
"""
|
||||
os.environ["DOCKER_HOST"] = f"unix://{sock_path}"
|
||||
return is_docker_ready()
|
||||
|
||||
|
||||
def _diagnose_socket() -> None:
|
||||
"""Print diagnostic info about the Docker socket."""
|
||||
click.echo(f"DOCKER_HOST = {os.environ.get('DOCKER_HOST', '(not set)')}")
|
||||
@@ -155,177 +97,50 @@ def _diagnose_socket() -> None:
|
||||
def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
|
||||
"""Ensure Docker is ready for molecule tests.
|
||||
|
||||
Tries sockets in this order, preferring ones with enough disk space:
|
||||
|
||||
1. Host rootless socket (``/run/host-docker.sock``) — mounted by the
|
||||
gitea runner config, has access to the host's full filesystem
|
||||
(e.g. 455 GB). Preferred over the inner dockerd.
|
||||
2. Default socket (``/var/run/docker.sock``) — may be an inner dockerd
|
||||
started by the CI image (v29.5.3) with data root on the container's
|
||||
limited overlay (e.g. 38 GB, often 100 % full).
|
||||
3. Other rootless sockets (``/run/user/*/docker.sock``).
|
||||
4. Local ``dockerd`` with vfs storage driver — last resort.
|
||||
First tries the host socket. If that works, sets ``DOCKER_HOST`` and
|
||||
returns immediately. If not, tries the rootless socket. If neither
|
||||
works, starts a local ``dockerd`` with vfs storage driver (requires
|
||||
privileged container).
|
||||
|
||||
Returns ``True`` if Docker is ready, ``False`` if it failed to
|
||||
start within the timeout.
|
||||
"""
|
||||
# Point Docker CLI and Python library to the socket explicitly
|
||||
os.environ["DOCKER_HOST"] = f"unix://{DOCKER_SOCK}"
|
||||
|
||||
# Diagnose socket state
|
||||
click.echo("--- Docker socket diagnostics ---")
|
||||
_diagnose_socket()
|
||||
click.echo("--- End diagnostics ---")
|
||||
|
||||
# Collect candidate sockets in priority order.
|
||||
# The host's rootless Docker socket (mounted at /run/host-docker.sock
|
||||
# by the gitea runner config) is preferred — it has access to the
|
||||
# host's full filesystem instead of the container's limited overlay.
|
||||
candidates: list[str] = []
|
||||
if os.path.exists(HOST_DOCKER_SOCK):
|
||||
candidates.append(HOST_DOCKER_SOCK)
|
||||
if os.path.exists(DOCKER_SOCK):
|
||||
candidates.append(DOCKER_SOCK)
|
||||
if os.path.exists(ROOTLESS_SOCK):
|
||||
candidates.append(ROOTLESS_SOCK)
|
||||
for sock in sorted(glob.glob("/run/user/*/docker.sock")):
|
||||
if sock not in candidates:
|
||||
candidates.append(sock)
|
||||
# Check if host Docker is already available
|
||||
if is_docker_ready():
|
||||
click.echo(_("Docker daemon already running"))
|
||||
return True
|
||||
|
||||
# Try each candidate socket — prefer one with enough free space
|
||||
for sock in candidates:
|
||||
click.echo(f"Trying socket: {sock}")
|
||||
if not _try_socket(sock):
|
||||
# Try rootless socket (e.g. /run/user/994/docker.sock)
|
||||
click.echo(f"Trying rootless socket: {ROOTLESS_SOCK}")
|
||||
os.environ["DOCKER_HOST"] = f"unix://{ROOTLESS_SOCK}"
|
||||
if os.path.exists(ROOTLESS_SOCK) and is_docker_ready():
|
||||
click.echo(_("Docker daemon already running"))
|
||||
return True
|
||||
|
||||
# Scan for any rootless sockets at other UIDs
|
||||
for sock in sorted(glob.glob("/run/user/*/docker.sock")):
|
||||
if sock == ROOTLESS_SOCK:
|
||||
continue
|
||||
free_bytes = _get_docker_free_bytes()
|
||||
free_gb = free_bytes / 1024**3
|
||||
click.echo(f" Docker daemon ready (free space: {free_gb:.1f} GB)")
|
||||
if free_bytes >= MIN_FREE_BYTES:
|
||||
click.echo(f"Trying alternative rootless socket: {sock}")
|
||||
os.environ["DOCKER_HOST"] = f"unix://{sock}"
|
||||
if is_docker_ready():
|
||||
click.echo(_("Docker daemon already running"))
|
||||
return True
|
||||
# If free_bytes is 0, the Docker root dir is on the host filesystem
|
||||
# (not accessible from inside the container). This is expected for
|
||||
# the host's rootless Docker — it has the full host disk.
|
||||
# Only trust this for /run/host-docker.sock (known host socket).
|
||||
# For other sockets (e.g. inner dockerd), free_bytes == 0 means
|
||||
# the data root path doesn't exist inside the container — the
|
||||
# inner dockerd may be using the container's full overlay.
|
||||
if free_bytes == 0 and sock == HOST_DOCKER_SOCK:
|
||||
click.echo("Host rootless Docker root dir not accessible from container, using it")
|
||||
return True
|
||||
# If free_bytes is 0 and there are no dockerd processes inside the
|
||||
# container, the socket is the host's Docker (mounted from outside).
|
||||
# The data root is on the host filesystem and has plenty of space.
|
||||
if free_bytes == 0 and sock == DOCKER_SOCK:
|
||||
has_inner_dockerd = False
|
||||
with contextlib.suppress(Exception):
|
||||
pgrep_result = subprocess.run( # nosec B603 B607
|
||||
["pgrep", "-f", "dockerd"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
has_inner_dockerd = pgrep_result.returncode == 0
|
||||
if not has_inner_dockerd:
|
||||
click.echo("No inner dockerd found, socket is host Docker (data root on host), using it")
|
||||
return True
|
||||
click.echo(f" Insufficient space ({free_gb:.1f} GB), trying next...")
|
||||
|
||||
# No socket with sufficient space found.
|
||||
# Don't fall back to the low-space inner dockerd — it will fail
|
||||
# on image pulls. Instead, kill the inner dockerd, clean up its
|
||||
# data root to free space, and start a new dockerd using the
|
||||
# freed space on the container's overlay.
|
||||
click.echo(_("Host Docker not available, starting local dockerd..."))
|
||||
|
||||
# Kill the inner dockerd (started by the CI image) to free its
|
||||
# data root and socket. The inner dockerd uses the container's
|
||||
# overlay (38G, often 100% full). Killing it frees up the
|
||||
# socket and any space used by its containers/volumes.
|
||||
# Use SIGKILL (-9) since the inner dockerd may not respond to SIGTERM.
|
||||
# Try multiple approaches to ensure the inner dockerd is killed.
|
||||
with contextlib.suppress(Exception):
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["pgrep", "-af", "dockerd"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.stdout.strip():
|
||||
click.echo(f" dockerd processes before kill: {result.stdout.strip()}")
|
||||
|
||||
for pattern in ["dockerd", "dockerd-entrypoint.sh", "containerd"]:
|
||||
with contextlib.suppress(Exception):
|
||||
subprocess.run( # nosec B603 B607
|
||||
["pkill", "-9", "-f", pattern],
|
||||
check=False,
|
||||
timeout=5,
|
||||
)
|
||||
time.sleep(3)
|
||||
|
||||
# Check if dockerd processes are still alive
|
||||
with contextlib.suppress(Exception):
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["pgrep", "-af", "dockerd"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.stdout.strip():
|
||||
click.echo(f" dockerd processes after kill: {result.stdout.strip()}")
|
||||
# Try killing by PID directly
|
||||
for pid_str in result.stdout.split("\n"):
|
||||
pid = pid_str.split()[0] if pid_str.strip() else ""
|
||||
if pid:
|
||||
with contextlib.suppress(Exception):
|
||||
os.kill(int(pid), 9)
|
||||
time.sleep(2)
|
||||
|
||||
# Verify the inner dockerd is actually dead. If we can still
|
||||
# connect to /var/run/docker.sock, the old daemon is still running
|
||||
# and we need to use a different socket path.
|
||||
old_daemon_alive = False
|
||||
with contextlib.suppress(Exception):
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["docker", "info"],
|
||||
env={**os.environ, "DOCKER_HOST": f"unix://{DOCKER_SOCK}"},
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
old_daemon_alive = result.returncode == 0
|
||||
|
||||
if old_daemon_alive:
|
||||
click.echo(" Inner dockerd still alive, using alternate socket")
|
||||
local_sock = "/dev/shm/docker.sock" # nosec B108
|
||||
else:
|
||||
local_sock = DOCKER_SOCK
|
||||
|
||||
# Clean up the inner dockerd's data root to free space.
|
||||
# The inner dockerd stores images, containers, and volumes here.
|
||||
# Removing them frees up ~2.4GB on the container's overlay.
|
||||
inner_data_root = "/home/grm-ci-runner-*/.local/share/docker"
|
||||
rm_paths = " ".join(f"{inner_data_root}/{d}" for d in ("overlay2", "image", "volumes", "containers"))
|
||||
with contextlib.suppress(Exception):
|
||||
subprocess.run( # nosec B603 B607
|
||||
["sh", "-c", f"rm -rf {rm_paths}"],
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
# Use a fresh data root. If the inner dockerd is dead, use the
|
||||
# container's overlay (38G, with freed space). If the inner
|
||||
# dockerd is still alive, use /dev/shm (16G tmpfs) — the overlay
|
||||
# is still full because the inner dockerd's data can't be cleaned.
|
||||
docker_data_root = "/dev/shm/docker" if old_daemon_alive else "/tmp/docker-data" # nosec B108
|
||||
|
||||
# Remove stale socket if present
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(local_sock)
|
||||
|
||||
os.environ["DOCKER_HOST"] = f"unix://{local_sock}"
|
||||
# Reset DOCKER_HOST to host socket for local dockerd
|
||||
os.environ["DOCKER_HOST"] = f"unix://{DOCKER_SOCK}"
|
||||
|
||||
# Start local dockerd (requires privileged container)
|
||||
# Use /tmp/docker-data as data root on the container's overlay.
|
||||
# The inner dockerd's data root has been cleaned up, freeing ~2.4GB.
|
||||
# vfs storage driver is used since overlay2 may not work inside
|
||||
# a Docker-in-Docker container without --privileged.
|
||||
log_file = tempfile.NamedTemporaryFile( # noqa: SIM115
|
||||
mode="w", suffix="dockerd.log", delete=False
|
||||
)
|
||||
@@ -335,13 +150,8 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
|
||||
"dockerd",
|
||||
"--storage-driver",
|
||||
"vfs",
|
||||
"--data-root",
|
||||
docker_data_root,
|
||||
"--iptables=false",
|
||||
"--ip6tables=false",
|
||||
"--bridge=none",
|
||||
"-H",
|
||||
f"unix://{local_sock}",
|
||||
f"unix://{DOCKER_SOCK}",
|
||||
],
|
||||
stdout=log_file,
|
||||
stderr=subprocess.STDOUT,
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
"""Check Ansible tasks for missing no_log on secret-handling tasks.
|
||||
|
||||
ansible-lint's built-in ``no-log-password`` rule only fires when a module
|
||||
parameter is literally named ``*password*`` and there's a loop. It does
|
||||
NOT catch:
|
||||
|
||||
- Shell/command tasks that interpolate ``{{ _secrets.* }}`` or
|
||||
``{{ *password* }}`` variables
|
||||
- Template/copy tasks that render secret values without ``no_log``
|
||||
|
||||
This script fills that gap by scanning all Ansible task files for
|
||||
variables that look like secrets (``_secrets.*``, ``*password*``,
|
||||
``*secret*``, ``*token*``, ``*api_key*``) and verifying that the task
|
||||
has ``no_log`` set to a non-False value.
|
||||
|
||||
Usage::
|
||||
|
||||
python -m devx.tools.check_ansible_no_log
|
||||
python -m devx.tools.check_ansible_no_log --path ansible/roles/my_role
|
||||
python -m devx.tools.check_ansible_no_log --ansible-dir ansible/roles
|
||||
|
||||
Exit code 0 if all secret-handling tasks have no_log, 1 otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path.cwd()
|
||||
DEFAULT_ANSIBLE_DIR = REPO_ROOT / "ansible"
|
||||
|
||||
# Patterns that indicate a task is handling secrets.
|
||||
# We only match Jinja-interpolated variables ({{ ... }}) to avoid false
|
||||
# positives from field names like "password" in module params or task names.
|
||||
SECRET_PATTERNS = [
|
||||
# {{ _secrets.anything }} or {{ _secrets['anything'] }}
|
||||
re.compile(r"\{\{[^}]*_secrets\.", re.IGNORECASE),
|
||||
# {{ anything_password }} but NOT the word "password" in a string literal
|
||||
re.compile(r"\{\{[^}]*password", re.IGNORECASE),
|
||||
# {{ anything_secret }}
|
||||
re.compile(r"\{\{[^}]*_secret\b", re.IGNORECASE),
|
||||
# {{ anything_api_key }}
|
||||
re.compile(r"\{\{[^}]*api_key", re.IGNORECASE),
|
||||
# {{ anything_token }} (but not loop tokens like {{ loop_token }})
|
||||
re.compile(r"\{\{[^}]*(?:vault_token|auth_token|access_token|bot_token)", re.IGNORECASE),
|
||||
]
|
||||
|
||||
# Task keys whose values might contain secret references
|
||||
TASK_VALUE_KEYS = {
|
||||
"shell",
|
||||
"command",
|
||||
"ansible.builtin.shell",
|
||||
"ansible.builtin.command",
|
||||
"ansible.builtin.template",
|
||||
"ansible.builtin.copy",
|
||||
"ansible.builtin.debug",
|
||||
"template",
|
||||
"copy",
|
||||
"debug",
|
||||
"cmd",
|
||||
"msg",
|
||||
"content",
|
||||
}
|
||||
|
||||
# Keys that are NOT secret-bearing (task metadata, not values)
|
||||
NON_VALUE_KEYS = {
|
||||
"name",
|
||||
"when",
|
||||
"loop",
|
||||
"loop_control",
|
||||
"changed_when",
|
||||
"failed_when",
|
||||
"no_log",
|
||||
"register",
|
||||
"tags",
|
||||
"vars",
|
||||
"become",
|
||||
"become_user",
|
||||
"delegate_to",
|
||||
"run_once",
|
||||
"environment",
|
||||
"with_items",
|
||||
"with_dict",
|
||||
"with_list",
|
||||
}
|
||||
|
||||
|
||||
def _contains_secret(value: object) -> bool:
|
||||
"""Recursively check if a value contains secret-like variable references."""
|
||||
if isinstance(value, str):
|
||||
return any(p.search(value) for p in SECRET_PATTERNS)
|
||||
if isinstance(value, dict):
|
||||
return any(_contains_secret(v) for v in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(_contains_secret(item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def _has_no_log(task: dict) -> bool:
|
||||
"""Check if a task has no_log set to a non-False value."""
|
||||
no_log = task.get("no_log", False)
|
||||
# Jinja expressions (e.g. "{{ not debug_mode }}") count as set
|
||||
return no_log is not False and no_log is not None
|
||||
|
||||
|
||||
def _check_task(task: dict, file_path: Path, task_num: int) -> list[str]:
|
||||
"""Check a single task for missing no_log on secret values.
|
||||
|
||||
Returns a list of violation messages (empty if OK).
|
||||
"""
|
||||
violations: list[str] = []
|
||||
|
||||
# Skip tasks that already have no_log
|
||||
if _has_no_log(task):
|
||||
return violations
|
||||
|
||||
# Check all string values in the task for secret references
|
||||
has_secrets = False
|
||||
for key, value in task.items():
|
||||
if key in NON_VALUE_KEYS:
|
||||
continue
|
||||
# Check action module params (shell, command, copy, template, etc.)
|
||||
if _contains_secret(value):
|
||||
has_secrets = True
|
||||
break
|
||||
|
||||
if has_secrets:
|
||||
task_name = task.get("name", "<unnamed>")
|
||||
violations.append(
|
||||
f"{file_path}:{task_num}: Task '{task_name}' references secrets "
|
||||
f"but has no no_log. Add `no_log: true` or "
|
||||
f'`no_log: "{{{{ not (debug_mode | default(false) | bool) }}}}"` '
|
||||
f"to prevent credential leakage in Ansible output."
|
||||
)
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def check_directory(ansible_dir: Path) -> list[str]:
|
||||
"""Check all Ansible task files in a directory tree."""
|
||||
all_violations: list[str] = []
|
||||
|
||||
# Find all task files
|
||||
task_files = list(ansible_dir.rglob("tasks/*.yml"))
|
||||
task_files += list(ansible_dir.rglob("tasks/*.yaml"))
|
||||
# Also check playbook files
|
||||
task_files += list(ansible_dir.glob("playbooks/*.yml"))
|
||||
|
||||
for task_file in sorted(task_files):
|
||||
# Skip molecule test files
|
||||
if "molecule" in task_file.parts:
|
||||
continue
|
||||
|
||||
try:
|
||||
with task_file.open() as f:
|
||||
docs = list(yaml.safe_load_all(f))
|
||||
except (yaml.YAMLError, OSError):
|
||||
continue
|
||||
|
||||
for doc in docs:
|
||||
if not doc:
|
||||
continue
|
||||
|
||||
# Task files are bare lists of tasks; playbook files are
|
||||
# lists of plays (each play is a dict with 'hosts' key)
|
||||
if isinstance(doc, list):
|
||||
is_plays = isinstance(doc[0], dict) and "hosts" in doc[0]
|
||||
if not is_plays:
|
||||
for i, task in enumerate(doc):
|
||||
if not isinstance(task, dict):
|
||||
continue
|
||||
all_violations.extend(_check_task(task, task_file, i + 1))
|
||||
continue
|
||||
plays = doc
|
||||
elif isinstance(doc, dict):
|
||||
plays = [doc]
|
||||
else:
|
||||
continue
|
||||
|
||||
for play in plays:
|
||||
if not isinstance(play, dict):
|
||||
continue
|
||||
for task_section in ("tasks", "pre_tasks", "post_tasks", "handlers"):
|
||||
tasks = play.get(task_section, [])
|
||||
if not isinstance(tasks, list):
|
||||
continue
|
||||
for i, task in enumerate(tasks):
|
||||
if not isinstance(task, dict):
|
||||
continue
|
||||
all_violations.extend(_check_task(task, task_file, i + 1))
|
||||
|
||||
return all_violations
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--path",
|
||||
type=click.Path(exists=True, path_type=Path),
|
||||
help="Check a specific file or directory (default: ansible/).",
|
||||
)
|
||||
@click.option(
|
||||
"--ansible-dir",
|
||||
type=click.Path(exists=True, path_type=Path),
|
||||
default=None,
|
||||
help="Override the default ansible directory (default: ansible/).",
|
||||
)
|
||||
def main(path: Path | None, ansible_dir: Path | None) -> None:
|
||||
"""Check that Ansible tasks handling secrets have no_log set."""
|
||||
target = path or ansible_dir or DEFAULT_ANSIBLE_DIR
|
||||
if not target.is_dir():
|
||||
click.echo(f"Error: {target} is not a directory", err=True)
|
||||
sys.exit(2)
|
||||
|
||||
violations = check_directory(target)
|
||||
|
||||
if violations:
|
||||
click.echo(f"Found {len(violations)} task(s) handling secrets without no_log:\n")
|
||||
for v in violations:
|
||||
click.echo(f" {v}")
|
||||
click.echo(f"\nTotal: {len(violations)} violation(s).")
|
||||
sys.exit(1)
|
||||
|
||||
click.echo(f"[check-ansible-no-log] All secret-handling tasks have no_log. ({target})")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,176 +0,0 @@
|
||||
"""Check Ansible tasks for ``state: absent`` on database data directories.
|
||||
|
||||
This is a static analysis lint check that runs in CI (``make lint-ci``)
|
||||
to prevent the class of bug that caused the 2026-07-22 production outage
|
||||
(ADR-0028): a ``state: absent`` on a PostgreSQL data directory path that
|
||||
fired on every deploy and wiped the ZITADEL database.
|
||||
|
||||
The existing unit test ``scripts/tests/test_no_zitadel_db_wipe.py`` covers
|
||||
the same concern as a regression test. This lint check runs earlier in
|
||||
the pipeline (before tests) and covers ALL roles and playbooks, not just
|
||||
the ZITADEL role.
|
||||
|
||||
Allowed contexts (where DB recreation is legitimate):
|
||||
- PostgreSQL major version upgrades (``upgrade-postgres``, ``PG_VERSION``)
|
||||
- Explicit ``# lint:allow-state-absent`` comment on the task
|
||||
|
||||
Usage::
|
||||
|
||||
python -m devx.tools.check_ansible_no_state_absent_on_db
|
||||
python -m devx.tools.check_ansible_no_state_absent_on_db --path ansible/roles/zitadel/tasks/main.yml
|
||||
|
||||
Exit code 0 if no violations found, 1 otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
REPO_ROOT = Path.cwd()
|
||||
DEFAULT_ANSIBLE_DIRS: list[Path] = [
|
||||
REPO_ROOT / "ansible" / "playbooks",
|
||||
REPO_ROOT / "ansible" / "roles",
|
||||
]
|
||||
|
||||
# Database data directory path patterns.
|
||||
# These match the DIRECTORY path, not individual files within it.
|
||||
# Removing a stale config file (e.g. postgresql.conf) is safe; removing
|
||||
# the entire data directory is not.
|
||||
DB_PATH_PATTERNS = (
|
||||
re.compile(r"postgres/zitadel-db", re.IGNORECASE),
|
||||
re.compile(r"postgres/\w+-db", re.IGNORECASE),
|
||||
re.compile(r"/var/lib/postgresql/data", re.IGNORECASE),
|
||||
re.compile(r"/var/lib/postgresql/data/\w+-db", re.IGNORECASE),
|
||||
)
|
||||
|
||||
# Destructive operations
|
||||
DESTRUCTIVE_PATTERNS = (
|
||||
re.compile(r"state:\s*absent", re.IGNORECASE),
|
||||
re.compile(r"rm\s+-rf.*\bdb\b", re.IGNORECASE),
|
||||
)
|
||||
|
||||
# Allowed contexts where DB recreation is legitimate
|
||||
ALLOWED_CONTEXT_KEYWORDS = (
|
||||
"upgrade-postgres",
|
||||
"PG_VERSION",
|
||||
"pg_version",
|
||||
)
|
||||
|
||||
# Comment marker to explicitly allow state: absent on a specific task
|
||||
ALLOW_MARKER = "lint:allow-state-absent"
|
||||
|
||||
|
||||
def _find_task_files(base: Path) -> list[Path]:
|
||||
"""Find all YAML task files under a base directory, skipping molecule."""
|
||||
if base.is_file() and base.suffix in (".yml", ".yaml"):
|
||||
return [base]
|
||||
if not base.is_dir():
|
||||
return []
|
||||
files: list[Path] = []
|
||||
for f in sorted(base.rglob("*.yml")) + sorted(base.rglob("*.yaml")):
|
||||
if "molecule" in f.parts:
|
||||
continue
|
||||
files.append(f)
|
||||
return files
|
||||
|
||||
|
||||
def _check_file(filepath: Path, repo_root: Path) -> list[str]:
|
||||
"""Check a YAML file for state: absent on DB data directory paths.
|
||||
|
||||
Returns a list of violation messages (empty if clean).
|
||||
"""
|
||||
try:
|
||||
content = filepath.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return []
|
||||
|
||||
# Quick check: if no DB path pattern appears anywhere, skip
|
||||
if not any(p.search(content) for p in DB_PATH_PATTERNS):
|
||||
return []
|
||||
|
||||
try:
|
||||
display_path = filepath.relative_to(repo_root)
|
||||
except ValueError:
|
||||
display_path = filepath
|
||||
|
||||
violations: list[str] = []
|
||||
lines = content.splitlines()
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
for db_pattern in DB_PATH_PATTERNS:
|
||||
if not db_pattern.search(line):
|
||||
continue
|
||||
|
||||
# Check surrounding context (±5 lines) for destructive operations
|
||||
context_start = max(0, i - 5)
|
||||
context_end = min(len(lines), i + 6)
|
||||
context = "\n".join(lines[context_start:context_end])
|
||||
|
||||
# Skip if in an allowed context (PG upgrade)
|
||||
if any(kw in context for kw in ALLOWED_CONTEXT_KEYWORDS):
|
||||
continue
|
||||
|
||||
# Skip if the allow marker comment is in the context
|
||||
if ALLOW_MARKER in context:
|
||||
continue
|
||||
|
||||
for dp in DESTRUCTIVE_PATTERNS:
|
||||
if dp.search(context):
|
||||
violations.append(
|
||||
f"{display_path}:{i + 1} — destructive operation "
|
||||
f"({dp.pattern!r}) near DB data directory path "
|
||||
f"({db_pattern.pattern!r}). "
|
||||
f"Database directories must never be wiped automatically (ADR-0028). "
|
||||
f"If this is legitimate (e.g. PG upgrade), add "
|
||||
f"#{ALLOW_MARKER} to the task."
|
||||
)
|
||||
break
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
@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 no Ansible task uses state: absent on a DB data directory."""
|
||||
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_violations: list[str] = []
|
||||
for f in files:
|
||||
all_violations.extend(_check_file(f, REPO_ROOT))
|
||||
|
||||
if all_violations:
|
||||
click.echo("[check-ansible-no-state-absent-on-db] FAIL: destructive operations on DB paths:")
|
||||
for v in all_violations:
|
||||
click.echo(f" - {v}")
|
||||
click.echo(f"\nTotal: {len(all_violations)} violation(s).")
|
||||
click.echo("Database data directories must never be wiped automatically (ADR-0028).")
|
||||
sys.exit(1)
|
||||
else:
|
||||
click.echo("[check-ansible-no-state-absent-on-db] OK: no destructive operations on DB paths.")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,345 +0,0 @@
|
||||
"""Check Ansible tasks for dangerous patterns that mask failures.
|
||||
|
||||
This check addresses the gap identified in the testing-strategy audit:
|
||||
the automated PR review only checks Python files, and ``ansible-lint``
|
||||
runs at ``profile: basic`` which does not catch dangerous patterns like:
|
||||
|
||||
- ``|| true`` on tasks that are NOT cleanup/idempotency operations
|
||||
- ``failed_when: false`` on critical tasks (e.g. DB operations)
|
||||
- ``2>/dev/null`` on tasks where stderr contains important diagnostics
|
||||
|
||||
Most ``|| true`` and ``2>/dev/null`` instances in the codebase are
|
||||
legitimate (container removal, journalctl, apt-get, docker prune, SUID
|
||||
removal). This check flags only instances that are NOT in a known-safe
|
||||
context. Tasks can also opt out with a ``# lint:allow-failure-masking``
|
||||
comment.
|
||||
|
||||
Usage::
|
||||
|
||||
python -m devx.tools.check_ansible_patterns
|
||||
python -m devx.tools.check_ansible_patterns --path ansible/roles/app_container/tasks/main.yml
|
||||
|
||||
Exit code 0 if no violations found, 1 otherwise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
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",
|
||||
]
|
||||
|
||||
# Comment marker to explicitly allow a pattern on a specific task
|
||||
ALLOW_MARKER = "lint:allow-failure-masking"
|
||||
|
||||
# Patterns that mask failures when used in shell/command tasks
|
||||
OR_TRUE_PATTERN = re.compile(r"\|\|\s*true\b", re.IGNORECASE)
|
||||
REDIRECT_DEVNULL_PATTERN = re.compile(r"2>/dev/null")
|
||||
|
||||
# Module keys that accept shell/command strings
|
||||
SHELL_MODULE_KEYS = frozenset(
|
||||
{
|
||||
"shell",
|
||||
"command",
|
||||
"ansible.builtin.shell",
|
||||
"ansible.builtin.command",
|
||||
"cmd",
|
||||
"ansible.builtin.raw",
|
||||
"raw",
|
||||
}
|
||||
)
|
||||
|
||||
# Task keys whose values might contain shell commands
|
||||
COMMAND_VALUE_KEYS = frozenset(
|
||||
{
|
||||
"shell",
|
||||
"command",
|
||||
"ansible.builtin.shell",
|
||||
"ansible.builtin.command",
|
||||
"cmd",
|
||||
"raw",
|
||||
"ansible.builtin.raw",
|
||||
}
|
||||
)
|
||||
|
||||
# Legitimate contexts where || true or 2>/dev/null are safe.
|
||||
# These are command prefixes or task names that indicate cleanup/idempotency.
|
||||
LEGITIMATE_COMMAND_PREFIXES = (
|
||||
# Container/process removal (may not exist)
|
||||
"docker rm",
|
||||
"docker stop",
|
||||
"docker rmi",
|
||||
"docker network rm",
|
||||
"docker volume rm",
|
||||
"pkill",
|
||||
"kill",
|
||||
# Cleanup commands that are expected to sometimes fail
|
||||
"journalctl --vacuum",
|
||||
"apt-get clean",
|
||||
"apt-get autoremove",
|
||||
"docker image prune",
|
||||
"docker container prune",
|
||||
"docker volume prune",
|
||||
"docker builder prune",
|
||||
"find / -name",
|
||||
# SUID removal (binaries may not exist)
|
||||
"chmod",
|
||||
"rm -f",
|
||||
# Network connection checks (may fail if not connected)
|
||||
"docker network connect",
|
||||
# Prometheus snapshot API (may fail if no snapshot)
|
||||
"curl.*api/v2/admin/tsdb/snapshot",
|
||||
)
|
||||
|
||||
LEGITIMATE_TASK_NAME_KEYWORDS = (
|
||||
"remove",
|
||||
"cleanup",
|
||||
"clean up",
|
||||
"prune",
|
||||
"purge",
|
||||
"disconnect",
|
||||
"stop",
|
||||
"kill",
|
||||
"strip suid",
|
||||
"suid",
|
||||
"vacuum",
|
||||
"ensure.*absent",
|
||||
"may not exist",
|
||||
"if exists",
|
||||
"optional",
|
||||
"best effort",
|
||||
"no-op",
|
||||
"noop",
|
||||
"idempotent",
|
||||
"sync",
|
||||
)
|
||||
|
||||
# Tasks with failed_when: false that are critical and should not mask failures.
|
||||
# Only flag operations that SHOULD fail loudly — writing secrets, provisioning
|
||||
# users, creating OIDC apps. Do NOT flag stop/start/check/wait/migrate/restore
|
||||
# operations where failed_when: false is legitimate (container may not exist,
|
||||
# may already be stopped, etc.).
|
||||
CRITICAL_TASK_KEYWORDS = (
|
||||
"password",
|
||||
"secret",
|
||||
"provision",
|
||||
"oidc",
|
||||
)
|
||||
|
||||
# Task name keywords that indicate failed_when: false is legitimate
|
||||
LEGITIMATE_FAILED_WHEN_KEYWORDS = (
|
||||
"stop",
|
||||
"start",
|
||||
"check",
|
||||
"wait",
|
||||
"migrate",
|
||||
"restart",
|
||||
"rebuild",
|
||||
"restore",
|
||||
"remove",
|
||||
"cleanup",
|
||||
"sync",
|
||||
"download",
|
||||
"extract",
|
||||
"verify",
|
||||
)
|
||||
|
||||
|
||||
def _is_legitimate_or_true(command_str: str, task_name: str) -> bool:
|
||||
"""Check if a || true in a command is in a legitimate context."""
|
||||
# Check task name for legitimate keywords
|
||||
name_lower = task_name.lower()
|
||||
if any(re.search(kw, name_lower) for kw in LEGITIMATE_TASK_NAME_KEYWORDS):
|
||||
return True
|
||||
|
||||
# Check command prefix for legitimate patterns
|
||||
cmd_lower = command_str.lower()
|
||||
return any(re.search(prefix, cmd_lower) for prefix in LEGITIMATE_COMMAND_PREFIXES)
|
||||
|
||||
|
||||
def _is_legitimate_devnull(command_str: str, task_name: str) -> bool:
|
||||
"""Check if a 2>/dev/null in a command is in a legitimate context."""
|
||||
# 2>/dev/null is almost always safe — it suppresses stderr noise.
|
||||
# Only flag it if the task is critical (DB, backup, OIDC) AND
|
||||
# there's no || true (which is the more dangerous pattern).
|
||||
return _is_legitimate_or_true(command_str, task_name)
|
||||
|
||||
|
||||
def _check_task(task: dict, filepath: Path, task_num: int, repo_root: Path) -> list[str]:
|
||||
"""Check a single task for dangerous failure-masking patterns."""
|
||||
violations: list[str] = []
|
||||
|
||||
try:
|
||||
display_path = filepath.relative_to(repo_root)
|
||||
except ValueError:
|
||||
display_path = filepath
|
||||
|
||||
task_name = task.get("name", "<unnamed>")
|
||||
|
||||
# Check for the allow marker in the task name
|
||||
# (YAML comments are not preserved by safe_load, so we check the
|
||||
# task name for the marker as a workaround)
|
||||
if ALLOW_MARKER in task_name:
|
||||
return violations
|
||||
|
||||
# Check for || true in command/shell values
|
||||
for key in COMMAND_VALUE_KEYS:
|
||||
value = task.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
value_str = str(value)
|
||||
if OR_TRUE_PATTERN.search(value_str) and not _is_legitimate_or_true(value_str, task_name):
|
||||
violations.append(
|
||||
f"{display_path}:{task_num} — task '{task_name}' uses "
|
||||
f"'|| true' in {key} which may mask real failures. "
|
||||
f"If this is a cleanup/idempotency operation, rename the "
|
||||
f"task to include 'remove'/'cleanup'/'prune' or add "
|
||||
f"#{ALLOW_MARKER} to the task."
|
||||
)
|
||||
|
||||
# Check for failed_when: false on critical tasks
|
||||
failed_when = task.get("failed_when")
|
||||
if failed_when is False:
|
||||
name_lower = task_name.lower()
|
||||
# Skip if the task name indicates a legitimate failed_when: false context
|
||||
is_legitimate = any(kw in name_lower for kw in LEGITIMATE_FAILED_WHEN_KEYWORDS)
|
||||
if not is_legitimate:
|
||||
for kw in CRITICAL_TASK_KEYWORDS:
|
||||
if kw in name_lower:
|
||||
violations.append(
|
||||
f"{display_path}:{task_num} — critical task '{task_name}' "
|
||||
f"has failed_when: false, which masks failures on "
|
||||
f"a {kw}-related operation. Remove failed_when: false "
|
||||
f"or add #{ALLOW_MARKER} if masking is intentional."
|
||||
)
|
||||
break
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def _check_file(filepath: Path, repo_root: Path) -> list[str]:
|
||||
"""Check a YAML file for dangerous failure-masking patterns."""
|
||||
try:
|
||||
content = filepath.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return []
|
||||
|
||||
# Quick check: if no patterns appear, skip
|
||||
if not (
|
||||
OR_TRUE_PATTERN.search(content) or "failed_when: false" in content or REDIRECT_DEVNULL_PATTERN.search(content)
|
||||
):
|
||||
return []
|
||||
|
||||
# Check for allow markers in comments
|
||||
has_allow_marker = ALLOW_MARKER in content
|
||||
|
||||
try:
|
||||
docs = list(yaml.safe_load_all(content))
|
||||
except yaml.YAMLError:
|
||||
return []
|
||||
|
||||
violations: list[str] = []
|
||||
|
||||
for doc in docs:
|
||||
if not doc:
|
||||
continue
|
||||
if isinstance(doc, list):
|
||||
for i, item in enumerate(doc):
|
||||
if isinstance(item, dict):
|
||||
if any(k in item for k in ("tasks", "pre_tasks", "post_tasks", "handlers")):
|
||||
_check_tasks(item, filepath, violations, repo_root)
|
||||
else:
|
||||
violations.extend(_check_task(item, filepath, i + 1, repo_root))
|
||||
block = item.get("block")
|
||||
if isinstance(block, list):
|
||||
for j, bt in enumerate(block):
|
||||
if isinstance(bt, dict):
|
||||
violations.extend(_check_task(bt, filepath, i + j + 1, repo_root))
|
||||
elif isinstance(doc, dict):
|
||||
_check_tasks(doc, filepath, violations, repo_root)
|
||||
|
||||
# Filter out violations if the allow marker is present in the file
|
||||
# (coarse-grained opt-out for files with many legitimate uses)
|
||||
if has_allow_marker:
|
||||
violations = []
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
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."""
|
||||
for section_key in ("tasks", "pre_tasks", "post_tasks", "handlers"):
|
||||
section = doc.get(section_key)
|
||||
if isinstance(section, list):
|
||||
for i, task in enumerate(section):
|
||||
if isinstance(task, dict):
|
||||
errors.extend(_check_task(task, filepath, i + 1, repo_root))
|
||||
block = task.get("block")
|
||||
if isinstance(block, list):
|
||||
for j, bt in enumerate(block):
|
||||
if isinstance(bt, dict):
|
||||
errors.extend(_check_task(bt, filepath, i + j + 1, repo_root))
|
||||
|
||||
|
||||
def _find_task_files(base: Path) -> list[Path]:
|
||||
"""Find all YAML task files under a base directory, skipping molecule."""
|
||||
if base.is_file() and base.suffix in (".yml", ".yaml"):
|
||||
return [base]
|
||||
if not base.is_dir():
|
||||
return []
|
||||
files: list[Path] = []
|
||||
for f in sorted(base.rglob("*.yml")) + sorted(base.rglob("*.yaml")):
|
||||
if "molecule" in f.parts:
|
||||
continue
|
||||
files.append(f)
|
||||
return files
|
||||
|
||||
|
||||
@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 Ansible tasks for dangerous failure-masking patterns."""
|
||||
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_violations: list[str] = []
|
||||
for f in files:
|
||||
all_violations.extend(_check_file(f, REPO_ROOT))
|
||||
|
||||
if all_violations:
|
||||
click.echo("[check-ansible-patterns] FAIL: dangerous failure-masking patterns found:")
|
||||
for v in all_violations:
|
||||
click.echo(f" - {v}")
|
||||
click.echo(f"\nTotal: {len(all_violations)} violation(s).")
|
||||
sys.exit(1)
|
||||
else:
|
||||
click.echo("[check-ansible-patterns] OK: no dangerous failure-masking patterns.")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,292 +0,0 @@
|
||||
"""Validate Jinja2 expressions in Ansible files by rendering them.
|
||||
|
||||
Extracts ``{{ ... }}`` expressions from Ansible YAML files and renders
|
||||
each one with Ansible's Jinja2 environment using mock variables. Catches
|
||||
errors like reversed filter arguments, undefined filters, and syntax
|
||||
errors before pushing to CI.
|
||||
|
||||
The check is intentionally lightweight — it doesn't need real Ansible
|
||||
facts or variables. It provides common mock values (now(), ansible_*,
|
||||
etc.) and renders each expression in isolation. Expressions that fail
|
||||
with undefined variables that aren't in the mock set are skipped (not
|
||||
all variables can be predicted).
|
||||
|
||||
Usage::
|
||||
|
||||
python -m devx.tools.check_jinja_expr
|
||||
python -m devx.tools.check_jinja_expr --path ansible/playbooks/deploy-observability.yml
|
||||
|
||||
Exit code 0 if all renderable expressions pass, 1 if any fail.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from jinja2 import Environment
|
||||
from jinja2.exceptions import TemplateSyntaxError, UndefinedError
|
||||
|
||||
REPO_ROOT = Path.cwd()
|
||||
|
||||
|
||||
def _default_ansible_dirs() -> list[Path]:
|
||||
"""Return the default directories to scan for Ansible files."""
|
||||
return [
|
||||
REPO_ROOT / "ansible" / "playbooks",
|
||||
REPO_ROOT / "ansible" / "roles",
|
||||
]
|
||||
|
||||
|
||||
# Mock context for rendering Jinja expressions.
|
||||
MOCK_CONTEXT: dict[str, object] = {
|
||||
"now": lambda fmt=None: (
|
||||
"2026-01-01T00:00:00+00:00"
|
||||
if fmt
|
||||
else type(
|
||||
"Now",
|
||||
(),
|
||||
{
|
||||
"timestamp": lambda self: 1735689600.0,
|
||||
"strftime": lambda self, fmt: "2026-01-01T00:00:00+00:00",
|
||||
},
|
||||
)()
|
||||
),
|
||||
"ansible_date_time": {
|
||||
"iso8601": "2026-01-01T00:00:00+00:00",
|
||||
"epoch": "1735689600",
|
||||
},
|
||||
"ansible_facts": {
|
||||
"service_mgr": "systemd",
|
||||
"architecture": "x86_64",
|
||||
"distribution_release": "noble",
|
||||
"virtualization_type": "none",
|
||||
"interfaces": ["eth0", "lo"],
|
||||
"hostname": "test-host",
|
||||
},
|
||||
"ansible_host": "10.0.0.1",
|
||||
"env": "staging",
|
||||
"environment": "staging",
|
||||
"customer_id": "test",
|
||||
"zitadel_domain": "zitadel.test",
|
||||
"_env_name": "staging",
|
||||
"_observability_data_root": "/opt",
|
||||
"skip_zitadel_stack": False,
|
||||
"skip_htpasswd": False,
|
||||
"skip_observability_stack": False,
|
||||
"backup_enabled": True,
|
||||
"app_filter": "",
|
||||
"app_domain": "test.example.com",
|
||||
"oidc_client_id": "test-client-id",
|
||||
"oidc_client_secret": "test-secret", # nosec B105 — mock value for Jinja rendering, not a real secret
|
||||
"s3_backup_bucket": "test-bucket",
|
||||
"s3_endpoint": "https://s3.test",
|
||||
"s3_access_key": "test-key",
|
||||
"s3_secret_key": "test-secret", # nosec B105 — mock value for Jinja rendering, not a real secret
|
||||
}
|
||||
|
||||
# Pattern to find {{ ... }} expressions (non-greedy, single-line).
|
||||
EXPR_PATTERN = re.compile(r"\{\{(.*?)\}\}", re.DOTALL)
|
||||
|
||||
|
||||
def _find_yaml_files(path: Path) -> list[Path]:
|
||||
"""Find Ansible YAML files (tasks, playbooks, handlers) in a path."""
|
||||
if path.is_file():
|
||||
return [path]
|
||||
files: list[Path] = []
|
||||
for pattern in ["**/*.yml", "**/*.yaml"]:
|
||||
files.extend(path.glob(pattern))
|
||||
# Exclude molecule scenarios — they have their own variables.
|
||||
return [f for f in files if "molecule" not in f.parts]
|
||||
|
||||
|
||||
def _extract_expressions(content: str) -> list[str]:
|
||||
"""Extract Jinja expressions from file content.
|
||||
|
||||
Filters out Go template syntax (``{{.Field}}``) used in docker
|
||||
inspect --format strings, and single-character fragments from
|
||||
quoted strings that aren't real Jinja expressions.
|
||||
"""
|
||||
expressions = []
|
||||
for match in EXPR_PATTERN.finditer(content):
|
||||
raw = match.group(1)
|
||||
# Skip multi-line expressions (often have YAML formatting artifacts).
|
||||
if "\n" in raw:
|
||||
continue
|
||||
expr = raw.strip()
|
||||
# Skip empty, control flow, and single-char fragments.
|
||||
if not expr or expr.startswith("%") or len(expr) <= 1:
|
||||
continue
|
||||
# Skip Go template syntax (docker inspect --format).
|
||||
if expr.startswith(".") or "println" in expr:
|
||||
continue
|
||||
# Skip expressions containing Go template dot-access patterns.
|
||||
if ".State." in expr or ".NetworkSettings." in expr:
|
||||
continue
|
||||
# Skip expressions with unbalanced parens/brackets/braces —
|
||||
# the regex captured only part of a larger expression where
|
||||
# }} appears inside a dict literal (e.g. default({'k': {}})).
|
||||
if expr.count("(") != expr.count(")"):
|
||||
continue
|
||||
if expr.count("{") != expr.count("}"):
|
||||
continue
|
||||
if expr.count("[") != expr.count("]"):
|
||||
continue
|
||||
expressions.append(expr)
|
||||
return expressions
|
||||
|
||||
|
||||
def _render_expression(expr: str) -> tuple[bool, str]:
|
||||
"""Try to render a Jinja expression. Returns (success, error_msg)."""
|
||||
try:
|
||||
env = Environment(autoescape=False, keep_trailing_newline=True) # nosec B701 — Ansible Jinja, not web-facing # noqa: S701
|
||||
|
||||
# Add common Ansible filters so expressions can render.
|
||||
# strftime: Ansible's signature is strftime(string_format, second, utc)
|
||||
# where string_format is the piped value. If the piped value looks like
|
||||
# a number (epoch) and second looks like a format string, the args are
|
||||
# reversed — this is the exact bug from OBL-INFRA-508.
|
||||
def _strftime(string_format: str, second: float | None = None, utc: bool = False) -> str:
|
||||
if isinstance(string_format, (int, float)) and isinstance(second, str) and "%" in second:
|
||||
raise ValueError( # noqa: TRY301
|
||||
"Invalid value for epoch value — strftime filter arguments "
|
||||
"are reversed. The format string must be the piped value: "
|
||||
"'%format%' | strftime(epoch), not epoch | strftime('%format%')"
|
||||
)
|
||||
return str(string_format)
|
||||
|
||||
env.filters["strftime"] = _strftime
|
||||
env.filters["b64decode"] = lambda x: x
|
||||
env.filters["b64encode"] = lambda x: x
|
||||
env.filters["regex_replace"] = lambda x, pattern, replacement="": x
|
||||
env.filters["int"] = lambda x, default=0: (
|
||||
int(x) if isinstance(x, (int, float, str)) and str(x).lstrip("-").isdigit() else default
|
||||
)
|
||||
env.filters["bool"] = bool
|
||||
env.filters["basename"] = lambda x: str(x).rsplit("/", 1)[-1]
|
||||
env.filters["dirname"] = lambda x: str(x).rsplit("/", 1)[0] if "/" in str(x) else "."
|
||||
env.filters["combine"] = lambda *args, **kwargs: args[0]
|
||||
env.filters["from_json"] = lambda x: x
|
||||
env.filters["to_json"] = lambda x: x
|
||||
env.filters["ternary"] = lambda x, true_val, false_val=None: true_val if x else false_val
|
||||
env.filters["dict2items"] = lambda x: [
|
||||
{"key": k, "value": v} for k, v in (x.items() if isinstance(x, dict) else [])
|
||||
]
|
||||
env.filters["map"] = lambda x, attribute=None: x
|
||||
env.filters["default"] = lambda x, default_value="", boolean=False: x if x else default_value
|
||||
env.filters["from_yaml"] = lambda x: x
|
||||
env.filters["difference"] = lambda x, y: x
|
||||
env.filters["join"] = lambda x, sep="": sep.join(str(i) for i in (x if isinstance(x, list) else [x]))
|
||||
env.filters["list"] = lambda x: list(x) if isinstance(x, (list, tuple)) else [x]
|
||||
env.filters["length"] = lambda x: len(x) if hasattr(x, "__len__") else 0
|
||||
env.filters["items"] = lambda x: list(x.items()) if isinstance(x, dict) else []
|
||||
env.filters["first"] = lambda x: x[0] if isinstance(x, (list, str)) and x else x
|
||||
env.filters["last"] = lambda x: x[-1] if isinstance(x, (list, str)) and x else x
|
||||
env.filters["upper"] = lambda x: str(x).upper()
|
||||
env.filters["lower"] = lambda x: str(x).lower()
|
||||
env.filters["replace"] = lambda x, old, new: str(x).replace(old, new)
|
||||
env.filters["split"] = lambda x, sep=None: str(x).split(sep) if sep else str(x).split()
|
||||
env.filters["trim"] = lambda x: str(x).strip()
|
||||
env.filters["sort"] = lambda x: sorted(x) if isinstance(x, list) else x
|
||||
env.filters["unique"] = lambda x: list(set(x)) if isinstance(x, list) else x
|
||||
env.filters["count"] = lambda x: len(x) if hasattr(x, "__len__") else 0
|
||||
env.filters["float"] = lambda x, default=0.0: (
|
||||
float(x) if isinstance(x, (int, float, str)) and str(x).replace(".", "").lstrip("-").isdigit() else default
|
||||
)
|
||||
env.filters["string"] = str
|
||||
env.filters["indent"] = lambda x, width=4: str(x)
|
||||
env.filters["to_nice_json"] = str
|
||||
env.filters["to_nice_yaml"] = str
|
||||
env.filters["from_yaml_all"] = lambda x: x
|
||||
env.filters["groupby"] = lambda x: x
|
||||
env.filters["dictsort"] = lambda x: list(x.items()) if isinstance(x, dict) else []
|
||||
env.filters["max"] = lambda x: max(x) if isinstance(x, list) and x else x
|
||||
env.filters["min"] = lambda x: min(x) if isinstance(x, list) and x else x
|
||||
env.filters["reverse"] = lambda x: list(reversed(x)) if isinstance(x, list) else x
|
||||
env.filters["flatten"] = lambda x: x
|
||||
env.filters["product"] = lambda x: x
|
||||
env.filters["zip"] = lambda x: x
|
||||
env.filters["subelements"] = lambda x: x
|
||||
env.filters["json_query"] = lambda x: x
|
||||
env.filters["type_debug"] = lambda x: type(x).__name__
|
||||
env.globals["lookup"] = lambda *args, **kwargs: ""
|
||||
env.globals["query"] = lambda *args, **kwargs: []
|
||||
|
||||
template = env.from_string("{{ " + expr + " }}")
|
||||
result = template.render(**MOCK_CONTEXT)
|
||||
except TemplateSyntaxError as e:
|
||||
return False, f"Syntax error: {e.message}"
|
||||
except UndefinedError as e:
|
||||
# Undefined variable — skip, we can't mock everything.
|
||||
return True, f"Skipped (undefined: {e})"
|
||||
except Exception as e:
|
||||
# Check if it's a filter argument error.
|
||||
error_msg = str(e)
|
||||
if "Invalid value for epoch" in error_msg:
|
||||
return False, f"strftime filter argument error: {error_msg}"
|
||||
# Other errors might be due to missing mock variables — skip.
|
||||
return True, f"Skipped ({type(e).__name__}: {error_msg})"
|
||||
else:
|
||||
return True, result
|
||||
|
||||
|
||||
def _check_file(filepath: Path, repo_root: Path) -> list[str]:
|
||||
"""Check all Jinja expressions in a file. Returns list of violations."""
|
||||
violations = []
|
||||
content = filepath.read_text()
|
||||
expressions = _extract_expressions(content)
|
||||
|
||||
for expr in expressions:
|
||||
success, msg = _render_expression(expr)
|
||||
if not success:
|
||||
try:
|
||||
rel_path = filepath.relative_to(repo_root)
|
||||
except ValueError:
|
||||
rel_path = filepath
|
||||
violations.append(f"{rel_path}: `{{{{ {expr} }}}}` — {msg}")
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
@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:
|
||||
"""Validate Jinja2 expressions in Ansible files."""
|
||||
dirs = list(ansible_dirs) if ansible_dirs else _default_ansible_dirs()
|
||||
if path:
|
||||
files = _find_yaml_files(path)
|
||||
else:
|
||||
files: list[Path] = []
|
||||
for d in dirs:
|
||||
files.extend(_find_yaml_files(d))
|
||||
|
||||
all_violations: list[str] = []
|
||||
for f in files:
|
||||
all_violations.extend(_check_file(f, REPO_ROOT))
|
||||
|
||||
if all_violations:
|
||||
click.echo("[check-jinja-expr] FAIL: invalid Jinja expressions found:")
|
||||
for v in all_violations:
|
||||
click.echo(f" - {v}")
|
||||
click.echo("\nFix: test expressions with `ansible localhost -m debug -a 'msg={{ <expr> }}'`")
|
||||
sys.exit(1)
|
||||
else:
|
||||
click.echo("[check-jinja-expr] OK: all Jinja expressions render correctly.")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -783,6 +783,14 @@
|
||||
"ru": "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).": {
|
||||
"bg": "Позволи празен таг (PR режим, където SHA е конкретен).",
|
||||
"de": "Leeren Tag zulassen (PR-Modus, in dem SHA konkret ist).",
|
||||
@@ -791,13 +799,13 @@
|
||||
"ru": "Разрешить пустой тег (режим PR, где SHA конкретен).",
|
||||
"zh": "允许空标签(SHA 为具体值的 PR 模式)。"
|
||||
},
|
||||
"Another runner failed. Stopping this runner early.": {
|
||||
"bg": "Друг runner се провали. Спиране на този runner по-рано.",
|
||||
"de": "Ein anderer Runner ist fehlgeschlagen. Dieser Runner wird vorzeitig gestoppt.",
|
||||
"en": "Another runner failed. Stopping this runner early.",
|
||||
"pl": "Inny runner zakończył się niepowodzeniem. Wczesne zatrzymanie tego runnera.",
|
||||
"ru": "Другой runner завершился с ошибкой. Останавливаю этот runner досрочно.",
|
||||
"zh": "另一个 runner 失败。提前停止此 runner。"
|
||||
"Another molecule runner failed. Stopping this runner early.": {
|
||||
"bg": "Another molecule runner failed. Stopping this runner early.",
|
||||
"de": "Another molecule runner failed. Stopping this runner early.",
|
||||
"en": "Another molecule runner failed. Stopping this runner early.",
|
||||
"pl": "Inny runner molecule zakończył się niepowodzeniem. Wczesne zatrzymanie tego runnera.",
|
||||
"ru": "Another molecule runner failed. Stopping this runner early.",
|
||||
"zh": "Another molecule runner failed. Stopping this runner early."
|
||||
},
|
||||
"Assigned {count} files to runner {runner_index}": {
|
||||
"bg": "Assigned {count} files to runner {runner_index}",
|
||||
@@ -1487,6 +1495,14 @@
|
||||
"ru": "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}": {
|
||||
"bg": "Failed images: {names}",
|
||||
"de": "Failed images: {names}",
|
||||
@@ -2247,6 +2263,14 @@
|
||||
"ru": "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.": {
|
||||
"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.",
|
||||
@@ -2719,6 +2743,14 @@
|
||||
"ru": "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": {
|
||||
"bg": "SSH ключът е настроен успешно",
|
||||
"de": "SSH-Schlüssel erfolgreich eingerichtet",
|
||||
@@ -3798,5 +3830,13 @@
|
||||
"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)",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ class TestCliGroups:
|
||||
result = runner.invoke(cli, ["molecule", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "distribute" in result.output
|
||||
assert "guard" in result.output
|
||||
assert "all" in result.output
|
||||
|
||||
|
||||
@@ -230,6 +231,13 @@ class TestMoleculeCommands:
|
||||
assert result.exit_code == 0
|
||||
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")
|
||||
def test_molecule_all(self, mock_run: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -7,7 +7,6 @@ import subprocess # nosec B404
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.integration_guard import cli
|
||||
@@ -112,8 +111,9 @@ class TestCli:
|
||||
clear=True,
|
||||
),
|
||||
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.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
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)),
|
||||
@@ -158,8 +158,9 @@ class TestCli:
|
||||
clear=True,
|
||||
),
|
||||
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.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("os.killpg", side_effect=ProcessLookupError("no such process")),
|
||||
patch("os.getpgid") as mock_getpgid,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||
@@ -202,8 +203,9 @@ class TestCli:
|
||||
clear=True,
|
||||
),
|
||||
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.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
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)),
|
||||
@@ -287,125 +289,3 @@ def test_main_module_block() -> None:
|
||||
namespace = dict(ig.__dict__)
|
||||
exec(compile(source, ig.__file__, "exec"), namespace)
|
||||
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,192 +0,0 @@
|
||||
"""Unit tests for devx.molecule.molecule_changed.
|
||||
|
||||
Verifies that the script correctly detects changed roles and maps
|
||||
them to make targets.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.molecule.molecule_changed import (
|
||||
detect_changed_roles,
|
||||
get_changed_files,
|
||||
main,
|
||||
roles_to_targets,
|
||||
)
|
||||
|
||||
|
||||
def test_detect_role_change():
|
||||
"""A file in ansible/roles/<role>/ maps to that role."""
|
||||
files = ["ansible/roles/docker_base/tasks/main.yml"]
|
||||
roles = detect_changed_roles(files)
|
||||
assert "docker_base" in roles
|
||||
|
||||
|
||||
def test_detect_playbook_change():
|
||||
"""A playbook change maps to its included roles."""
|
||||
files = ["ansible/playbooks/deploy-observability.yml"]
|
||||
roles = detect_changed_roles(files)
|
||||
assert "observability" in roles
|
||||
assert "docker_base" in roles
|
||||
assert "zitadel" in roles
|
||||
|
||||
|
||||
def test_detect_shared_infra_triggers_all():
|
||||
"""ansible.cfg change triggers all roles."""
|
||||
files = ["ansible/ansible.cfg"]
|
||||
roles = detect_changed_roles(files)
|
||||
assert len(roles) == 10 # all roles
|
||||
|
||||
|
||||
def test_detect_no_ansible_changes():
|
||||
"""Non-Ansible files don't trigger any roles."""
|
||||
files = ["scripts/molecule_changed.py", "Makefile"]
|
||||
roles = detect_changed_roles(files)
|
||||
assert len(roles) == 0
|
||||
|
||||
|
||||
def test_roles_to_targets():
|
||||
"""Role names map to make targets."""
|
||||
targets = roles_to_targets({"docker_base", "zitadel"})
|
||||
assert "molecule-docker-base" in targets
|
||||
assert "molecule-zitadel" in targets
|
||||
|
||||
|
||||
def test_roles_to_targets_unknown_role():
|
||||
"""Unknown roles are silently skipped."""
|
||||
targets = roles_to_targets({"docker_base", "unknown_role"})
|
||||
assert targets == ["molecule-docker-base"]
|
||||
|
||||
|
||||
def test_main_no_changes():
|
||||
"""When no files changed, outputs message to stderr."""
|
||||
with patch("devx.molecule.molecule_changed.get_changed_files", return_value=[]):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--print-targets"])
|
||||
assert result.exit_code == 0
|
||||
assert "No changed files" in result.output
|
||||
|
||||
|
||||
def test_main_print_targets():
|
||||
"""--print-targets outputs make targets."""
|
||||
with patch(
|
||||
"devx.molecule.molecule_changed.get_changed_files",
|
||||
return_value=["ansible/roles/docker_base/tasks/main.yml"],
|
||||
):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--print-targets"])
|
||||
assert result.exit_code == 0
|
||||
assert "molecule-docker-base" in result.output
|
||||
|
||||
|
||||
def test_main_print_roles():
|
||||
"""--print-roles outputs role names."""
|
||||
with patch(
|
||||
"devx.molecule.molecule_changed.get_changed_files",
|
||||
return_value=["ansible/roles/zitadel/tasks/main.yml"],
|
||||
):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--print-roles"])
|
||||
assert result.exit_code == 0
|
||||
assert "zitadel" in result.output
|
||||
|
||||
|
||||
def test_main_no_ansible_changes():
|
||||
"""When only non-Ansible files changed, outputs no scenarios message."""
|
||||
with patch(
|
||||
"devx.molecule.molecule_changed.get_changed_files",
|
||||
return_value=["scripts/molecule_changed.py"],
|
||||
):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--print-targets"])
|
||||
assert result.exit_code == 0
|
||||
assert "No molecule scenarios" in result.output
|
||||
|
||||
|
||||
def test_get_changed_files_with_mock():
|
||||
"""get_changed_files returns files from git diff."""
|
||||
with patch("devx.molecule.molecule_changed._run_git", return_value="file1\nfile2\n"):
|
||||
files = get_changed_files("origin/master")
|
||||
assert files == ["file1", "file2"]
|
||||
|
||||
|
||||
def test_get_changed_files_falls_back_to_master():
|
||||
"""When base ref has no diff, falls back to master."""
|
||||
calls: list[list[str]] = []
|
||||
|
||||
def mock_git(args):
|
||||
calls.append(args)
|
||||
# First call (origin/master) returns empty, second (master) returns files
|
||||
if "origin/master...HEAD" in args[2]:
|
||||
return ""
|
||||
return "ansible/roles/docker_base/tasks/main.yml\n"
|
||||
|
||||
with patch("devx.molecule.molecule_changed._run_git", side_effect=mock_git):
|
||||
files = get_changed_files("origin/master")
|
||||
assert files == ["ansible/roles/docker_base/tasks/main.yml"]
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_get_changed_files_empty():
|
||||
"""When no changes in either ref, returns empty list."""
|
||||
with patch("devx.molecule.molecule_changed._run_git", return_value=""):
|
||||
files = get_changed_files("origin/master")
|
||||
assert files == []
|
||||
|
||||
|
||||
def test_detect_molecule_shared_path():
|
||||
"""ansible/molecule/ change triggers all roles."""
|
||||
files = ["ansible/molecule/Dockerfile"]
|
||||
roles = detect_changed_roles(files)
|
||||
assert len(roles) == 10
|
||||
|
||||
|
||||
def test_detect_requirements_yml_triggers_all():
|
||||
"""ansible/requirements.yml change triggers all roles."""
|
||||
files = ["ansible/requirements.yml"]
|
||||
roles = detect_changed_roles(files)
|
||||
assert len(roles) == 10
|
||||
|
||||
|
||||
def test_detect_configure_oidc_playbook():
|
||||
"""configure-oidc.yml maps to sso_config and app_container."""
|
||||
files = ["ansible/playbooks/configure-oidc.yml"]
|
||||
roles = detect_changed_roles(files)
|
||||
assert "sso_config" in roles
|
||||
assert "app_container" in roles
|
||||
|
||||
|
||||
def test_detect_prepare_vms_playbook():
|
||||
"""prepare-vms.yml maps to all base roles."""
|
||||
files = ["ansible/playbooks/prepare-vms.yml"]
|
||||
roles = detect_changed_roles(files)
|
||||
assert "docker_base" in roles
|
||||
assert "app_hardening" in roles
|
||||
assert "storage" in roles
|
||||
assert "disk_cleanup" in roles
|
||||
assert "crowdsec" in roles
|
||||
|
||||
|
||||
def test_detect_deploy_customer_playbook():
|
||||
"""deploy-customer.yml maps to its roles."""
|
||||
files = ["ansible/playbooks/deploy-customer.yml"]
|
||||
roles = detect_changed_roles(files)
|
||||
assert "app_container" in roles
|
||||
assert "docker_base" in roles
|
||||
assert "app_hardening" in roles
|
||||
assert "sso_config" in roles
|
||||
|
||||
|
||||
def test_main_default_base():
|
||||
"""main() with no --base uses origin/master."""
|
||||
with patch(
|
||||
"devx.molecule.molecule_changed.get_changed_files",
|
||||
return_value=["ansible/roles/zitadel/tasks/main.yml"],
|
||||
) as mock:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--print-roles"])
|
||||
assert result.exit_code == 0
|
||||
mock.assert_called_once_with("origin/master")
|
||||
@@ -0,0 +1,591 @@
|
||||
"""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
|
||||
+40
-405
@@ -7,32 +7,13 @@ from click.testing import CliRunner
|
||||
|
||||
from devx.molecule.start_docker import (
|
||||
DOCKER_SOCK,
|
||||
HOST_DOCKER_SOCK,
|
||||
ROOTLESS_SOCK,
|
||||
_diagnose_socket,
|
||||
_get_docker_free_bytes,
|
||||
_try_socket,
|
||||
is_docker_ready,
|
||||
main,
|
||||
start_docker_daemon,
|
||||
)
|
||||
|
||||
|
||||
def _pgrep_empty() -> MagicMock:
|
||||
"""Mock for pgrep returning no dockerd processes."""
|
||||
return MagicMock(stdout="", returncode=1)
|
||||
|
||||
|
||||
def _docker_info_alive() -> MagicMock:
|
||||
"""Mock for docker info showing daemon alive."""
|
||||
return MagicMock(returncode=0)
|
||||
|
||||
|
||||
def _docker_info_dead() -> MagicMock:
|
||||
"""Mock for docker info showing daemon dead."""
|
||||
return MagicMock(returncode=1)
|
||||
|
||||
|
||||
class TestIsDockerReady:
|
||||
@patch("devx.molecule.start_docker.subprocess.run")
|
||||
def test_ready(self, mock_run: MagicMock) -> None:
|
||||
@@ -59,42 +40,6 @@ class TestIsDockerReady:
|
||||
assert call_kwargs.kwargs["env"]["DOCKER_HOST"] == rootless
|
||||
|
||||
|
||||
class TestGetDockerFreeBytes:
|
||||
@patch("devx.molecule.start_docker.shutil.disk_usage")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
|
||||
@patch("devx.molecule.start_docker.subprocess.run")
|
||||
def test_returns_free_bytes(self, mock_run: MagicMock, mock_exists: MagicMock, mock_du: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(stdout="/var/lib/docker\n", returncode=0, text="")
|
||||
mock_du.return_value = MagicMock(free=50 * 1024**3)
|
||||
with patch.dict("os.environ", {"DOCKER_HOST": f"unix://{DOCKER_SOCK}"}, clear=False):
|
||||
assert _get_docker_free_bytes() == 50 * 1024**3
|
||||
|
||||
@patch("devx.molecule.start_docker.subprocess.run")
|
||||
def test_daemon_not_reachable(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", text="")
|
||||
assert _get_docker_free_bytes() == 0
|
||||
|
||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
|
||||
@patch("devx.molecule.start_docker.subprocess.run")
|
||||
def test_data_root_not_accessible(self, mock_run: MagicMock, mock_exists: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(stdout="/some/path\n", returncode=0, text="")
|
||||
assert _get_docker_free_bytes() == 0
|
||||
|
||||
@patch("devx.molecule.start_docker.subprocess.run", side_effect=FileNotFoundError)
|
||||
def test_subprocess_not_found(self, mock_run: MagicMock) -> None:
|
||||
assert _get_docker_free_bytes() == 0
|
||||
|
||||
|
||||
class TestTrySocket:
|
||||
@patch("devx.molecule.start_docker.is_docker_ready", return_value=True)
|
||||
def test_ready(self, mock_ready: MagicMock) -> None:
|
||||
assert _try_socket("/run/user/999/docker.sock") is True
|
||||
|
||||
@patch("devx.molecule.start_docker.is_docker_ready", return_value=False)
|
||||
def test_not_ready(self, mock_ready: MagicMock) -> None:
|
||||
assert _try_socket("/run/user/999/docker.sock") is False
|
||||
|
||||
|
||||
class TestDiagnoseSocket:
|
||||
@patch("devx.molecule.start_docker.os.stat")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
|
||||
@@ -141,373 +86,81 @@ class TestDiagnoseSocket:
|
||||
mock_exists.assert_called_with(DOCKER_SOCK)
|
||||
|
||||
|
||||
def _exists_map(paths: set[str]) -> MagicMock:
|
||||
"""Return a mock os.path.exists that returns True only for *paths*."""
|
||||
return MagicMock(side_effect=lambda p: p in paths)
|
||||
|
||||
|
||||
class TestStartDockerDaemon:
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=100 * 1024**3)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready", return_value=True)
|
||||
@patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK}))
|
||||
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
|
||||
def test_host_socket_available_with_space(
|
||||
self,
|
||||
mock_glob: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_free: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
) -> None:
|
||||
"""Should return immediately if /var/run/docker.sock has enough space."""
|
||||
def test_host_socket_available(self, mock_ready: MagicMock, mock_diag: MagicMock) -> None:
|
||||
"""Should return immediately if host Docker is available."""
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
mock_ready.assert_called_once()
|
||||
mock_diag.assert_called_once()
|
||||
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=0)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready", return_value=True)
|
||||
@patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({HOST_DOCKER_SOCK}))
|
||||
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
|
||||
def test_host_rootless_inaccessible_root_uses_host(
|
||||
self,
|
||||
mock_glob: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_free: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
) -> None:
|
||||
"""Should use host rootless Docker when root dir is inaccessible (free=0)."""
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=0)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready", return_value=True)
|
||||
@patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK}))
|
||||
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
|
||||
@patch("devx.molecule.start_docker.subprocess.run")
|
||||
@patch("devx.molecule.start_docker.subprocess.Popen")
|
||||
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
|
||||
@patch("devx.molecule.start_docker.time.sleep")
|
||||
def test_inner_dockerd_free_zero_starts_local(
|
||||
self,
|
||||
mock_sleep: MagicMock,
|
||||
mock_ntf: MagicMock,
|
||||
mock_popen: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_glob: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_free: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
) -> None:
|
||||
"""Inner dockerd with free=0 (not host socket) should NOT be trusted — start local."""
|
||||
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||
# is_docker_ready: first check (inner dockerd) True, then local daemon checks
|
||||
mock_ready.side_effect = [True, False, False, False, False, True]
|
||||
# subprocess.run: pgrep(socket check, finds dockerd), pgrep(before kill),
|
||||
# pkill x3, pgrep(after kill), docker info(dead), rm
|
||||
mock_run.side_effect = [
|
||||
MagicMock(returncode=0, stdout="123 /usr/bin/dockerd\n"), # socket check pgrep
|
||||
_pgrep_empty(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
_pgrep_empty(),
|
||||
_docker_info_dead(),
|
||||
MagicMock(),
|
||||
]
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
mock_popen.assert_called_once()
|
||||
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=0)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready", return_value=True)
|
||||
@patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK}))
|
||||
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
|
||||
@patch("devx.molecule.start_docker.subprocess.run")
|
||||
def test_no_inner_dockerd_trusts_host_socket(
|
||||
self,
|
||||
mock_run: MagicMock,
|
||||
mock_glob: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_free: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
) -> None:
|
||||
"""Should trust /var/run/docker.sock with free=0 when no inner dockerd exists."""
|
||||
# pgrep finds no dockerd processes (returncode=1)
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="")
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
assert os.environ.get("DOCKER_HOST") == f"unix://{DOCKER_SOCK}"
|
||||
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker._get_docker_free_bytes")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK}))
|
||||
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
|
||||
@patch("devx.molecule.start_docker.subprocess.run")
|
||||
@patch("devx.molecule.start_docker.subprocess.Popen")
|
||||
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
|
||||
@patch("devx.molecule.start_docker.time.sleep")
|
||||
def test_host_socket_low_space_starts_local(
|
||||
self,
|
||||
mock_sleep: MagicMock,
|
||||
mock_ntf: MagicMock,
|
||||
mock_popen: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_glob: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_free: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
def test_rootless_socket_available(
|
||||
self, mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock
|
||||
) -> None:
|
||||
"""Should start local dockerd if host Docker has low space and no rootless sockets."""
|
||||
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||
mock_ready.side_effect = [True, False, False, False, False, True]
|
||||
mock_free.return_value = 5 * 1024**3
|
||||
# subprocess.run: pgrep(before), pkill x3, pgrep(after), docker info(dead), rm
|
||||
mock_run.side_effect = [
|
||||
_pgrep_empty(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
_pgrep_empty(),
|
||||
_docker_info_dead(),
|
||||
MagicMock(),
|
||||
]
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
mock_popen.assert_called_once()
|
||||
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker._get_docker_free_bytes")
|
||||
@patch("devx.molecule.start_docker.is_docker_ready")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK}))
|
||||
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
|
||||
@patch("devx.molecule.start_docker.subprocess.run")
|
||||
@patch("devx.molecule.start_docker.subprocess.Popen")
|
||||
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
|
||||
@patch("devx.molecule.start_docker.time.sleep")
|
||||
def test_inner_dockerd_alive_uses_alt_sock(
|
||||
self,
|
||||
mock_sleep: MagicMock,
|
||||
mock_ntf: MagicMock,
|
||||
mock_popen: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_glob: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_free: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
) -> None:
|
||||
"""Should use /dev/shm/docker.sock if inner dockerd can't be killed."""
|
||||
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||
mock_ready.side_effect = [True, False, False, False, False, True]
|
||||
mock_free.return_value = 5 * 1024**3
|
||||
# subprocess.run: pgrep(before), pkill x3, pgrep(after), docker info(alive), rm
|
||||
mock_run.side_effect = [
|
||||
_pgrep_empty(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
_pgrep_empty(),
|
||||
_docker_info_alive(),
|
||||
MagicMock(),
|
||||
]
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
mock_popen.assert_called_once()
|
||||
assert os.environ.get("DOCKER_HOST") == "unix:///dev/shm/docker.sock"
|
||||
|
||||
@patch("devx.molecule.start_docker.os.kill")
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker._get_docker_free_bytes")
|
||||
@patch("devx.molecule.start_docker.is_docker_ready")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK}))
|
||||
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
|
||||
@patch("devx.molecule.start_docker.subprocess.run")
|
||||
@patch("devx.molecule.start_docker.subprocess.Popen")
|
||||
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
|
||||
@patch("devx.molecule.start_docker.time.sleep")
|
||||
def test_inner_dockerd_killed_by_pid(
|
||||
self,
|
||||
mock_sleep: MagicMock,
|
||||
mock_ntf: MagicMock,
|
||||
mock_popen: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_glob: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_free: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
mock_kill: MagicMock,
|
||||
) -> None:
|
||||
"""Should kill dockerd by PID when pkill fails and pgrep finds processes."""
|
||||
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||
mock_ready.side_effect = [True, False, False, False, False, True]
|
||||
mock_free.return_value = 5 * 1024**3
|
||||
# pgrep(before) empty, pkill x3, pgrep(after) finds PID 12345, docker info(dead), rm
|
||||
mock_run.side_effect = [
|
||||
_pgrep_empty(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
MagicMock(stdout="12345 /usr/bin/dockererd\n", returncode=0),
|
||||
_docker_info_dead(),
|
||||
MagicMock(),
|
||||
]
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
mock_popen.assert_called_once()
|
||||
mock_kill.assert_called_once_with(12345, 9)
|
||||
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker._get_docker_free_bytes")
|
||||
@patch("devx.molecule.start_docker.is_docker_ready")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK, HOST_DOCKER_SOCK}))
|
||||
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
|
||||
def test_host_rootless_preferred_over_inner_dockerd(
|
||||
self,
|
||||
mock_glob: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_free: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
) -> None:
|
||||
"""Should prefer /run/host-docker.sock over /var/run/docker.sock."""
|
||||
# host rootless ready with space, inner dockerd never tried
|
||||
mock_ready.side_effect = [True]
|
||||
mock_free.side_effect = [200 * 1024**3]
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
# DOCKER_HOST should be set to host socket
|
||||
assert os.environ.get("DOCKER_HOST") == f"unix://{HOST_DOCKER_SOCK}"
|
||||
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=100 * 1024**3)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK, HOST_DOCKER_SOCK}))
|
||||
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
|
||||
def test_host_rootless_not_ready_falls_to_inner(
|
||||
self,
|
||||
mock_glob: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_free: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
) -> None:
|
||||
"""Should fall through to inner dockerd if host rootless socket is not ready."""
|
||||
"""Should use rootless socket if host socket fails."""
|
||||
# First check (host) fails, second check (rootless) succeeds
|
||||
mock_ready.side_effect = [False, True]
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
assert os.environ.get("DOCKER_HOST") == f"unix://{DOCKER_SOCK}"
|
||||
with patch("devx.molecule.start_docker.glob.glob", return_value=[]):
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=100 * 1024**3)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready", return_value=True)
|
||||
@patch(
|
||||
"devx.molecule.start_docker.os.path.exists",
|
||||
side_effect=_exists_map({DOCKER_SOCK, HOST_DOCKER_SOCK, ROOTLESS_SOCK, "/run/user/999/docker.sock"}),
|
||||
)
|
||||
@patch("devx.molecule.start_docker.glob.glob", return_value=["/run/user/999/docker.sock"])
|
||||
def test_glob_finds_extra_rootless_socket(
|
||||
self,
|
||||
mock_glob: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_free: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
) -> None:
|
||||
"""Should include rootless sockets found via glob scan."""
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker._get_docker_free_bytes")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK, HOST_DOCKER_SOCK}))
|
||||
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
|
||||
def test_inner_dockerd_fallback_when_host_rootless_low_space(
|
||||
self,
|
||||
mock_glob: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_free: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
def test_alt_rootless_socket_found(
|
||||
self, mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock
|
||||
) -> None:
|
||||
"""Should fall back to inner dockerd if host rootless has low space."""
|
||||
# host rootless ready low space, inner dockerd ready with space
|
||||
mock_ready.side_effect = [True, True]
|
||||
mock_free.side_effect = [5 * 1024**3, 200 * 1024**3]
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
assert os.environ.get("DOCKER_HOST") == f"unix://{DOCKER_SOCK}"
|
||||
"""Should find rootless socket at a different UID via glob scan."""
|
||||
# Host fails, own rootless fails, alt rootless succeeds
|
||||
mock_ready.side_effect = [False, False, True]
|
||||
alt_sock = "/run/user/999/docker.sock"
|
||||
with patch("devx.molecule.start_docker.glob.glob", return_value=[alt_sock]):
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker._get_docker_free_bytes")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=True)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready")
|
||||
@patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK}))
|
||||
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
|
||||
@patch("devx.molecule.start_docker.subprocess.run")
|
||||
@patch("devx.molecule.start_docker.subprocess.Popen")
|
||||
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
|
||||
@patch("devx.molecule.start_docker.time.sleep")
|
||||
def test_all_sockets_low_space_starts_local(
|
||||
self,
|
||||
mock_sleep: MagicMock,
|
||||
mock_ntf: MagicMock,
|
||||
mock_popen: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_glob: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_free: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
def test_alt_rootless_socket_skips_own(
|
||||
self, mock_ready: MagicMock, mock_exists: MagicMock, mock_diag: MagicMock
|
||||
) -> None:
|
||||
"""Should start local dockerd if all sockets have low space."""
|
||||
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||
mock_ready.side_effect = [True, False, False, False, False, True]
|
||||
mock_free.return_value = 5 * 1024**3
|
||||
# subprocess.run: pgrep(before), pkill x3, pgrep(after), docker info(dead), rm
|
||||
mock_run.side_effect = [
|
||||
_pgrep_empty(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
_pgrep_empty(),
|
||||
_docker_info_dead(),
|
||||
MagicMock(),
|
||||
]
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
mock_popen.assert_called_once()
|
||||
"""Should skip the own rootless socket in glob scan (already tried)."""
|
||||
# Host fails, own rootless fails, alt rootless also fails, dockerd fails
|
||||
mock_ready.side_effect = [False, False, False, False, False, False]
|
||||
own_sock = f"/run/user/{os.getuid()}/docker.sock"
|
||||
alt_sock = "/run/user/999/docker.sock"
|
||||
with (
|
||||
patch("devx.molecule.start_docker.glob.glob", return_value=[own_sock, alt_sock]),
|
||||
patch("devx.molecule.start_docker.time.sleep"),
|
||||
patch("devx.molecule.start_docker.subprocess.Popen"),
|
||||
patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile") as mock_ntf,
|
||||
patch("builtins.open", mock_open(read_data="err")),
|
||||
):
|
||||
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||
assert start_docker_daemon(timeout=2) is False
|
||||
|
||||
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=0)
|
||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready", return_value=False)
|
||||
@patch("devx.molecule.start_docker.time.sleep")
|
||||
@patch("devx.molecule.start_docker.subprocess.run")
|
||||
@patch("devx.molecule.start_docker.subprocess.Popen")
|
||||
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
|
||||
def test_starts_local_daemon(
|
||||
self,
|
||||
mock_ntf: MagicMock,
|
||||
mock_popen: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_free: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
mock_glob: MagicMock,
|
||||
) -> None:
|
||||
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||
# No sockets exist, local daemon starts
|
||||
# Host fails, rootless doesn't exist, local daemon starts
|
||||
mock_ready.side_effect = [False, False, False, False, True]
|
||||
# subprocess.run: pgrep(before), pkill x3, pgrep(after), rm
|
||||
mock_run.side_effect = [
|
||||
_pgrep_empty(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
MagicMock(),
|
||||
_pgrep_empty(),
|
||||
MagicMock(),
|
||||
]
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
mock_popen.assert_called_once()
|
||||
popen_args = mock_popen.call_args.args[0]
|
||||
@@ -518,22 +171,18 @@ class TestStartDockerDaemon:
|
||||
|
||||
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=0)
|
||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready", return_value=False)
|
||||
@patch("devx.molecule.start_docker.time.sleep")
|
||||
@patch("devx.molecule.start_docker.subprocess.run")
|
||||
@patch("devx.molecule.start_docker.subprocess.Popen")
|
||||
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
|
||||
def test_fails_after_timeout(
|
||||
self,
|
||||
mock_ntf: MagicMock,
|
||||
mock_popen: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_free: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
mock_glob: MagicMock,
|
||||
) -> None:
|
||||
@@ -541,26 +190,22 @@ class TestStartDockerDaemon:
|
||||
with patch("builtins.open", mock_open(read_data="dockerd error log")):
|
||||
assert start_docker_daemon(timeout=3) is False
|
||||
mock_popen.assert_called_once()
|
||||
assert mock_sleep.call_count == 5 # 1 after pkill + 1 after pgrep + 3 timeout retries
|
||||
assert mock_sleep.call_count == 3
|
||||
|
||||
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=0)
|
||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready", return_value=False)
|
||||
@patch("devx.molecule.start_docker.time.sleep")
|
||||
@patch("devx.molecule.start_docker.subprocess.run")
|
||||
@patch("devx.molecule.start_docker.subprocess.Popen")
|
||||
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
|
||||
def test_fails_log_read_error(
|
||||
self,
|
||||
mock_ntf: MagicMock,
|
||||
mock_popen: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_free: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
mock_glob: MagicMock,
|
||||
) -> None:
|
||||
@@ -571,45 +216,35 @@ class TestStartDockerDaemon:
|
||||
|
||||
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=0)
|
||||
@patch("devx.molecule.start_docker.os.path.exists", return_value=False)
|
||||
@patch("devx.molecule.start_docker.is_docker_ready")
|
||||
@patch("devx.molecule.start_docker.time.sleep")
|
||||
@patch("devx.molecule.start_docker.subprocess.run")
|
||||
@patch("devx.molecule.start_docker.subprocess.Popen")
|
||||
@patch("devx.molecule.start_docker.tempfile.NamedTemporaryFile")
|
||||
def test_local_daemon_ready_on_first_check(
|
||||
self,
|
||||
mock_ntf: MagicMock,
|
||||
mock_popen: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
mock_sleep: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_free: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
mock_glob: MagicMock,
|
||||
) -> None:
|
||||
mock_ntf.return_value = MagicMock(name="/tmp/dockerd.log")
|
||||
# No sockets exist, local ready on second loop check
|
||||
# Host fails, rootless doesn't exist, local ready on first loop check
|
||||
mock_ready.side_effect = [False, False, True]
|
||||
assert start_docker_daemon(timeout=5) is True
|
||||
assert mock_popen.call_count == 1
|
||||
assert mock_sleep.call_count == 4 # 1 after pkill + 1 after pgrep + 2 loop retries
|
||||
assert mock_sleep.call_count == 1
|
||||
|
||||
@patch("devx.molecule.start_docker._diagnose_socket")
|
||||
@patch("devx.molecule.start_docker._get_docker_free_bytes", return_value=100 * 1024**3)
|
||||
@patch("devx.molecule.start_docker.os.environ")
|
||||
@patch("devx.molecule.start_docker.is_docker_ready", return_value=True)
|
||||
@patch("devx.molecule.start_docker.os.path.exists", side_effect=_exists_map({DOCKER_SOCK}))
|
||||
@patch("devx.molecule.start_docker.glob.glob", return_value=[])
|
||||
def test_sets_docker_host(
|
||||
self,
|
||||
mock_glob: MagicMock,
|
||||
mock_exists: MagicMock,
|
||||
mock_ready: MagicMock,
|
||||
mock_environ: MagicMock,
|
||||
mock_free: MagicMock,
|
||||
mock_diag: MagicMock,
|
||||
) -> None:
|
||||
"""DOCKER_HOST must be set so molecule connects to correct socket."""
|
||||
|
||||
@@ -1,367 +0,0 @@
|
||||
"""Unit tests for devx.tools.check_ansible_no_log."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.check_ansible_no_log import _check_task, check_directory, main
|
||||
|
||||
|
||||
def _make_task(name: str, action: str, value: str, **extra: object) -> dict:
|
||||
"""Build a minimal task dict for testing."""
|
||||
task: dict = {"name": name, action: value}
|
||||
task.update(extra)
|
||||
return task
|
||||
|
||||
|
||||
class TestCheckTask:
|
||||
def test_task_with_secret_and_no_log_passes(self):
|
||||
task = _make_task(
|
||||
"Safe task",
|
||||
"ansible.builtin.shell",
|
||||
"echo {{ _secrets.mattermost_admin_password }}",
|
||||
no_log=True,
|
||||
)
|
||||
assert _check_task(task, Path("test.yml"), 1) == []
|
||||
|
||||
def test_task_with_secret_and_no_no_log_fails(self):
|
||||
task = _make_task(
|
||||
"Unsafe task",
|
||||
"ansible.builtin.shell",
|
||||
"echo {{ _secrets.mattermost_admin_password }}",
|
||||
)
|
||||
violations = _check_task(task, Path("test.yml"), 1)
|
||||
assert len(violations) == 1
|
||||
assert "no_log" in violations[0]
|
||||
|
||||
def test_task_without_secret_passes(self):
|
||||
task = _make_task(
|
||||
"Normal task",
|
||||
"ansible.builtin.shell",
|
||||
"echo hello world",
|
||||
)
|
||||
assert _check_task(task, Path("test.yml"), 1) == []
|
||||
|
||||
def test_task_with_jinja_no_log_passes(self):
|
||||
task = _make_task(
|
||||
"Safe task with jinja no_log",
|
||||
"ansible.builtin.shell",
|
||||
"echo {{ _secrets.mattermost_admin_password }}",
|
||||
no_log="{{ not (debug_mode | default(false) | bool) }}",
|
||||
)
|
||||
assert _check_task(task, Path("test.yml"), 1) == []
|
||||
|
||||
def test_task_with_password_in_name_only_no_false_positive(self):
|
||||
"""Task name contains 'password' but no secret value — should not flag."""
|
||||
task = _make_task(
|
||||
"Configure passwdqc in common-password",
|
||||
"ansible.builtin.lineinfile",
|
||||
"password required pam_passwdqc.so min=disabled,disabled,16,12,8",
|
||||
)
|
||||
assert _check_task(task, Path("test.yml"), 1) == []
|
||||
|
||||
def test_task_with_password_in_module_param_no_false_positive(self):
|
||||
"""Module param named 'password' but value is a literal — no Jinja."""
|
||||
task = {
|
||||
"name": "Set user password",
|
||||
"ansible.builtin.user": {
|
||||
"name": "deploy",
|
||||
"password_lock": True,
|
||||
},
|
||||
}
|
||||
assert _check_task(task, Path("test.yml"), 1) == []
|
||||
|
||||
def test_task_with_vault_password_variable_fails(self):
|
||||
task = _make_task(
|
||||
"Unsafe vault task",
|
||||
"ansible.builtin.shell",
|
||||
"echo {{ vault_zitadel_db_password }}",
|
||||
)
|
||||
violations = _check_task(task, Path("test.yml"), 1)
|
||||
assert len(violations) == 1
|
||||
|
||||
def test_task_with_nested_dict_secret_fails(self):
|
||||
"""Secrets in nested dict values (e.g. set_fact) should be caught."""
|
||||
task = {
|
||||
"name": "Set secrets",
|
||||
"ansible.builtin.set_fact": {
|
||||
"db_password": "{{ vault_db_password }}",
|
||||
"api_key": "{{ vault_api_key }}",
|
||||
},
|
||||
}
|
||||
violations = _check_task(task, Path("test.yml"), 1)
|
||||
assert len(violations) == 1
|
||||
|
||||
def test_task_with_no_log_none_passes(self):
|
||||
"""no_log: None should count as not set (flagged)."""
|
||||
task = _make_task(
|
||||
"Unsafe task",
|
||||
"ansible.builtin.shell",
|
||||
"echo {{ _secrets.db_password }}",
|
||||
no_log=None,
|
||||
)
|
||||
violations = _check_task(task, Path("test.yml"), 1)
|
||||
assert len(violations) == 1
|
||||
|
||||
def test_task_with_secret_in_list_value_fails(self):
|
||||
"""Secrets inside list values should be caught."""
|
||||
task = {
|
||||
"name": "Task with list secret",
|
||||
"ansible.builtin.set_fact": {
|
||||
"items": ["{{ _secrets.api_key }}", "normal_value"],
|
||||
},
|
||||
}
|
||||
violations = _check_task(task, Path("test.yml"), 1)
|
||||
assert len(violations) == 1
|
||||
|
||||
def test_task_with_api_key_secret_fails(self):
|
||||
"""api_key in Jinja expression should be caught."""
|
||||
task = _make_task(
|
||||
"Unsafe task",
|
||||
"ansible.builtin.shell",
|
||||
"echo {{ my_api_key }}",
|
||||
)
|
||||
violations = _check_task(task, Path("test.yml"), 1)
|
||||
assert len(violations) == 1
|
||||
|
||||
def test_task_with_secret_in_jinja_fails(self):
|
||||
"""_secret in Jinja expression should be caught."""
|
||||
task = _make_task(
|
||||
"Unsafe task",
|
||||
"ansible.builtin.shell",
|
||||
"echo {{ my_secret }}",
|
||||
)
|
||||
violations = _check_task(task, Path("test.yml"), 1)
|
||||
assert len(violations) == 1
|
||||
|
||||
def test_task_with_access_token_fails(self):
|
||||
"""access_token in Jinja expression should be caught."""
|
||||
task = _make_task(
|
||||
"Unsafe task",
|
||||
"ansible.builtin.shell",
|
||||
"echo {{ my_access_token }}",
|
||||
)
|
||||
violations = _check_task(task, Path("test.yml"), 1)
|
||||
assert len(violations) == 1
|
||||
|
||||
def test_task_with_non_secret_non_dict_non_list_value(self):
|
||||
"""Non-str, non-dict, non-list values (e.g. int) should not crash."""
|
||||
task = _make_task(
|
||||
"Task with int",
|
||||
"ansible.builtin.shell",
|
||||
"echo hello",
|
||||
some_int=42,
|
||||
)
|
||||
assert _check_task(task, Path("test.yml"), 1) == []
|
||||
|
||||
|
||||
class TestCheckDirectory:
|
||||
def test_clean_directory_passes(self, tmp_path: Path):
|
||||
"""A directory with no secret-handling tasks should pass."""
|
||||
role_dir = tmp_path / "roles" / "test_role"
|
||||
(role_dir / "tasks").mkdir(parents=True)
|
||||
(role_dir / "tasks" / "main.yml").write_text(
|
||||
"- name: Normal task\n ansible.builtin.shell: echo hello\n changed_when: false\n"
|
||||
)
|
||||
assert check_directory(role_dir) == []
|
||||
|
||||
def test_unsafe_task_is_caught(self, tmp_path: Path):
|
||||
"""A task with secrets but no no_log should be flagged."""
|
||||
role_dir = tmp_path / "roles" / "test_role"
|
||||
(role_dir / "tasks").mkdir(parents=True)
|
||||
(role_dir / "tasks" / "main.yml").write_text(
|
||||
"- name: Unsafe task\n ansible.builtin.shell: echo {{ _secrets.db_password }}\n changed_when: false\n"
|
||||
)
|
||||
violations = check_directory(role_dir)
|
||||
assert len(violations) == 1
|
||||
assert "Unsafe task" in violations[0]
|
||||
|
||||
def test_molecule_files_are_skipped(self, tmp_path: Path):
|
||||
"""Molecule test files should not be scanned."""
|
||||
role_dir = tmp_path / "roles" / "test_role"
|
||||
mol_dir = role_dir / "molecule" / "default" / "tasks"
|
||||
mol_dir.mkdir(parents=True)
|
||||
(mol_dir / "main.yml").write_text(
|
||||
"- name: Unsafe task in molecule\n ansible.builtin.shell: echo {{ _secrets.db_password }}\n"
|
||||
)
|
||||
assert check_directory(role_dir) == []
|
||||
|
||||
def test_playbook_format_is_parsed(self, tmp_path: Path):
|
||||
"""Playbook files (list of plays with 'hosts') should be parsed."""
|
||||
pb_dir = tmp_path / "playbooks"
|
||||
pb_dir.mkdir(parents=True)
|
||||
(pb_dir / "test.yml").write_text(
|
||||
"---\n"
|
||||
"- name: Test play\n"
|
||||
" hosts: all\n"
|
||||
" tasks:\n"
|
||||
" - name: Unsafe task\n"
|
||||
" ansible.builtin.shell: echo {{ _secrets.db_password }}\n"
|
||||
)
|
||||
violations = check_directory(tmp_path)
|
||||
assert len(violations) == 1
|
||||
assert "Unsafe task" in violations[0]
|
||||
|
||||
def test_invalid_yaml_is_skipped(self, tmp_path: Path):
|
||||
"""Invalid YAML files should be skipped, not crash."""
|
||||
role_dir = tmp_path / "roles" / "test_role"
|
||||
(role_dir / "tasks").mkdir(parents=True)
|
||||
(role_dir / "tasks" / "main.yml").write_text("{{ invalid yaml: [")
|
||||
assert check_directory(role_dir) == []
|
||||
|
||||
def test_empty_yaml_doc_is_skipped(self, tmp_path: Path):
|
||||
"""Empty YAML documents (None) should be skipped."""
|
||||
role_dir = tmp_path / "roles" / "test_role"
|
||||
(role_dir / "tasks").mkdir(parents=True)
|
||||
(role_dir / "tasks" / "main.yml").write_text("---\n")
|
||||
assert check_directory(role_dir) == []
|
||||
|
||||
def test_non_dict_non_list_doc_is_skipped(self, tmp_path: Path):
|
||||
"""YAML docs that are neither dict nor list should be skipped."""
|
||||
role_dir = tmp_path / "roles" / "test_role"
|
||||
(role_dir / "tasks").mkdir(parents=True)
|
||||
(role_dir / "tasks" / "main.yml").write_text("just a string\n")
|
||||
assert check_directory(role_dir) == []
|
||||
|
||||
def test_task_file_with_non_dict_task_skipped(self, tmp_path: Path):
|
||||
"""Non-dict items in a task list should be skipped."""
|
||||
role_dir = tmp_path / "roles" / "test_role"
|
||||
(role_dir / "tasks").mkdir(parents=True)
|
||||
(role_dir / "tasks" / "main.yml").write_text(
|
||||
"- just a string\n- name: Safe task\n ansible.builtin.shell: echo hello\n"
|
||||
)
|
||||
assert check_directory(role_dir) == []
|
||||
|
||||
def test_secret_in_list_value_is_caught(self, tmp_path: Path):
|
||||
"""Secrets inside list values should be caught."""
|
||||
role_dir = tmp_path / "roles" / "test_role"
|
||||
(role_dir / "tasks").mkdir(parents=True)
|
||||
(role_dir / "tasks" / "main.yml").write_text(
|
||||
"- name: Task with list secret\n"
|
||||
" ansible.builtin.set_fact:\n"
|
||||
" items:\n"
|
||||
' - "{{ _secrets.api_key }}"\n'
|
||||
" - normal_value\n"
|
||||
)
|
||||
violations = check_directory(role_dir)
|
||||
assert len(violations) == 1
|
||||
|
||||
def test_single_play_dict_format(self, tmp_path: Path):
|
||||
"""A playbook that's a bare dict (not list of plays) should be parsed."""
|
||||
pb_dir = tmp_path / "playbooks"
|
||||
pb_dir.mkdir(parents=True)
|
||||
(pb_dir / "test.yml").write_text(
|
||||
"---\n"
|
||||
"name: Single play\n"
|
||||
"hosts: all\n"
|
||||
"tasks:\n"
|
||||
" - name: Unsafe task\n"
|
||||
" ansible.builtin.shell: echo {{ _secrets.db_password }}\n"
|
||||
)
|
||||
violations = check_directory(tmp_path)
|
||||
assert len(violations) == 1
|
||||
|
||||
def test_play_with_non_dict_play_skipped(self, tmp_path: Path):
|
||||
"""Non-dict plays in a playbook list should be skipped."""
|
||||
pb_dir = tmp_path / "playbooks"
|
||||
pb_dir.mkdir(parents=True)
|
||||
# First play is valid (makes is_plays=True), second is a non-dict
|
||||
(pb_dir / "test.yml").write_text(
|
||||
"---\n"
|
||||
"- name: Safe play\n"
|
||||
" hosts: all\n"
|
||||
" tasks:\n"
|
||||
" - name: Safe task\n"
|
||||
" ansible.builtin.shell: echo hello\n"
|
||||
'- "just a string as second play"\n'
|
||||
)
|
||||
assert check_directory(tmp_path) == []
|
||||
|
||||
def test_play_with_non_list_tasks_skipped(self, tmp_path: Path):
|
||||
"""Plays where tasks is not a list should be skipped."""
|
||||
pb_dir = tmp_path / "playbooks"
|
||||
pb_dir.mkdir(parents=True)
|
||||
(pb_dir / "test.yml").write_text('---\n- name: Play with bad tasks\n hosts: all\n tasks: "not a list"\n')
|
||||
assert check_directory(tmp_path) == []
|
||||
|
||||
def test_play_with_non_dict_task_in_playbook(self, tmp_path: Path):
|
||||
"""Non-dict tasks in a playbook should be skipped."""
|
||||
pb_dir = tmp_path / "playbooks"
|
||||
pb_dir.mkdir(parents=True)
|
||||
(pb_dir / "test.yml").write_text(
|
||||
"---\n"
|
||||
"- name: Play\n"
|
||||
" hosts: all\n"
|
||||
" tasks:\n"
|
||||
' - "just a string"\n'
|
||||
" - name: Safe task\n"
|
||||
" ansible.builtin.shell: echo hello\n"
|
||||
)
|
||||
assert check_directory(tmp_path) == []
|
||||
|
||||
def test_yaml_file_with_oserror_skipped(self, tmp_path: Path):
|
||||
"""YAML files that can't be opened should be skipped."""
|
||||
role_dir = tmp_path / "roles" / "test_role"
|
||||
(role_dir / "tasks").mkdir(parents=True)
|
||||
# Create a file that will cause OSError when opened
|
||||
# (use a directory with .yml extension)
|
||||
bad_file = role_dir / "tasks" / "main.yml"
|
||||
bad_file.mkdir()
|
||||
assert check_directory(role_dir) == []
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_main_passes_on_clean_dir(self, tmp_path: Path):
|
||||
"""main() should exit 0 on a clean directory."""
|
||||
role_dir = tmp_path / "roles" / "test_role"
|
||||
(role_dir / "tasks").mkdir(parents=True)
|
||||
(role_dir / "tasks" / "main.yml").write_text(
|
||||
"- name: Normal task\n ansible.builtin.shell: echo hello\n changed_when: false\n"
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--path", str(role_dir)])
|
||||
assert result.exit_code == 0
|
||||
assert "OK" in result.output or "no_log" in result.output
|
||||
|
||||
def test_main_fails_on_unsafe_dir(self, tmp_path: Path):
|
||||
"""main() should exit 1 when violations are found."""
|
||||
role_dir = tmp_path / "roles" / "test_role"
|
||||
(role_dir / "tasks").mkdir(parents=True)
|
||||
(role_dir / "tasks" / "main.yml").write_text(
|
||||
"- name: Unsafe task\n ansible.builtin.shell: echo {{ _secrets.db_password }}\n"
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--path", str(role_dir)])
|
||||
assert result.exit_code == 1
|
||||
assert "Unsafe task" in result.output
|
||||
|
||||
def test_main_returns_2_on_missing_dir(self, tmp_path: Path):
|
||||
"""main() should exit 2 when the directory doesn't exist."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--path", str(tmp_path / "nonexistent")])
|
||||
assert result.exit_code == 2
|
||||
|
||||
def test_main_with_ansible_dir_option(self, tmp_path: Path):
|
||||
"""main() --ansible-dir should work like --path."""
|
||||
role_dir = tmp_path / "roles" / "test_role"
|
||||
(role_dir / "tasks").mkdir(parents=True)
|
||||
(role_dir / "tasks" / "main.yml").write_text(
|
||||
"- name: Unsafe task\n ansible.builtin.shell: echo {{ _secrets.db_password }}\n"
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--ansible-dir", str(tmp_path)])
|
||||
assert result.exit_code == 1
|
||||
|
||||
def test_main_no_path_no_ansible_dir_uses_default(self, tmp_path: Path, monkeypatch):
|
||||
"""main() with no args uses DEFAULT_ANSIBLE_DIR."""
|
||||
import devx.tools.check_ansible_no_log as mod
|
||||
|
||||
role_dir = tmp_path / "roles" / "test_role"
|
||||
(role_dir / "tasks").mkdir(parents=True)
|
||||
(role_dir / "tasks" / "main.yml").write_text("- name: Normal task\n ansible.builtin.shell: echo hello\n")
|
||||
monkeypatch.setattr(mod, "DEFAULT_ANSIBLE_DIR", tmp_path)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
@@ -1,198 +0,0 @@
|
||||
"""Unit tests for devx.tools.check_ansible_no_state_absent_on_db."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.check_ansible_no_state_absent_on_db import _check_file, _find_task_files, main
|
||||
|
||||
|
||||
class TestCheckFile:
|
||||
def test_clean_file_no_db_paths(self, tmp_path: Path):
|
||||
"""A file with no DB paths should produce no violations."""
|
||||
p = tmp_path / "test.yml"
|
||||
p.write_text("- name: Safe task\n ansible.builtin.file:\n path: /opt/app/data\n state: directory\n")
|
||||
assert _check_file(p, tmp_path) == []
|
||||
|
||||
def test_state_absent_on_zitadel_db_fails(self, tmp_path: Path):
|
||||
"""state: absent on zitadel-db path should be flagged."""
|
||||
p = tmp_path / "test.yml"
|
||||
p.write_text(
|
||||
"- name: Dangerous wipe\n ansible.builtin.file:\n path: /opt/postgres/zitadel-db\n state: absent\n"
|
||||
)
|
||||
violations = _check_file(p, tmp_path)
|
||||
assert len(violations) >= 1
|
||||
assert "state" in violations[0].lower() or "absent" in violations[0].lower()
|
||||
|
||||
def test_state_absent_on_var_lib_postgresql_fails(self, tmp_path: Path):
|
||||
"""state: absent on /var/lib/postgresql/data should be flagged."""
|
||||
p = tmp_path / "test.yml"
|
||||
p.write_text(
|
||||
"- name: Dangerous wipe\n ansible.builtin.file:\n path: /var/lib/postgresql/data\n state: absent\n"
|
||||
)
|
||||
violations = _check_file(p, tmp_path)
|
||||
assert len(violations) >= 1
|
||||
|
||||
def test_state_absent_on_app_db_fails(self, tmp_path: Path):
|
||||
"""state: absent on any *-db path should be flagged."""
|
||||
p = tmp_path / "test.yml"
|
||||
p.write_text(
|
||||
"- name: Dangerous wipe\n ansible.builtin.file:\n path: /opt/postgres/gitea-db\n state: absent\n"
|
||||
)
|
||||
violations = _check_file(p, tmp_path)
|
||||
assert len(violations) >= 1
|
||||
|
||||
def test_state_absent_with_pg_upgrade_context_passes(self, tmp_path: Path):
|
||||
"""state: absent near DB path with upgrade-postgres context should pass."""
|
||||
p = tmp_path / "test.yml"
|
||||
p.write_text(
|
||||
"- name: PG upgrade — remove old data\n"
|
||||
" ansible.builtin.file:\n"
|
||||
" path: /opt/postgres/zitadel-db\n"
|
||||
" state: absent\n"
|
||||
" when: pg_version_changed | default(false)\n"
|
||||
)
|
||||
violations = _check_file(p, tmp_path)
|
||||
assert violations == []
|
||||
|
||||
def test_state_absent_with_pg_version_context_passes(self, tmp_path: Path):
|
||||
"""state: absent near DB path with PG_VERSION context should pass."""
|
||||
p = tmp_path / "test.yml"
|
||||
p.write_text(
|
||||
"- name: PG upgrade\n"
|
||||
" ansible.builtin.file:\n"
|
||||
" path: /opt/postgres/zitadel-db\n"
|
||||
" state: absent\n"
|
||||
" when: PG_VERSION is defined\n"
|
||||
)
|
||||
violations = _check_file(p, tmp_path)
|
||||
assert violations == []
|
||||
|
||||
def test_state_absent_with_allow_marker_passes(self, tmp_path: Path):
|
||||
"""state: absent with lint:allow-state-absent comment should pass."""
|
||||
p = tmp_path / "test.yml"
|
||||
p.write_text(
|
||||
"# lint:allow-state-absent\n"
|
||||
"- name: Intentional wipe\n"
|
||||
" ansible.builtin.file:\n"
|
||||
" path: /opt/postgres/zitadel-db\n"
|
||||
" state: absent\n"
|
||||
)
|
||||
violations = _check_file(p, tmp_path)
|
||||
assert violations == []
|
||||
|
||||
def test_state_present_on_db_path_passes(self, tmp_path: Path):
|
||||
"""state: present (not absent) on DB path should pass."""
|
||||
p = tmp_path / "test.yml"
|
||||
p.write_text(
|
||||
"- name: Safe task\n ansible.builtin.file:\n path: /opt/postgres/zitadel-db\n state: directory\n"
|
||||
)
|
||||
assert _check_file(p, tmp_path) == []
|
||||
|
||||
def test_nonexistent_file_returns_empty(self):
|
||||
"""A nonexistent file should return no violations."""
|
||||
assert _check_file(Path("/nonexistent/path/file.yml"), Path.cwd()) == []
|
||||
|
||||
def test_rm_rf_db_fails(self, tmp_path: Path):
|
||||
"""rm -rf on a DB path should be flagged."""
|
||||
p = tmp_path / "test.yml"
|
||||
p.write_text("- name: Dangerous wipe\n ansible.builtin.shell: rm -rf /opt/postgres/zitadel-db\n")
|
||||
violations = _check_file(p, tmp_path)
|
||||
assert len(violations) >= 1
|
||||
|
||||
def test_relative_path_outside_repo(self, tmp_path: Path):
|
||||
"""Files outside repo_root use the full path in display."""
|
||||
p = tmp_path / "test.yml"
|
||||
p.write_text(
|
||||
"- name: Dangerous wipe\n ansible.builtin.file:\n path: /opt/postgres/zitadel-db\n state: absent\n"
|
||||
)
|
||||
violations = _check_file(p, Path("/other/repo"))
|
||||
assert len(violations) >= 1
|
||||
assert str(tmp_path) in violations[0] or "test.yml" in violations[0]
|
||||
|
||||
|
||||
class TestFindTaskFiles:
|
||||
def test_find_yml_files_in_directory(self, tmp_path: Path):
|
||||
"""Should find .yml files in a directory."""
|
||||
(tmp_path / "tasks").mkdir()
|
||||
(tmp_path / "tasks" / "main.yml").write_text("[]")
|
||||
(tmp_path / "tasks" / "other.yaml").write_text("[]")
|
||||
files = _find_task_files(tmp_path)
|
||||
assert len(files) == 2
|
||||
|
||||
def test_skip_molecule_files(self, tmp_path: Path):
|
||||
"""Should skip files in molecule directories."""
|
||||
(tmp_path / "molecule").mkdir()
|
||||
(tmp_path / "molecule" / "test.yml").write_text("[]")
|
||||
(tmp_path / "main.yml").write_text("[]")
|
||||
files = _find_task_files(tmp_path)
|
||||
assert len(files) == 1
|
||||
assert "molecule" not in files[0].parts
|
||||
|
||||
def test_single_file_input(self, tmp_path: Path):
|
||||
"""Should return the file itself if it's a .yml file."""
|
||||
f = tmp_path / "test.yml"
|
||||
f.write_text("[]")
|
||||
files = _find_task_files(f)
|
||||
assert files == [f]
|
||||
|
||||
def test_nonexistent_path_returns_empty(self):
|
||||
"""A path that is neither a file nor a dir should return []."""
|
||||
files = _find_task_files(Path("/nonexistent/path/that/does/not/exist"))
|
||||
assert files == []
|
||||
|
||||
def test_non_yaml_file_skipped(self, tmp_path: Path):
|
||||
"""Non-YAML files should not be included."""
|
||||
f = tmp_path / "readme.txt"
|
||||
f.write_text("not yaml")
|
||||
assert _find_task_files(f) == []
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_main_no_violations_exit_zero(self, tmp_path: Path):
|
||||
"""main() with a clean file should exit 0."""
|
||||
f = tmp_path / "clean.yml"
|
||||
f.write_text("- name: Safe task\n ansible.builtin.file:\n path: /opt/app\n state: directory\n")
|
||||
result = CliRunner().invoke(main, ["--path", str(f)])
|
||||
assert result.exit_code == 0
|
||||
assert "OK" in result.output
|
||||
|
||||
def test_main_with_violations_exit_one(self, tmp_path: Path):
|
||||
"""main() with a state: absent on a DB path should exit 1."""
|
||||
f = tmp_path / "dangerous.yml"
|
||||
f.write_text(
|
||||
"- name: Dangerous wipe\n ansible.builtin.file:\n path: /opt/postgres/zitadel-db\n state: absent\n"
|
||||
)
|
||||
result = CliRunner().invoke(main, ["--path", str(f)])
|
||||
assert result.exit_code == 1
|
||||
assert "FAIL" in result.output
|
||||
|
||||
def test_main_path_to_clean_file(self, tmp_path: Path):
|
||||
"""main() --path pointing to a specific clean file should exit 0."""
|
||||
f = tmp_path / "tasks.yml"
|
||||
f.write_text("- name: Safe\n ansible.builtin.file:\n path: /opt/app\n state: directory\n")
|
||||
result = CliRunner().invoke(main, ["--path", str(f)])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_main_default_dirs_no_violations(self, tmp_path: Path, monkeypatch):
|
||||
"""main() with no --path scans default dirs and exits 0."""
|
||||
import devx.tools.check_ansible_no_state_absent_on_db as mod
|
||||
|
||||
(tmp_path / "clean.yml").write_text(
|
||||
"- name: Safe\n ansible.builtin.file:\n path: /opt/app\n state: directory\n"
|
||||
)
|
||||
monkeypatch.setattr(mod, "DEFAULT_ANSIBLE_DIRS", [tmp_path])
|
||||
result = CliRunner().invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "OK" in result.output
|
||||
|
||||
def test_main_custom_ansible_dirs(self, tmp_path: Path):
|
||||
"""main() --ansible-dir should work."""
|
||||
f = tmp_path / "dangerous.yml"
|
||||
f.write_text(
|
||||
"- name: Dangerous wipe\n ansible.builtin.file:\n path: /opt/postgres/zitadel-db\n state: absent\n"
|
||||
)
|
||||
result = CliRunner().invoke(main, ["--ansible-dir", str(tmp_path)])
|
||||
assert result.exit_code == 1
|
||||
@@ -1,340 +0,0 @@
|
||||
"""Unit tests for devx.tools.check_ansible_patterns."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.check_ansible_patterns import (
|
||||
_check_file,
|
||||
_check_task,
|
||||
_check_tasks,
|
||||
_find_task_files,
|
||||
_is_legitimate_devnull,
|
||||
_is_legitimate_or_true,
|
||||
main,
|
||||
)
|
||||
|
||||
|
||||
def _make_task(name: str, action: str, value: str, **extra: object) -> dict:
|
||||
"""Build a minimal task dict for testing."""
|
||||
task: dict = {"name": name, action: value}
|
||||
task.update(extra)
|
||||
return task
|
||||
|
||||
|
||||
class TestIsLegitimateOrTrue:
|
||||
def test_cleanup_task_name_is_legitimate(self):
|
||||
assert _is_legitimate_or_true("docker rm old-container", "Remove old container")
|
||||
|
||||
def test_prune_task_name_is_legitimate(self):
|
||||
assert _is_legitimate_or_true("docker image prune -f", "Prune unused images")
|
||||
|
||||
def test_docker_rm_command_is_legitimate(self):
|
||||
assert _is_legitimate_or_true("docker rm -f mycontainer", "Some task")
|
||||
|
||||
def test_provision_task_is_not_legitimate(self):
|
||||
assert not _is_legitimate_or_true("curl -X POST https://api/app || true", "Provision OIDC client")
|
||||
|
||||
def test_sync_task_name_is_legitimate(self):
|
||||
assert _is_legitimate_or_true("psql -c 'ALTER USER' || true", "Sync PostgreSQL password")
|
||||
|
||||
|
||||
class TestCheckTask:
|
||||
def test_or_true_on_provision_task_fails(self, tmp_path: Path):
|
||||
task = _make_task(
|
||||
"Provision OIDC client",
|
||||
"ansible.builtin.shell",
|
||||
"curl -X POST https://zitadel/api || true",
|
||||
)
|
||||
violations = _check_task(task, tmp_path / "test.yml", 1, tmp_path)
|
||||
assert len(violations) >= 1
|
||||
assert "|| true" in violations[0]
|
||||
|
||||
def test_or_true_on_cleanup_task_passes(self, tmp_path: Path):
|
||||
task = _make_task(
|
||||
"Remove old container",
|
||||
"ansible.builtin.shell",
|
||||
"docker rm -f old-container || true",
|
||||
)
|
||||
assert _check_task(task, tmp_path / "test.yml", 1, tmp_path) == []
|
||||
|
||||
def test_failed_when_false_on_provision_fails(self, tmp_path: Path):
|
||||
task = _make_task(
|
||||
"Provision OIDC client",
|
||||
"ansible.builtin.shell",
|
||||
"curl -X POST https://zitadel/api",
|
||||
failed_when=False,
|
||||
)
|
||||
violations = _check_task(task, tmp_path / "test.yml", 1, tmp_path)
|
||||
assert any("failed_when" in v for v in violations)
|
||||
|
||||
def test_failed_when_false_on_stop_passes(self, tmp_path: Path):
|
||||
task = _make_task(
|
||||
"Stop ZITADEL containers",
|
||||
"ansible.builtin.shell",
|
||||
"docker stop zitadel",
|
||||
failed_when=False,
|
||||
)
|
||||
assert _check_task(task, tmp_path / "test.yml", 1, tmp_path) == []
|
||||
|
||||
def test_failed_when_false_on_check_passes(self, tmp_path: Path):
|
||||
task = _make_task(
|
||||
"Check if ZITADEL is running",
|
||||
"ansible.builtin.shell",
|
||||
"docker inspect zitadel",
|
||||
failed_when=False,
|
||||
)
|
||||
assert _check_task(task, tmp_path / "test.yml", 1, tmp_path) == []
|
||||
|
||||
def test_allow_marker_in_name_passes(self, tmp_path: Path):
|
||||
task = _make_task(
|
||||
"Provision OIDC #lint:allow-failure-masking",
|
||||
"ansible.builtin.shell",
|
||||
"curl -X POST https://zitadel/api || true",
|
||||
failed_when=False,
|
||||
)
|
||||
assert _check_task(task, tmp_path / "test.yml", 1, tmp_path) == []
|
||||
|
||||
def test_safe_task_no_violations(self, tmp_path: Path):
|
||||
task = _make_task(
|
||||
"Create directory",
|
||||
"ansible.builtin.file",
|
||||
"path=/opt/app state=directory",
|
||||
)
|
||||
assert _check_task(task, tmp_path / "test.yml", 1, tmp_path) == []
|
||||
|
||||
def test_relative_path_outside_repo(self, tmp_path: Path):
|
||||
"""Files outside repo_root use the full path in display."""
|
||||
task = _make_task(
|
||||
"Provision OIDC",
|
||||
"ansible.builtin.shell",
|
||||
"curl || true",
|
||||
)
|
||||
other_dir = Path("/tmp/other")
|
||||
violations = _check_task(task, other_dir / "test.yml", 1, tmp_path)
|
||||
assert len(violations) >= 1
|
||||
|
||||
|
||||
class TestCheckFile:
|
||||
def test_clean_file_passes(self, tmp_path: Path):
|
||||
p = tmp_path / "test.yml"
|
||||
p.write_text("- name: Safe task\n ansible.builtin.file:\n path: /opt/app\n state: directory\n")
|
||||
assert _check_file(p, tmp_path) == []
|
||||
|
||||
def test_dangerous_pattern_detected(self, tmp_path: Path):
|
||||
p = tmp_path / "test.yml"
|
||||
p.write_text(
|
||||
"- name: Provision OIDC\n"
|
||||
" ansible.builtin.shell: |\n"
|
||||
" curl -X POST https://api/app || true\n"
|
||||
" failed_when: false\n"
|
||||
)
|
||||
violations = _check_file(p, tmp_path)
|
||||
assert len(violations) >= 1
|
||||
|
||||
def test_file_level_allow_marker_passes(self, tmp_path: Path):
|
||||
p = tmp_path / "test.yml"
|
||||
p.write_text(
|
||||
"# lint:allow-failure-masking\n"
|
||||
"- name: Provision OIDC\n"
|
||||
" ansible.builtin.shell: |\n"
|
||||
" curl -X POST https://api/app || true\n"
|
||||
" failed_when: false\n"
|
||||
)
|
||||
assert _check_file(p, tmp_path) == []
|
||||
|
||||
def test_nonexistent_file_returns_empty(self):
|
||||
assert _check_file(Path("/nonexistent/path/file.yml"), Path.cwd()) == []
|
||||
|
||||
def test_yaml_parse_error_returns_empty(self, tmp_path: Path):
|
||||
p = tmp_path / "test.yml"
|
||||
p.write_text("name: Provision OIDC\n shell: curl || true\n: invalid: [")
|
||||
assert _check_file(p, tmp_path) == []
|
||||
|
||||
def test_dict_doc_playbook_with_tasks(self, tmp_path: Path):
|
||||
p = tmp_path / "playbook.yml"
|
||||
p.write_text(
|
||||
"- hosts: all\n"
|
||||
" tasks:\n"
|
||||
" - name: Provision OIDC\n"
|
||||
" ansible.builtin.shell: curl -X POST https://api/app || true\n"
|
||||
)
|
||||
violations = _check_file(p, tmp_path)
|
||||
assert any("|| true" in v for v in violations)
|
||||
|
||||
def test_dict_doc_with_pre_tasks_and_post_tasks(self, tmp_path: Path):
|
||||
p = tmp_path / "playbook.yml"
|
||||
p.write_text(
|
||||
"- hosts: all\n"
|
||||
" pre_tasks:\n"
|
||||
" - name: Provision secret\n"
|
||||
" ansible.builtin.shell: curl -X POST https://api/app || true\n"
|
||||
" post_tasks:\n"
|
||||
" - name: Provision OIDC\n"
|
||||
" ansible.builtin.shell: curl -X POST https://api/app || true\n"
|
||||
" handlers:\n"
|
||||
" - name: Provision password\n"
|
||||
" ansible.builtin.shell: curl -X POST https://api/app || true\n"
|
||||
)
|
||||
violations = _check_file(p, tmp_path)
|
||||
assert len(violations) >= 3
|
||||
|
||||
def test_block_tasks_in_list_item(self, tmp_path: Path):
|
||||
p = tmp_path / "tasks.yml"
|
||||
p.write_text(
|
||||
"- name: Outer task\n"
|
||||
" block:\n"
|
||||
" - name: Provision OIDC\n"
|
||||
" ansible.builtin.shell: curl -X POST https://api/app || true\n"
|
||||
" - name: Provision secret\n"
|
||||
" ansible.builtin.shell: curl -X POST https://api/app || true\n"
|
||||
)
|
||||
violations = _check_file(p, tmp_path)
|
||||
assert any("|| true" in v for v in violations)
|
||||
|
||||
def test_empty_doc_skipped(self, tmp_path: Path):
|
||||
p = tmp_path / "test.yml"
|
||||
p.write_text("---\nnull\n---\n- name: Provision OIDC\n ansible.builtin.shell: curl || true\n")
|
||||
violations = _check_file(p, tmp_path)
|
||||
assert any("|| true" in v for v in violations)
|
||||
|
||||
def test_pure_dict_doc_with_tasks(self, tmp_path: Path):
|
||||
p = tmp_path / "playbook.yml"
|
||||
p.write_text(
|
||||
"hosts: all\n"
|
||||
"tasks:\n"
|
||||
" - name: Provision OIDC\n"
|
||||
" ansible.builtin.shell: curl -X POST https://api/app || true\n"
|
||||
)
|
||||
violations = _check_file(p, tmp_path)
|
||||
assert any("|| true" in v for v in violations)
|
||||
|
||||
|
||||
class TestIsLegitimateDevnull:
|
||||
def test_cleanup_task_is_legitimate(self):
|
||||
assert _is_legitimate_devnull("docker rm old-container 2>/dev/null", "Remove old container")
|
||||
|
||||
def test_provision_task_is_not_legitimate(self):
|
||||
assert not _is_legitimate_devnull("curl -X POST https://api/app 2>/dev/null", "Provision OIDC client")
|
||||
|
||||
|
||||
class TestCheckTasks:
|
||||
def test_tasks_section_checked(self, tmp_path: Path):
|
||||
doc = {
|
||||
"tasks": [
|
||||
{"name": "Provision OIDC", "ansible.builtin.shell": "curl || true"},
|
||||
],
|
||||
}
|
||||
errors: list[str] = []
|
||||
_check_tasks(doc, tmp_path / "test.yml", errors, tmp_path)
|
||||
assert any("|| true" in e for e in errors)
|
||||
|
||||
def test_block_inside_tasks_section(self, tmp_path: Path):
|
||||
doc = {
|
||||
"tasks": [
|
||||
{
|
||||
"name": "Outer",
|
||||
"block": [
|
||||
{"name": "Provision secret", "ansible.builtin.shell": "curl || true"},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
errors: list[str] = []
|
||||
_check_tasks(doc, tmp_path / "test.yml", errors, tmp_path)
|
||||
assert any("|| true" in e for e in errors)
|
||||
|
||||
def test_non_list_section_ignored(self, tmp_path: Path):
|
||||
doc = {"tasks": "not a list"}
|
||||
errors: list[str] = []
|
||||
_check_tasks(doc, tmp_path / "test.yml", errors, tmp_path)
|
||||
assert errors == []
|
||||
|
||||
def test_non_dict_task_ignored(self, tmp_path: Path):
|
||||
doc = {"tasks": ["just a string"]}
|
||||
errors: list[str] = []
|
||||
_check_tasks(doc, tmp_path / "test.yml", errors, tmp_path)
|
||||
assert errors == []
|
||||
|
||||
|
||||
class TestFindTaskFiles:
|
||||
def test_single_file(self, tmp_path: Path):
|
||||
p = tmp_path / "main.yml"
|
||||
p.write_text("- name: test\n")
|
||||
assert _find_task_files(p) == [p]
|
||||
|
||||
def test_single_yaml_file(self, tmp_path: Path):
|
||||
p = tmp_path / "main.yaml"
|
||||
p.write_text("- name: test\n")
|
||||
assert _find_task_files(p) == [p]
|
||||
|
||||
def test_non_yaml_file_returns_empty(self, tmp_path: Path):
|
||||
p = tmp_path / "main.txt"
|
||||
p.write_text("hello\n")
|
||||
assert _find_task_files(p) == []
|
||||
|
||||
def test_directory_finds_yaml_files(self, tmp_path: Path):
|
||||
(tmp_path / "a.yml").write_text("- name: a\n")
|
||||
(tmp_path / "sub").mkdir()
|
||||
(tmp_path / "sub" / "b.yaml").write_text("- name: b\n")
|
||||
(tmp_path / "ignore.txt").write_text("nope\n")
|
||||
result = _find_task_files(tmp_path)
|
||||
names = {f.name for f in result}
|
||||
assert names == {"a.yml", "b.yaml"}
|
||||
|
||||
def test_directory_skips_molecule(self, tmp_path: Path):
|
||||
(tmp_path / "a.yml").write_text("- name: a\n")
|
||||
(tmp_path / "molecule").mkdir()
|
||||
(tmp_path / "molecule" / "scenario.yml").write_text("- name: mol\n")
|
||||
result = _find_task_files(tmp_path)
|
||||
assert all("molecule" not in f.parts for f in result)
|
||||
|
||||
def test_nonexistent_path_returns_empty(self):
|
||||
assert _find_task_files(Path("/nonexistent/path/xyz")) == []
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_main_clean_file_exit_zero(self, tmp_path: Path):
|
||||
p = tmp_path / "clean.yml"
|
||||
p.write_text("- name: Safe task\n ansible.builtin.file:\n path: /opt/app\n state: directory\n")
|
||||
result = CliRunner().invoke(main, ["--path", str(p)])
|
||||
assert result.exit_code == 0
|
||||
assert "OK" in result.output
|
||||
|
||||
def test_main_violation_exit_one(self, tmp_path: Path):
|
||||
p = tmp_path / "bad.yml"
|
||||
p.write_text(
|
||||
"- name: Provision OIDC\n"
|
||||
" ansible.builtin.shell: curl -X POST https://api/app || true\n"
|
||||
" failed_when: false\n"
|
||||
)
|
||||
result = CliRunner().invoke(main, ["--path", str(p)])
|
||||
assert result.exit_code == 1
|
||||
assert "FAIL" in result.output
|
||||
|
||||
def test_main_directory(self, tmp_path: Path):
|
||||
(tmp_path / "clean.yml").write_text(
|
||||
"- name: Safe task\n ansible.builtin.file:\n path: /opt\n state: directory\n"
|
||||
)
|
||||
result = CliRunner().invoke(main, ["--path", str(tmp_path)])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_main_default_dirs(self, tmp_path: Path, monkeypatch):
|
||||
import devx.tools.check_ansible_patterns as mod
|
||||
|
||||
(tmp_path / "clean.yml").write_text(
|
||||
"- name: Safe task\n ansible.builtin.file:\n path: /opt\n state: directory\n"
|
||||
)
|
||||
monkeypatch.setattr(mod, "DEFAULT_ANSIBLE_DIRS", [tmp_path])
|
||||
result = CliRunner().invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "OK" in result.output
|
||||
|
||||
def test_main_custom_ansible_dirs(self, tmp_path: Path):
|
||||
(tmp_path / "bad.yml").write_text(
|
||||
"- name: Provision OIDC\n ansible.builtin.shell: curl -X POST https://api/app || true\n"
|
||||
)
|
||||
result = CliRunner().invoke(main, ["--ansible-dir", str(tmp_path)])
|
||||
assert result.exit_code == 1
|
||||
@@ -1,264 +0,0 @@
|
||||
"""Unit tests for devx.tools.check_jinja_expr.
|
||||
|
||||
Verifies that the check correctly validates Jinja2 expressions,
|
||||
catches reversed strftime filter arguments (the OBL-INFRA-508 bug),
|
||||
and passes on valid expressions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.check_jinja_expr import (
|
||||
_check_file,
|
||||
_default_ansible_dirs,
|
||||
_extract_expressions,
|
||||
_render_expression,
|
||||
main,
|
||||
)
|
||||
|
||||
|
||||
def test_render_valid_expression():
|
||||
"""Valid Jinja expression renders without error."""
|
||||
ok, _ = _render_expression("'%Y-%m-%dT%H:%M:%S+00:00' | strftime(1735689600)")
|
||||
assert ok
|
||||
|
||||
|
||||
def test_render_reversed_strftime_args():
|
||||
"""Reversed strftime filter args are detected as an error."""
|
||||
ok, msg = _render_expression("(now().timestamp() | int + 3600) | strftime('%Y-%m-%dT%H:%M:%S+00:00')")
|
||||
assert not ok
|
||||
assert "reversed" in msg.lower()
|
||||
|
||||
|
||||
def test_render_correct_strftime_args():
|
||||
"""Correct strftime filter args pass."""
|
||||
ok, _ = _render_expression("'%Y-%m-%dT%H:%M:%S+00:00' | strftime((now().timestamp() | int) + 3600)")
|
||||
assert ok
|
||||
|
||||
|
||||
def test_render_unknown_filter():
|
||||
"""Unknown filter is reported as an error."""
|
||||
ok, msg = _render_expression("'test' | nonexistent_filter")
|
||||
assert not ok
|
||||
assert "filter" in msg.lower()
|
||||
|
||||
|
||||
def test_extract_skips_go_templates():
|
||||
"""Go template syntax ({{.Field}}) is not extracted."""
|
||||
content = "cmd: docker inspect --format '{{.State.Running}}' container"
|
||||
expressions = _extract_expressions(content)
|
||||
assert len(expressions) == 0
|
||||
|
||||
|
||||
def test_extract_skips_single_char():
|
||||
"""Single-character fragments are not extracted."""
|
||||
content = 'value: "{{ \' }}"'
|
||||
expressions = _extract_expressions(content)
|
||||
assert len(expressions) == 0
|
||||
|
||||
|
||||
def test_extract_skips_multiline():
|
||||
"""Multi-line expressions are skipped."""
|
||||
content = 'value: "{{\n something\n}}"'
|
||||
expressions = _extract_expressions(content)
|
||||
assert len(expressions) == 0
|
||||
|
||||
|
||||
def test_extract_skips_unbalanced():
|
||||
"""Expressions with unbalanced braces (from partial capture) are skipped."""
|
||||
content = "value: \"{{ default({'k': {}}, true) }}\""
|
||||
expressions = _extract_expressions(content)
|
||||
# The regex captures {{ default({'k': {}} — unbalanced parens
|
||||
# because the inner }} terminates the match early.
|
||||
# All extracted expressions should have balanced braces.
|
||||
for expr in expressions:
|
||||
assert expr.count("{") == expr.count("}")
|
||||
|
||||
|
||||
def test_extract_valid_expression():
|
||||
"""Valid Jinja expressions are extracted."""
|
||||
content = "value: \"{{ my_var | default('x') }}\""
|
||||
expressions = _extract_expressions(content)
|
||||
assert "my_var | default('x')" in expressions
|
||||
|
||||
|
||||
def test_main_passes_on_clean_file(tmp_path: Path) -> None:
|
||||
"""A file with valid expressions passes."""
|
||||
test_file = tmp_path / "tasks.yml"
|
||||
test_file.write_text("value: \"{{ my_var | default('x') }}\"\nother: \"{{ '%Y' | strftime(1735689600) }}\"\n")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--path", str(test_file)])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_main_no_violations_empty_dir(tmp_path: Path) -> None:
|
||||
"""An empty directory passes."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--path", str(tmp_path)])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_main_catches_reversed_strftime(tmp_path: Path) -> None:
|
||||
"""A file with reversed strftime args is flagged."""
|
||||
test_file = tmp_path / "test.yml"
|
||||
test_file.write_text("value: \"{{ (now().timestamp() | int + 3600) | strftime('%Y-%m-%dT%H:%M:%S+00:00') }}\"\n")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--path", str(test_file)])
|
||||
assert result.exit_code == 1
|
||||
assert "reversed" in result.output.lower()
|
||||
|
||||
|
||||
def test_render_skips_undefined_var():
|
||||
"""Undefined variables are skipped (MockDict returns mock for missing keys)."""
|
||||
ok, _ = _render_expression("nonexistent_var_in_mock | upper")
|
||||
assert ok
|
||||
|
||||
|
||||
def test_render_skips_other_errors():
|
||||
"""Non-filter errors from missing mocks are skipped."""
|
||||
ok, _ = _render_expression("some_undefined.attr.method()")
|
||||
assert ok
|
||||
|
||||
|
||||
def test_extract_skips_backtick():
|
||||
"""Backtick fragments are skipped (caught by single-char check)."""
|
||||
content = 'value: "{{ ` }}"'
|
||||
expressions = _extract_expressions(content)
|
||||
assert len(expressions) == 0
|
||||
|
||||
|
||||
def test_extract_skips_network_settings():
|
||||
"""Expressions with .NetworkSettings. patterns are skipped."""
|
||||
content = 'value: "{{ foo.NetworkSettings.IPAddress }}"'
|
||||
expressions = _extract_expressions(content)
|
||||
assert len(expressions) == 0
|
||||
|
||||
|
||||
def test_extract_skips_unbalanced_parens():
|
||||
"""Expressions with unbalanced parens are skipped."""
|
||||
content = 'value: "{{ foo(bar }}"'
|
||||
expressions = _extract_expressions(content)
|
||||
assert len(expressions) == 0
|
||||
|
||||
|
||||
def test_extract_skips_unbalanced_braces():
|
||||
"""Expressions with unbalanced braces are skipped."""
|
||||
content = 'value: "{{ foo{bar }}"'
|
||||
expressions = _extract_expressions(content)
|
||||
assert len(expressions) == 0
|
||||
|
||||
|
||||
def test_extract_skips_unbalanced_brackets():
|
||||
"""Expressions with unbalanced brackets are skipped."""
|
||||
content = 'value: "{{ foo[0 }}"'
|
||||
expressions = _extract_expressions(content)
|
||||
assert len(expressions) == 0
|
||||
|
||||
|
||||
def test_extract_skips_control_flow():
|
||||
"""Control flow fragments starting with % are skipped."""
|
||||
content = 'value: "{{ % if x }}"'
|
||||
expressions = _extract_expressions(content)
|
||||
assert len(expressions) == 0
|
||||
|
||||
|
||||
def test_check_file_outside_repo(tmp_path: Path) -> None:
|
||||
"""Files outside REPO_ROOT are handled (no relative_to error)."""
|
||||
test_file = tmp_path / "test.yml"
|
||||
test_file.write_text("value: \"{{ (now().timestamp() | int + 3600) | strftime('%Y-%m-%dT%H:%M:%S+00:00') }}\"\n")
|
||||
violations = _check_file(test_file, Path("/other/repo"))
|
||||
assert len(violations) == 1
|
||||
assert "reversed" in violations[0].lower()
|
||||
|
||||
|
||||
def test_render_mock_dict_missing_key():
|
||||
"""MockDict returns a mock for missing keys (no UndefinedError)."""
|
||||
ok, _ = _render_expression("undefined_var.some_attr | upper")
|
||||
assert ok
|
||||
|
||||
|
||||
def test_render_syntax_error():
|
||||
"""Syntax errors are reported as failures."""
|
||||
ok, msg = _render_expression("{{ invalid syntax +")
|
||||
assert not ok
|
||||
assert "Syntax error" in msg
|
||||
|
||||
|
||||
def test_render_unknown_filter_error():
|
||||
"""Unknown filters are reported as failures (not skipped)."""
|
||||
ok, msg = _render_expression("'test' | nonexistent_filter")
|
||||
assert not ok
|
||||
assert "filter" in msg.lower()
|
||||
|
||||
|
||||
def test_render_generic_exception_skipped():
|
||||
"""Non-filter exceptions from missing mocks are skipped."""
|
||||
# replace() with no args triggers TypeError (missing required args)
|
||||
# which is not a filter-not-found or strftime error — should be skipped.
|
||||
ok, msg = _render_expression("my_var | replace")
|
||||
assert ok
|
||||
assert "Skipped" in msg
|
||||
|
||||
|
||||
def test_default_ansible_dirs():
|
||||
"""_default_ansible_dirs returns playbooks and roles paths."""
|
||||
dirs = _default_ansible_dirs()
|
||||
assert Path.cwd() / "ansible" / "playbooks" in dirs
|
||||
assert Path.cwd() / "ansible" / "roles" in dirs
|
||||
|
||||
|
||||
def test_main_default_dirs(tmp_path: Path) -> None:
|
||||
"""Running with no --path scans default dirs (uses small temp fixture)."""
|
||||
(tmp_path / "playbooks").mkdir()
|
||||
(tmp_path / "roles").mkdir()
|
||||
(tmp_path / "playbooks" / "test.yml").write_text("value: \"{{ my_var | default('x') }}\"\n")
|
||||
with patch(
|
||||
"devx.tools.check_jinja_expr._default_ansible_dirs",
|
||||
return_value=[tmp_path / "playbooks", tmp_path / "roles"],
|
||||
):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_main_custom_ansible_dirs(tmp_path: Path) -> None:
|
||||
"""--ansible-dir option works."""
|
||||
(tmp_path / "test.yml").write_text("value: \"{{ my_var | default('x') }}\"\n")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--ansible-dir", str(tmp_path)])
|
||||
assert result.exit_code == 0
|
||||
|
||||
|
||||
def test_extract_skips_println():
|
||||
"""Expressions with 'println' (Go template) are skipped."""
|
||||
content = 'value: "{{ println something }}"'
|
||||
expressions = _extract_expressions(content)
|
||||
assert len(expressions) == 0
|
||||
|
||||
|
||||
def test_extract_skips_state_dot():
|
||||
"""Expressions with .State. patterns are skipped."""
|
||||
content = 'value: "{{ foo.State.Running }}"'
|
||||
expressions = _extract_expressions(content)
|
||||
assert len(expressions) == 0
|
||||
|
||||
|
||||
def test_render_now_with_format():
|
||||
"""now() with a format argument works."""
|
||||
ok, _ = _render_expression("now('%Y-%m-%d')")
|
||||
assert ok
|
||||
|
||||
|
||||
def test_find_yaml_files_skips_molecule(tmp_path: Path) -> None:
|
||||
"""Molecule directories are excluded from file search."""
|
||||
from devx.tools.check_jinja_expr import _find_yaml_files
|
||||
|
||||
(tmp_path / "tasks.yml").write_text("value: test\n")
|
||||
(tmp_path / "molecule").mkdir()
|
||||
(tmp_path / "molecule" / "test.yml").write_text("value: test\n")
|
||||
files = _find_yaml_files(tmp_path)
|
||||
assert all("molecule" not in f.parts for f in files)
|
||||
Reference in New Issue
Block a user