mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-10 04:36:40 +00:00
fix/dev-python/python-upper-bound
14682
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e0eda30fbe |
style(e2e): drop four more timeout arguments isVisible discards
Same class as
|
||
|
|
642055a20a |
style(e2e): drop the timeout argument isVisible discards
Playwright declares the timeout option on locator.isVisible() and isHidden() as deprecated and ignored: both return immediately and never wait. Passing resolveTimeout(10_000) to one therefore changes nothing at runtime and only tells the reader a wait happened that never did. That reading is what let |
||
|
|
a77bc07d4a |
fix(espocrm): take the original entrypoint path from the spot
The path to the image entrypoint the build patches was written out four times in the Dockerfile, and /usr/local/bin twice more in vars. It now comes from ESPOCRM_BIN_DIR through ESPOCRM_ENTRYPOINT_SCRIPT_ORIGINAL, declared as a build ARG after FROM and passed from the compose build args. The runtime side already reads the same value from the environment, because a build ARG does not exist at runtime.
The sed expressions themselves are byte-identical. Only the file operand changed, and the variable sits outside the single quotes, so the delimiter and ampersand escaping that
|
||
|
|
b97cf88881 |
fix(e2e): let the admin persona wait out the OIDC popup handoff
Playwright declares the timeout option on locator.isVisible() and isHidden() as deprecated and ignored - both return immediately. |
||
|
|
2389b6e38b |
fix(gitlab): discover the OIDC issuer over the scheme it actually uses
GitLab could not log anyone in through Keycloak on an onion deployment. The sign-in page carried the omniauth flash: Could not authenticate you from OpenIDConnect because Ssl connect returned=1 errno=0 peeraddr=172.30.0.10:443 state=error: tlsv1 unrecognized name (ssl alert number 112), and gitlab-webservice logged OpenIDConnect::Discovery::DiscoveryFailed eleven times. The openid_connect gem rebuilds the well-known URL and never reads the scheme of the issuer. Its config resource also drops the port when it is 80 or 443, so http://auth.onion loses port 80 and URI::HTTPS.build puts 443 back. The hidden service publishes port 80 only, so 443 reaches the SNI-less default listener, which answers ssl_reject_handshake - alert 112, exactly the string in the flash. An initializer sets SWD.url_builder and WebFinger.url_builder to URI::HTTP when the issuer is plaintext. These are the same two lines web-app-decidim already ships for the identical bug, and this repo has one such fix per role for zammad, mastodon, decidim and peertube; gitlab was the gap. The mount copies the smtp_settings.rb initializer pattern onto webservice and sidekiq. The scheme test lives in the ruby file rather than in the render and mount conditions, which both gate on services.sso.enabled alone. Two separately spelled conditions could drift once an inventory sets OIDC_OVERRIDE.URL, and would then either render a file that is never mounted or mount one that was never rendered. Boot safety: omniauth_openid_connect and openid_connect are default-group Gemfile entries without require: false, so Bundler.require loads them before the initializers run, and openid_connect requires swd and webfinger itself. Both writers are public and assign an ivar. GitLab never assigns url_builder anywhere. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
c2018354ab |
fix(espocrm): set the config keys the app actually reads
Two settings never reached EspoCRM. The image entrypoint maps a double underscore to a dot and a single one to camelCase, so ESPOCRM_CONFIG_LOGGER_PATH wrote a flat loggerPath that nothing reads, while the real key is nested under logger. The level therefore stayed at the WARNING default, which is why Internal server error - Check logs for details had no logs behind it. The value php://stdout was never in effect either, and that was luck: EspoFileHandler raises RuntimeException on it and EspoCRM installs no exception handler, so making the key live with that value would have turned a working file logger into a throwing one. The path and rotation are now written explicitly at the values that were already in effect. LdapLogin builds its search as (&(objectClass=X)(userNameAttribute=user)(loginFilter)) - the filter is ANDed on top, it does not replace anything. ldapUserNameAttribute defaults to sAMAccountName, an Active Directory attribute, while this directory is OpenLDAP with uid, so the previous filter produced a term that could never match. Setting the attribute is the knob that decides matching; web-app-semaphore sets both for the same reason. ldapCreateEspoUser decides whether a user who authenticated against LDAP but has no EspoCRM record gets one created. Without it such a login is refused with USER_NOT_FOUND. It is exposed as services.ldap.autocreate rather than hardcoded, because it shifts who may enter from an explicit EspoCRM account to directory membership; the created user gets no team and no role. The log directory now has one spot in vars, and the entrypoint derives both the streamed filename and the init diagnostics from the same value. It streams data/logs/<stem>-<Y-m-d>.log to stdout so the rescue collector picks it up unchanged; the date is re-evaluated because EspoRotatingFileHandler renames at midnight and a single tail would go silent there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8ad639d7f8 |
test(nextcloud): record the Mastodon authorize handoff instead of racing it
The spec polled the live URL until it read /oauth/authorize?, a state that cannot last: Mastodon answers that endpoint with a 302 to /auth/sign_in for any session-less browser, in 5.39 ms per its own log, while expect.poll samples roughly every 100 ms. The test was chasing something it is structurally unable to observe, and reported the bridge as broken when it was working. The listener is registered on the context rather than the page so the popup is covered too, and after every preceding navigation so no earlier request is captured. currentUrl now prefers the recorded request and otherwise behaves exactly as before. All four downstream assertions - host is not Nextcloud, host is the partner, client_id present, response_type=code - are untouched and now read a complete URL instead of one that has already gone. Not a masked SSO defect: the partner Mastodon has OIDC_ENABLED, OMNIAUTH_ONLY and ONE_CLICK_SSO_LOGIN all true, and its log shows discovery, token, certs and userinfo each returning 200 over the onion with a successful biber login. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0e39f3fad5 |
fix(mobilizon): pin the SSO back-channel so the token exchange can connect
The server-side code-to-token call died with OAuth2.Error Connection refused, 24 times, on every /auth/keycloak/callback. The oauth2 library prints that string only for :econnrefused, so the name resolved and the connect was refused - not nxdomain, not a timeout, and not a credential or redirect_uri mismatch, which would have come back as an HTTP 400. openresty, the only ingress for the issuer vhost, logged 14 token POSTs in that window and not one from the Mobilizon address. container_extra_hosts is the single sanctioned emitter for this pin, and its contract describes exactly this case: on an onion deployment the OIDC endpoints handed to an application are public .onion authorities, right for the browser and unroutable for its own server-side calls. |
||
|
|
bbf4ca09cc |
fix(nextcloud): let occ read a credential that starts with a dash
Symfony ArgvInput reads a token beginning with a dash as an option, so passing the secret as a separate token after --value handed occ two tokens and occ reported the option as missing its value. Doorkeeper mints base64url secrets, so roughly one run in 43 produced one starting with a dash and the OpenProject provisioning died with: The "--value" option requires a value. The equals form is unambiguous: Symfony splits at the first equals sign and takes everything after it as the value. The quote filter replaces the hand-written single quotes and stays shell-safe. Note that shlex leaves a leading-dash value unquoted, so the equals form is what carries the fix, not the quoting. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
adc1aa3bfd |
style(tests): satisfy the ruff rule the addon-pinning lint tripped
make quality-high stopped at autoformat: ruff reported PERF401 in the addon variant-pinning lint, whose third check collected findings through a loop whose entire body was findings.append(). It hands a generator to findings.extend() now. The two sibling checks were not flagged -- their `continue` guard makes them something other than a pure transformation. Rewriting a finding collector can silence a rule while every test still passes, so the rule was broken again afterwards: a dynamic addon pinned in variant 1 still reports all eight variants, the same set as before the change. The rest is what the formatters rewrote by themselves -- a trailing blank line in the ganesha task file and two over-long lines wrapped in the volume spot lints. make quality-high is green end to end after this: four test suites, ansible-lint 0 failures over 3632 files at the production profile, eleven lint targets. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
39e0ac821d |
fix(prometheus): give back the image names the path refactor swallowed
f06a3cfad replaced every spelled-out occurrence of a path declared in
meta/volumes.yml with a lookup of that volume's target. Two of those paths sit
inside image names, where they are the repository part of prom/prometheus and
prom/alertmanager rather than a mount point:
image: prom{{ lookup('volume', 'web-app-prometheus', 'data').target }}
The rendered value stayed correct -- /prometheus is exactly what the lookup
returns -- but the image field carried Jinja from then on, and
TestDockerServicesImageVersionValid rejects that. make quality-high failed at
test-integration with both entries named.
The sibling lines prove the rewrite followed the volume declarations rather than
the field it was editing: prom/blackbox-exporter and prom/node-exporter kept
their literals, because their suffixes are not declared targets.
Both are literals again, each with a per-line opt-out naming why this path
cannot derive from the registry.
Measured: test_services_image_version_valid and tests/lint/repository/spot/ --
the lint that forced the rewrite -- pass together, and make quality-high is green
end to end afterwards: four test suites, ansible-lint 0 failures over 3632 files
at the production profile, eleven lint targets.
f06a3cfad is still unpushed, so this never reached CI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
46baeeda5b |
feat(lint): let variant 1 decide the addons that install everywhere
An addon's enabled is either dynamic -- a Jinja expression resolving per deploy
against a service flag, an API credential, the SSO plugin choice or group_names
-- or static, a literal boolean. A dynamic addon already follows its variant:
switch the service off and the expression yields false. A statically-on addon
does not. It installs in every round, so each non-baseline variant pays install
time for plugins it never exercises, and variant 0 stops being the only
maximum-footprint shape that meta/variants.yml exists to document.
web-app-nextcloud carried 22 of them across nine variants. They are now pinned
off once in variant 1 behind a YAML anchor, and variants 2-8 alias it, so the
set has one spot -- the same shape meta/volumes.yml and the
volumes-target-single-spot lint already prescribe.
test_addons_variant_pinning.py holds the rule for every role that ships
meta/addons/ and declares more than one variant:
1. an addon whose base enabled is literal true MUST be pinned enabled: false
in variant 1; a literal false is out of scope, the pin would override
nothing,
2. an addon whose base enabled is a Jinja expression MUST NOT be pinned in
any variant -- that replaces the expression with a constant and decouples
it from the service it follows,
3. every later variant MUST carry the same set, in practice by aliasing
variant 1. Variant-specific pins beyond that set stay allowed, so a
variant may still switch a statically-off addon on (web-app-matrix does
exactly that for its bridges in variant 2).
Measured on the real role through utils.cache.applications.get_variants: all
nine variants keep 51 addon keys, variant 0 keeps its 22 statics on, variants
1-8 hold none, and every dynamic entry keeps its Jinja string -- xwiki still
resolves true only where services.xwiki.enabled is. The 51 keys matter: the
override merges per addon instead of replacing the map, which is what an
earlier lookup-based attempt at this got wrong.
Each rule was broken on purpose and seen to fail: calendar pinned true, xwiki
pinned (reported for variants 1-8, which also proves the alias resolves), and
variant 5's alias removed (22 findings, exactly the required set).
web-app-discourse is exempted per addon with a reason. Its plugin set is
compiled into the image from services.discourse.plugins, and meta/addons/ is a
second declaration of the same thing; an addon-level disable would still build
the plugin and only flip the gate flag, saving no time while silencing the
spec. web-app-wordpress is not exempt: vars/main.yml:16 builds the plugin list
from the merged map and 07_manager_ops.yml:45 gates the install loop on
enabled, so its variant 1 genuinely skips a wp plugin install. Its README
claimed activitypub was "always enabled"; that is corrected rather than left to
contradict the file.
Not verified: no deploy has run. The saving is derived from the install loops
being gated, not observed, and the WordPress Multisite variant losing
activitypub rests on the same reading plus its skipUnlessAddonEnabled gate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
30510fe4c4 |
refactor(erpnext): give the bench python invocation one spot
Four tasks spelled the same container command out in full:
bash -c 'cd {{ lookup('volume', 'web-app-erpnext', 'sites').target }} && /home/frappe/frappe-bench/env/bin/python -'
Three facts were repeated in it: the sites mount, the bench root, and the role
id -- hardcoded right next to a lookup whose whole purpose is to avoid that. A
rename of the mount in meta/volumes.yml would have had to land in four places
and could half-succeed in silence, because the tasks still parse, still deploy,
and cd into a directory that no longer holds the site.
vars/main.yml derives all of it. ERPNEXT_SITES_DIR reads meta/volumes.yml, where
the path is already the single truth, addressing the role through application_id
instead of a literal name. ERPNEXT_BENCH_PYTHON builds the command from it and
takes the bench root as its dirname, so the last hardcoded
/home/frappe/frappe-bench leaves the tasks entirely. compose.yml.j2 uses the same
var for gunicorn's --chdir rather than keeping a second copy of the lookup.
Rendered with Ansible's own filters and trim_blocks, the expression reproduces
the four replaced lines byte for byte. ansible-lint passes the role at profile
production (0 failures, 0 warnings, 29 files); the volume spot lints, the
volume-lookup lint and the comment lint pass.
Left alone deliberately: files/scripts/apply/*.py name the sites path as well,
but run inside the container with no Ansible to evaluate a lookup -- they would
need an environment variable, which is a separate change. The bench-relative
binaries (env/bin/gunicorn, apps/frappe/socketio.js) and the two
site_config.json paths appear once each, so extracting them would add
indirection without removing a duplicate.
Not verified: compose.yml.j2 drives the backend entrypoint, and that path only
proves itself on a deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
8d7fff4d2d |
test(svc-net-tor): let the probe demand the values its test.env already sets
roles/svc-net-tor/templates/test.env.j2 sets RETRIES=6 and SLEEP_SECONDS=10, and
test-e2e-cli/tasks/run_one.yml renders that file unconditionally at :66 before
sourcing it at :74. The ${RETRIES:-20} / ${SLEEP_SECONDS:-15} fallbacks could
therefore never be reached -- a second source of truth that additionally
documented the wrong numbers: the header promised "default 20 / default 15"
while every run used 6 and 10.
They now take the :? form the two variables above them already use, so
test.env.j2 is the only place the values live, and a missing one aborts loudly
instead of silently probing three times longer than intended. The header stops
claiming defaults.
The sibling probes in svc-db-mariadb, svc-db-openldap and svc-db-postgres keep
${RETRIES:-20}: their test.env.j2 does not set the pair, so there the fallback is
live, not dead. Converting them would mean adding the values to those templates
first, and is deliberately left out.
Measured: a missing RETRIES exits 1 with "line 24: RETRIES: pass RETRIES as env
(attempts per domain, from svc-net-tor test.env)"; a complete env leaves the
probe output unchanged. shellcheck is clean against the staged content, and the
comment, shell and cli-asset-pairing lints pass.
The message wording avoids an apostrophe on purpose: inside ${VAR:?...} bash
still treats one as a quote character, which splits the abort message across the
next source line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
64131a4c35 |
test(svc-net-tor): name the address a failed probe resolved to
HTTP 000 cannot tell a name that never resolved from a listener that never
answered. Both leave curl without a status and openresty without an access-log
entry, which is exactly the signature the apex infinito.example has produced in
every compose job of run 31543714113.
2a60ddb3c already stopped discarding curl's stderr, so the message now names the
layer -- (6) could not resolve, (7) failed to connect, (35) handshake. It never
names the address, and that is the half that decides between "the resolver
answered wrong" and "the resolver did not answer".
getent hosts goes through the same NSS path curl uses, so the verdict carries
what the node itself resolved:
[FAIL] localhost HTTP 000 dns=::1
[FAIL] no-such-host-xyz.invalid HTTP 000 dns=
Empty means resolution failed. An address together with a connect or TLS error
means the resolver answered and the far side did not. An address other than the
node's own means the resolver answered wrongly. Read next to curl's line, those
three branches separate.
Only the failure path changes; [OK] keeps its format, and getent's own noise
goes to /dev/null so a probe never fails on the diagnostic.
Measured: both branches above; getent and head present in all four node images
(fedora, arch, centos, debian) via docker run; shellcheck clean against the
staged content; tests/lint/repository/test_comments_valid.py,
tests/lint/filesystem/shell/ and test_cli_test_assets_paired.py pass.
This is not the fix for the apex failure. Nine mechanisms were eliminated by
measurement today -- CoreDNS, dnsmasq and Docker's 127.0.0.11 each resolve both
the apex and its sibling to 172.30.0.10; the vhost exists in http and https;
nginx validated six times; no server-name conflict, no proxy env, no conntrack
pressure. What remains needs the one fact the log has never carried, and this
line carries it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
1ff6999ee2 |
fix(nfs-server): make one script own "ganesha serves", and let it judge
Penpot 0 of run 31658241728 died on the restart handler while the node was
healing itself. Its journal, in order:
20:46:47 nfs-ganesha.service: State 'stop-sigterm' timed out. Aborting.
20:46:58 nfs-ganesha.service: State 'stop-watchdog' timed out. Killing.
20:46:58 Main process exited, code=killed, status=9/KILL
20:46:58 Failed to start nfs-ganesha.service
20:47:00 [main] nfs_start :NFS STARTUP :EVENT : NFS SERVER INITIALIZED
20:47:01 systemd[1]: Started nfs-ganesha.service - NFS-Ganesha file server.
Ansible failed at 20:46:58, three seconds before the replacement was serving.
The rescue bundle confirms the end state independently: 17 threads, RPC workers
in svc_rqst_epoll_loop's epoll_wait, fridge threads idle -- a healthy server.
|
||
|
|
0c2ce12c7a |
feat(healthcheck): compose a probe from prefixes, and gate Moodle's mail on it
Moodle renders the same msmtprc as WordPress and mounts it into two services,
but nothing ever proved a message left the container: its healthcheck opens the
FPM port and nothing else. Moodle 0 of run 31658241728 passed green while mail
could not have worked -- the defect 2da285067 fixed for WordPress was invisible
here, and would have stayed invisible until a user reset a password.
WordPress proves delivery with the msmtp_curl flavor: the probe sends one mail
and writes a marker, and a task after convergence waits for that marker. Moodle
cannot use it, because curl cannot judge a port that answers FastCGI. Rather
than add a second msmtp_* class per liveness shape, the declaration now composes:
healthcheck:
flavor:
- msmtp
- connect
port_key: fpm
msmtp stops being a flavor and becomes a prefix -- shell that runs on the way
past and never decides liveness. connect joins the probes as the one that only
asks whether the port accepts a TCP connection. A declaration names exactly one
probe and any number of prefixes, as a string or a list; msmtp_curl stays as an
alias for [msmtp, curl], and the lint that validates declarations now shares
resolve_flavors with the lookup instead of keeping its own idea of a flavor.
The module became five responsibilities in one file, so it is a package:
probes.py answers which probes exist, prefixes.py what runs before them,
compose.py how a declaration becomes a rendered block. The curl helpers sit with
the probes and mail_branch with the prefix -- each next to its only caller,
rather than together in a module header.
Two defects in this work were found by measuring, not reading:
- the timing merge took the maximum across prefixes only, which cut Moodle's
start period from 15m to 120s and would have marked a heavy PHP app dead
while it was still installing. A prefix may now raise a timing and never
lower one, pinned by test.
- composing moved msmtp_curl's interval from 30s to 1m, because the composed
class derives from Curl where MsmtpCurl derived from Probe. That is the
interval every other curl probe uses, but it was not intended; it now has
its own test rather than riding along unseen.
Regression measured across the whole repo rather than argued: every declared
healthcheck was rendered with the pre-refactor code and with this one. 58 render
byte-identically, 1 is Moodle's intentionally changed declaration, and 2 differ
-- friendica and wordpress, in that interval alone, with the probe command
itself unchanged.
Coverage was checked in both directions. No test was removed: the four deleted
lines are an import, the registry literal, one class name in a url test, and the
helper, which now goes through compose() instead of reaching into PROBES -- a
tightening. New code had two gaps, both closed: connect was the only probe whose
emitted command no test pinned, and compose() raised KeyError for an unknown
name unobserved.
Not verified: no deploy has run any of these probes. The composed class is built
dynamically, so what it emits is proven for all 59 services while docker
accepting it is not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
2ffaa2fc5c |
fix(healthcheck): take the curl probes off the proxy, from one definition
Every probe in this module targets 127.0.0.1 inside the container, so a request
a proxy picks up is wrong by definition. The Wget probe has said so since it was
written -- `wget --spider --proxy=off` -- while the four curl invocations said
nothing, and a container whose environment carries http_proxy would have sent
its own liveness check through it.
That environment is not hypothetical: 2d6a5fd18 puts http_proxy and https_proxy
into EspoCRM's env_file so the OIDC back-channel can reach an onion issuer. It
also sets no_proxy=localhost,127.0.0.1, so that role's probe is covered by hand.
The next role to route a container through a proxy and forget that line would
have had a healthy web app reported dead.
The flags now come from one place. CURL_NO_PROXY plus curl_argv() build the exec
form, and curl_shell() is the same argv run through shlex.quote. The two
spellings differ in exactly one character -- the exec form needs `*` bare
because docker runs no shell, the CMD-SHELL form needs it quoted or the shell
globs it -- and that difference was previously written out by hand at three
sites plus a fourth in a different shape. Deriving one from the other is what
keeps them from drifting; the Host-header branch moved along for the same
reason, since it too existed twice in two spellings.
The http fallback keeps its wget half untouched on purpose. That flavor exists
for images where it is unknown which tool ships, and busybox wget does not
accept --proxy=off; adding it there would have made the first branch fail on an
unknown option for exactly the images the fallback is for.
Verified by execution at each step:
- adding the flag broke five tests, which is every call site plus the hostname
variant -- the suite pins the argv and the shell string literally, so that
was the completeness check
- the refactor then passed those same 31 tests with no further edit, and the
four rendered probes were diffed against the pre-refactor output character
for character
- one of those tests executes the CMD-SHELL probe against stubbed msmtp and
curl, so the quoting survives a real shell rather than just a string compare
- the option is accepted where it will run: joomla:6.1.2-php8.3-apache, one of
the images configured for this flavor, ships curl 8.14.1 and answers
`curl -f --noproxy "*"` with exit 7 (refused, nothing listening) rather than
exit 2 (unknown option)
Not verified: the other images behind this flavor. --noproxy has existed since
curl 7.19 in 2008, but only one image was measured, and no probe built this way
has yet reported healthy in a live container.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
d0516716c5 |
fix(mail): stop the container copy of msmtprc inheriting the host's loopback
One template serves two consumers with opposite needs. sys-svc-mail-msmtp renders it to /root/.msmtprc, which the host itself reads, and web-app-wordpress and web-app-moodle render the same file into their config directory, from where it is mounted at /etc/msmtprc inside a container. resolve_smtp_host returned the loopback for both, which is right for the first and meaningless for the second: inside a container 127.0.0.1 is that container's own loopback and nothing is listening on it. WordPress 0 of run 31658270172 died on it. The deploy converged and then "⏳ Wait for the delivery marker the healthcheck writes" retried sixteen times before `assertion: wordpress_smoke_mail.rc == 0` evaluated to false. The reason is in the rescue bundle rather than the log, identically in all three replicas (containers/wordpress_wordpress.{1,2,3}.*.inspect.json): msmtp: cannot connect to 127.0.0.1, port 587: Connection refused msmtp: could not send mail (account default from /etc/msmtprc) Those containers report Status: healthy with FailingStreak: 0, which is by design since |
||
|
|
f1dff50fb6 |
feat(nextcloud): give xwiki its own variant and loosen its bond
Variant 0 of this role no longer fits the box CI gives it. Job 94319317296 of run 31658270172 started at 01:49:01Z and logged "##[error]The operation was canceled." at 07:44:14Z -- 355 minutes later, to the minute, which is the ceiling .github/workflows/call-test-deploy-swarm.yml:161 sets. It died inside its own step, at "TASK [test-e2e-playwright : ▶️ Run Playwright container]". The drill costs about 6h40m and the box is 5h55m. The cost is dominated by the deploy itself, not by a repeatable sweep: PASS 1 alone is 13695.90s in role-runtimes, of which sys-svc-compose is 3094.19s and test-e2e-playwright 2832.15s. It therefore scales with how many services variant 0 switches on, which is what this commit reduces. The file's own header states the rule: variant 0 carries every tightly-coupled (bond>=1, same-host) service, while loosely-coupled (bond<1) partners are excluded from the closure and distributed across later variants to keep each host under budget. xwiki and bigbluebutton were declared bond: 1 and therefore had to sit in variant 0. Both are bridged partners with their own role and their own spec -- the same shape as openproject, gitlab and mattermost, which already carry bond: 0.5 -- so the declaration was the outlier, not the placement. Both bonds drop to 0.5. xwiki moves out of variant 0 into a new variant 8 with sso, ldap and tor, the same shape as variant 5 (zammad) and variant 6 (gitlab). bigbluebutton stays in variant 0 for now, by explicit request; its bond change only makes the move possible later. Each edge is one-directional -- neither web-app-xwiki nor web-app-bigbluebutton declares a nextcloud service -- so there is exactly one bond per partner to change. coturn is deliberately absent from variant 8. BigBlueButton speaks WebRTC and the reflex is to bring a TURN server, but the only coupling in the repo is meta/addons/spreed.yml:8, which bridges coturn for Nextcloud Talk, and talk is off in that variant. An extra service there would be exactly the overhead this commit removes. Measured: the file loads to 9 variants, variant 0 falls from 16 enabled services to 15, and variant 8 resolves to ['ldap', 'sso', 'tor', 'xwiki']. make test-lint stays at 328 passed -- variant-limit (capped at 3, opened by the file's existing nocheck), variants-static-override and the bond lint (which only requires a number in 0..1) all accept it. Not measured, and the honest cost: a ninth variant is one more deploy round, and the limit lint's own rationale says total deploy time grows linearly with max(variant_count) across the primary apps -- Nextcloud sets that maximum, so this is roughly +12.5% for the whole matrix, spent to relieve the single job that dies on its own ceiling. Whether removing one service of sixteen closes a ~45 minute overrun is not established; role-runtimes has the per-role seconds and no run has been made with this shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d926dd0832 |
fix(nextcloud): keep an onion Discourse URL on http in the settings bundle
The integration_discourse settings page rewrites what the user types, and the
rewrite is unconditional. src/components/PersonalSettings.vue:217-224 upstream:
if (this.state.url !== '' && !this.state.url.startsWith('https://')) {
if (this.state.url.startsWith('http://')) {
this.state.url = this.state.url.replace('http://', 'https://')
An onion service carries no certificate and is served over plain http through
Tor, so on such a node the address is rewritten to an endpoint that does not
exist. Run 31658270172, job 94319317649 shows all three steps: the trace's
action log records the spec typing `http://forum.<onion>/`, the PUT to
/apps/integration_discourse/config carries `{"values":{"url":"https://forum.
<onion>/"}}`, and the connect navigation to `https://forum.<onion>//user-api-
key/new` fails with network status -1, which Playwright reports as
`Received string: "chrome-error://chromewebdata/"`.
The app ships compiled JS, so the fix has to reach the bundle rather than the
source. It is measured against the file the appstore actually installs --
nextcloud-releases/integration_discourse v3.3.0, whose
integration_discourse-personalSettings.mjs is the exact filename the trace
loaded from the container; the nextcloud/integration_discourse releases stop at
v2.0.1 in 2023 and still ship .js, which is why the earlier reading of that repo
was misleading.
Verified rather than argued:
- the anchor `startsWith("https://")&&(` occurs exactly once in that file
- after the patch the guard reads
`…startsWith("https://")&&!this.state.url.includes(".onion")&&(…`
and `node --check` accepts the bundle as valid ESM
- the anchor no longer exists afterwards, so a second application is a no-op:
idempotency is structural, not a marker check
- the task was run through a real ansible-playbook against a real copy of the
bundle with only the exec prefixes stubbed: first run changed=1 and patched,
second run changed=0, so changed_when reports honestly
- busybox sed (alpine:3, "This is not GNU sed version 4.0") produces the same
result as GNU sed, which matters because the image is 34-fpm-alpine
`occ app:getpath` locates the app instead of hardcoding custom_apps, which
appears nowhere in the repo and which the appstore may move. The `grep -q … ||
exit 0` makes the task both idempotent and safe when the file is absent.
Clearnet is untouched: the added condition is false for every non-onion URL, so
the upgrade still happens where TLS is available.
Not verified: what `app:getpath` returns in this installation. If the path is
wrong the task no-ops silently -- the same shape as the WordPress healthcheck,
where msmtp fails and a passing curl hides it. A `test -f` with a loud failure
would close that, and the layer behind the redirect has never been reached by a
green run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
a0ef59049d |
fix(e2e): let biber's authenticated-surface probe actually wait
236578d3b converted this exact fallback in admin.js and left its twin in
biber.js untouched. Both personas do the same thing: when the Keycloak round
trip did not complete cleanly, fall back to looking for an authentication
marker on the page, and budget fifteen seconds for it. isVisible does not
spend that budget -- Playwright ignores the timeout option there and answers
from the current DOM.
The cost is measured, not argued. In run 31658241728 the EspoCRM 2 trace shows
the same API asked for ten seconds and returning in 0.20 s, while the network
log has the SPA finishing its boot at 0.66 s and the fallback navigations
firing at 0.91 s. A surface that renders one second late reads as
unauthenticated.
waitFor spends the budget only when the marker is absent, which is precisely
the case that is answered wrongly today; when the marker is there it returns as
soon as it appears. So a passing run pays nothing and a failing run pays the
fifteen seconds it was always meant to pay. admin.js has paid them since
236578d3b.
Ten further isVisible({timeout}) call sites in this directory were reviewed and
deliberately left alone: they ask whether something is visible right now
(passwordStillVisible, loginStillVisible, the frame scans, clickFirstVisible),
and clickFirstVisible would lose five seconds per candidate if it waited.
Not verified by a run: the mechanism is proven from the EspoCRM trace and the
parity with admin.js, not from an observed biber probe.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
4f5dc2be44 |
fix(mastodon): skip the SeaweedFS filer check on an onion target
|
||
|
|
b6e3368a21 |
fix(funkwhale): click through Funkwhale's own log-out confirmation
Variant 2 of the swarm matrix fails the sign-out assertion for both personas, and variant 0 fails the same assertion for a different reason. The artefacts of run 31658241728 separate the two states. Variant 0's snapshot at failure is the universal-logout terminal page (`- text: You have been logged out.`): the session is gone and the test only had to return to the app, which f683d18b8 does. Variant 2 stops one step earlier, for administrator and for biber alike: - heading "Are you sure you want to log out?" [level=2] - paragraph: You are currently logged in as administrator - button "Yes, log me out!" Clicking the sidebar control routes Funkwhale to its own confirmation view, and the session survives until that button is pressed. Returning to the app base and asserting a sign-in offer therefore fails, and it would keep failing with f683d18b8 alone -- that commit navigates away from the dialog without answering it. The new block presses the confirmation when it appears. One click covers both personas, and the artefact lists no other failing test in the job. Variant 0 is unaffected: its terminal page carries no button at all, so the probe expires and the flow continues as before, at the cost of ten idle seconds. The name regex is measured against the observed strings rather than assumed. It matches "Yes, log me out!", "Log out", "Sign out" and "Logout", and rejects "Log in" and "Sign in", so it cannot re-select the sign-in affordance that the next assertion looks for. Not verified: the click has not been run against a live instance. What is proven is that the button is there and that the selector selects it, not that Funkwhale ends the session afterwards. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a30fb79a79 |
style(nextcloud): wrap the openproject provisioning stdin expression
The stdin: value concatenating doorkeeper_allow_onion_redirect.rb with ensure_nextcloud_storage.rb ran past the line budget; it now wraps before the closing braces. Whitespace only. yaml.safe_load of the file before and after the change returns the same document, so the Ruby that reaches the container is byte-identical. Not authored as part of the surrounding session work; committed separately so it does not ride along inside an unrelated bundle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
93bbcf7a1c |
fix(espocrm): route the OIDC back-channel through Tor on an onion node
EspoCRM's browser half completes and the server-side code-to-token exchange then
fails in 28.7 ms with a 401, with no matching POST to the Keycloak vhost anywhere
in openresty's log. ESPOCRM_CONFIG_OIDC_TOKEN_ENDPOINT carries the public onion
authority, which is right for the browser and unresolvable for PHP.
An extra_hosts pin does not fix that, and this is measured rather than reasoned:
the image ships libcurl 8.14.1, and with --add-host set to exactly what
extra_hosts produces, a request to an .onion name returns
`errno=6 Not resolving .onion address (RFC 7686)`. libcurl refuses the name
before /etc/hosts is ever consulted. The same request with
`http_proxy=socks5h://...` returns `errno=7 Failed to connect` instead -- the
name is handed to the proxy and never resolved locally, so the refusal does not
apply. That is the whole fix.
web-app-joomla and web-app-wordpress reach the same endpoint through code we
ship, because their frameworks set CURLOPT_PROXY themselves and override the
environment. EspoCRM does not: Login.php sets URL, timeout, method, headers and
protocols, and neither it nor anything else under
application/Espo/Core/Authentication/Oidc/ mentions CURLOPT_PROXY. libcurl
therefore falls back to http_proxy/https_proxy, so an env var is enough and no
patch to upstream code is needed.
The container_extra_hosts include returns, for the reason it was actually needed:
lookup('tor_socks_proxy') resolves to socks5h://host.docker.internal:<port> under
compose, and the container has to be able to resolve that name to reach the proxy
at all. Under swarm it is the tor overlay alias, which the role already bonds
(meta/services.yml:93-95).
Clearnet is untouched -- ESPOCRM_OIDC_SOCKS_PROXY is empty unless the Keycloak
vhost ends in .onion, and the block does not render.
Blast radius checked rather than assumed: the health probe is flavor curl against
http://127.0.0.1:<port>/, which no_proxy covers; nothing in the role's shipped
scripts makes an outbound HTTP call, so there is no boot-time dependency on Tor;
and daemon and websocket inherit the same env_file, so their scheduled outbound
HTTP also takes Tor, which on an onion node is the egress anyway.
Not verified: no token exchange has been run through a live Tor proxy. What is
proven is that libcurl forwards the name instead of refusing it, not that Tor
resolves it and the local proxy answers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
29015311b6 |
feat(lint): derive container paths from meta/volumes.yml everywhere
meta/volumes.yml says where a volume lands inside its container. Wherever a
second file spelled the same path out, the two drifted independently: the mount
moves, the consumer keeps pointing at the old place, and nothing fails until the
process looks for content that is not there.
|
||
|
|
750476b26a |
feat(lint): give every mount target one spot in meta/volumes.yml
A volume that reaches several services repeated its container path once per
mount. Written out each time it has no single point of truth: a rename that
misses one line still parses, still deploys, and mounts one service somewhere
nobody intended. Nothing fails until that service looks for content which is
not there.
YAML already solves this, and
|
||
|
|
6e93862dd5 |
refactor(gitlab): ship the config trees as swarm configs, not host binds
|
||
|
|
c7e5b0a274 |
fix(funkwhale): assert the sign-in offer on Funkwhale, not on the logout page
The spec signed out and then asserted, on whatever page it had landed on, that
Funkwhale offers sign-in again. That page is never Funkwhale.
Both exits leave the app by design. The fallback navigates to
${appBaseUrl}/logout, but on every app vhost that exact path belongs to the
universal-logout service: roles/web-svc-logout/templates/logout-proxy.conf.j2
defines `location = /logout` with a proxy_pass, included through
sys-front-inj-all into the shared vhost. The in-app control is no better --
initLogoutPatch rewrites it to Keycloak's end-session URL with redirect_uri set
to the primary domain.
Measured in the run's trace rather than inferred:
https://audio.infinito.example/logout 200, content-length 25
body "You have been logged out."
clear-site-data "cache","cookies","storage"
set-cookie sessionid=, csrftoken=, _oauth2_proxy=,
_pk_* -- all Expires 1970, across the
bare, app and parent domain scopes
So the sign-out itself works; the session really is gone. Only the assertion sat
on the wrong page, where no sign-in affordance can appear at any timeout.
Returning to appBaseUrl before the expectation puts the claim where it can be
true or false: the app, freshly anonymous. The locator is untouched --
|
||
|
|
a0cc3dbe40 |
feat(lint): make default.env the enforced source of the primary domain
default.env declares INFINITO_DOMAIN and everything is meant to derive from it.
Nothing checked that, so copies accumulated. A copy is a second source nobody
updates, and it fails in the least useful way: the code keeps running and
quietly talks about the wrong host. integration_mastodon is the worked example
-- it asserted the provisioned partner equalled a clearnet literal, so the spec
passed on a clearnet node and failed on an onion one while the product was
right in both.
tests/lint/repository/spot/test_no_hardcoded_primary_domain.py now fails on the
literal anywhere in tracked source. It reads the value it hunts for FROM
default.env, so the rule follows the SPOT instead of repeating it and would
otherwise be its own first violation.
Scope, and why each exclusion is not a loophole: default.env itself is the
source; .md is documentation; tests/ and scripts/tests/ hold fixtures that must
assert against a named host; inventories/ is where writing the real values is
the entire point; gitignored output carries the value because it correctly
derived it. Everything else is in.
Two real hardcodes are repaired rather than suppressed.
utils/domains/default_primary.py mirrors utils/roles/lifecycle.py -- one
function reading one key through parse_static_env -- and replaces the module
constant in utils/cleanup/nginx_vhosts.py and the argparse default in
cli/meta/domains/__main__.py. Both resolve to the same value as before.
Seven documentation sites become <primary-domain> placeholders instead of
carrying a nocheck: a Python docstring cannot host one without polluting the
docstring, and a placeholder does not rot when the default changes. The single
nocheck is scripts/system/tls/trust/wsl2.sh:61, where the marker is matched
verbatim against what an earlier run wrote into the Windows hosts file --
deriving it would orphan that block instead of replacing it.
Collateral, not intent: touching roles/sys-ctl-hlth-csp/files/script.py brought
it under the comment lint, which found five pre-existing narration comments.
Three deleted, `fn[:-5] # strip ".conf"` replaced by fn.removesuffix(".conf"),
and the root-user comment rewritten in the sanctioned Exception form. Its 33
tests stay green.
tests/lint/repository/test_no_hardcoded_container_name.py (
|
||
|
|
c14b1d13d6 |
fix(nextcloud): compare the Mastodon partner host against the deployed one
integration_mastodon asserted the provisioned oauth_instance_url resolved to
the literal "microblog.infinito.example". That value is not fixed: the addon
hook writes lookup('tls','web-app-mastodon','url.base') into it
(meta/addons/integration_mastodon.yml:12), so on an onion node the plugin
correctly received http://microblog.<onion>/ and the spec failed for being
right. The product was never wrong; the assertion was pinned to a clearnet
name.
The partner URL now reaches the spec the way every sibling bridge spec already
gets it, from the same lookup the hook uses:
playwright.env.j2 MASTODON_BASE_URL, gated on services.mastodon.enabled
_shared.js read and exported as mastodonBaseUrl
the spec compares host against host, not against a literal
web-app-fediwall already wires MASTODON_BASE_URL from this exact lookup
(templates/playwright.env.j2:12), so the name and the source are the
established ones; this adds the gate, so the variable renders empty instead of
naming an undeployed partner.
The guard in front of the comparison is not decoration: without it an empty
MASTODON_BASE_URL would reach new URL("") and abort the spec with a TypeError
instead of an assertion anyone can read.
The evidence that this is the whole difference: integration_peertube runs the
identical shape against shared.env.peertubeBaseUrl and PASSED in the same red
run, on the same onion node where integration_mastodon failed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
7538b8a0bb |
fix(e2e): let the persona probe actually wait for a slow SPA
Playwright's isVisible() is an immediate, non-retrying check; the timeout option
it accepts is ignored. Two call sites in the shared admin persona passed a
substantial timeout and behaved as if it applied:
:75 gates the whole native-login path, called with probeTimeout=10_000 (:101)
:141 the fallback that decides whether an authenticated surface was reached,
called with resolveTimeout(15_000)
The trace of EspoCRM variant 1 shows the option never reaching the call. Every
navigation carries its own budget -- {"url": ..., "timeout": 60000} -- while the
probes record only {"selector": "input[type='password']:visible >> nth=0",
"strict": true}, with no timeout field at all. The first probe answers false
220 ms after it starts, against the 10 s it was given; the three fallback probes
that follow take 36, 32 and 29 ms. The whole native-login attempt, four
navigations and four probes, is over 1.15 s after it began.
That is not enough for an SPA. The DOM snapshot at the moment of the first probe
is ["BODY", {"data-id": "espocrm"}, ["DIV", {"class": "container content"}]] --
an empty shell, with espo.js and espo-main.js still in flight. The login form the
probe waits for renders later, so the persona falls through to /login, /admin/
and /admin, collects three Apache 404s, and asserts that the administrator never
reached an authenticated surface.
Both sites move to waitFor({state: "visible", timeout}), which is the waiting
form and is already the idiom elsewhere in this directory.
The four remaining isVisible({timeout}) calls in the file stay as they are: they
ask whether something is visible right now -- is the password field STILL there
after submitting, is a Login control STILL showing -- where an immediate answer
is the correct semantics and the timeout argument is inert but harmless.
Fixes the EspoCRM 1 and 2 failures and applies to every SPA the shared persona
drives; it does not touch EspoCRM 0, whose OIDC back-channel fails for an
unrelated reason.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
8c7593f43f |
refactor(container): make container_extra_hosts the only emitter of extra_hosts
extra_hosts is the one compose key a service may not carry twice. The onion SSO
back-channel pin was emitted by a Jinja partial that six roles included, while
six other services wrote the key by hand; nothing stopped a service from
receiving both. YAML then either rejects the document or keeps one list and
drops the other, silently.
The partial becomes plugins/lookup/container_extra_hosts.py, and every pin now
goes through it. Callers add their own entries with extra_hosts=[...], the way
container_volumes already takes extra_volumes=, and the lookup merges them after
the automatic pin and deduplicates in first-seen order. With nothing to emit it
returns an empty string.
The swarm address reasoning from
|
||
|
|
ccb2c82b8b |
test(svc-net-tor): let the probe report why a domain failed
The apex infinito.example has failed six compose jobs with HTTP 000 and no
explanation, because the probe throws curl's stderr away:
curl ... "${scheme}://${domain}/" 2>/dev/null || true
--show-error was already set, so curl had the answer the whole time and the
redirect discarded it. Command substitution captures stdout only, so
%{http_code} and the accept regex at :61 are untouched, and || true still
swallows the exit code. test-e2e-cli/tasks/run_one.yml:85-92 prints stdout and
stderr when rc != 0, so the text reaches the job log.
This is NOT the fix for that failure. Three rounds of reasoning about it rested
on the premise that the apex answers 301 and curl follows it off-instance;
openresty.log:126 refutes it. That line logs the successful clearnet probe
"GET / HTTP/2.0" 200 with "Scheme: https" and User-Agent curl/8.18.0, so https
requests do appear in that log -- and the apex left no entry at all. curl never
completed hop 1, and there never was a 301 to follow. Whether the cause is
NXDOMAIN, a refused connect, or the 443 default_server's ssl_reject_handshake
(sys-svc-webserver-core/templates/default_server.conf.j2:17-20, which produces
exactly this signature: rejected handshake, 000 at curl, no access-log entry)
is what one curl error line will now say.
Costs up to six extra stderr lines per failing domain, RETRIES=6.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
0c56709be7 |
fix(mobilizon): spell the TLS option the way its adapter reads it
|
||
|
|
bffe4ee04c |
fix(peertube): derive the webserver authority from the TLS lookup
The role set no webserver scheme or port, so PeerTube kept its upstream default
of https on 443 even on an onion node, where the vhost is plaintext and Tor
publishes port 80. redirect_uris.py meanwhile resolves the same deployment
through resolve_enabled and registers http://video.<onion>/* with Keycloak, so
the plugin asked for an https callback against an http registration and Keycloak
answered invalid_redirect_uri four times in its event log.
Both values now come from the tls lookup, which decides scheme and port together
from one resolve_enabled call -- the same predicate redirect_uris.py uses for the
registration and nginx_ssl_header.j2 uses for its listen directive. There is no
second expression left that could drift.
The port is not cosmetic. WEBSERVER.URL is built as scheme://hostname:port and
sanitizeUrl drops the port only for (https,443) and (http,80), so flipping the
scheme alone would have produced http://video.<onion>:443 -- still unmatched
against the portless registration, and against a port Tor never publishes.
|
||
|
|
f7bb608927 |
refactor(nextcloud): sort files by language, and let Doorkeeper accept onion
files/ now mirrors the role's own structure: php, ruby and shell, each split
into addons for the per-integration scripts that tasks/addons/ drives and utils
for what more than one caller shares. set_encrypted_appconfig.php serves gitlab,
mattermost and zammad; doorkeeper_allow_onion_redirect.rb serves zammad and
openproject; init-gate.sh belongs to the role itself. The partner directories
survive because two scripts are both named provision_oauth_app.rb.
NEXTCLOUD_INIT_GATE_FILE had to split. It was the source path under files/, the
name Dockerfile.j2 COPYs into /usr/local/bin, and the ENTRYPOINT, all at once;
moving the script would have produced /usr/local/bin/shell/utils/init-gate.sh.
The basename keeps that name and NEXTCLOUD_INIT_GATE_SRC carries the path.
The same commit closes two of the three faces of one defect. resolve_enabled
reports TLS off for an .onion domain, correctly -- the vhost is plaintext and
Tor carries the transport crypto -- so every redirect URI built from
lookup('tls', ...) is http, and Doorkeeper rejects it. Zammad raises out of
app.save! and fails the task at rc 1; OpenProject discards result.errors, keeps
the unsaved model whose plaintext_secret already exists, reports
oauth_application_created:true, and only dies three tasks later on a null
client_id. The prelude relaxes force_ssl_in_redirect_uri for .onion hosts and
delegates every other host to the previous setting, so clearnet is unchanged at
both partners; the OpenProject script now raises with the real validation
errors.
The third face is PeerTube, whose Keycloak callback is registered http and
requested https, and it stays open: fixing it means changing either PeerTube's
scheme, which is its federation identity, or the registration side.
Evidence: run 31658270172, jobs 94319317307 ("Redirect URI must be an HTTPS/SSL
URI.") and 94319317278 (changed:true, then length on NoneType).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
4311acfdfd |
fix(ci): gate the image consumers on the fork image wait
test-dns depended on build-ci-images, which skips its push step for a fork PR and reports success anyway, so the dependency was satisfied by a job that built nothing. The images it pulls come from the sibling pull_request_target run, and nothing made it wait for them: arch finished at 00:10:40 while the pull ran at 00:09:28. fedora was no safer -- it completed four seconds after the job even started, and only survived because its pull fell later in the probe order. The race was thin for four of five distros, so no retry count would have settled it. The gate for exactly this already existed and only done consumed it. wait-fork-prereq-run polls docker manifest inspect through scripts/meta/wait/images.sh until every ci-<sha> distro ref is present, and fails early once the privileged run ends unsuccessfully; fork-prereqs-ready enforces its result. The three jobs that pull those images directly now depend on it. test-deploy-swarm-priority and test-instructions were exposed the same way and are wired too -- gating only the job that happened to fail would have left both to lose the next close race. The remaining test-deploy-* jobs inherit the gate transitively, which was computed over the needs graph rather than assumed. Same-repo PRs are unaffected: wait-fork-prereq-run carries no job-level if, reports "implicitly ready" outside fork PRs, and the gate script accepts both success and skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
28e917721e |
fix(healthcheck): stop deriving the HTTP Host from a kernel hostname
container_hostname is computed for sethostname(2) and clamped to 63 bytes, so a v3 onion domain degrades it to the bare entity name. container_healthcheck forwarded that value into the curl probe's Host header, and curl is the only flavor that asserts a vhost identity on a loopback probe -- tcp and http_status hardcode Host: localhost, wget and http send none. An app that validates Host therefore rejected its own healthcheck: bookwyrm's Django answered 400 to Host: bookwyrm and the container never turned healthy while serving fine over its onion. This was diagnosed once already. |
||
|
|
5bc1653ea8 |
fix(ci): stop asserting the merge in the trusted context
For a fork PR both clauses of the gate were true, so assert-main-merged ran
once per event. checkout@v7.0.0 now refuses the fork-head checkout under
pull_request_target outright, and the trusted copy fails on every push while
the untrusted one passes. Dropping the second clause removes a checkout of
fork code held against the base repo token rather than authorising one with
allow-unsafe-pr-checkout.
Nothing is lost. The assertion reads git history and needs no token, the
pull_request run already performs it, and no job declares needs on it. Same-repo
PRs never matched the second clause anyway.
The three jobs that genuinely need the trusted context keep it untouched:
images-mirror-missing-fork pins source_repository to github.repository, so it
never sees fork code despite holding packages: write; build-ci-images-fork
switches to the fork head only behind the "Trusted" label; prepare-fork-merge-ref
checks out the base with no ref at all. Those were wired through
prepare-fork-merge-ref in
|
||
|
|
c541e7c22f |
docs(credentials): name the path the code actually reads
Eight prose references still pointed at the pre-rename root, and one of them
did more than mislead a reader: the addons lint told an author to reach a
secret through lookup('config', application_id, 'credentials.<name>') at the
exact moment it rejected their literal, so the guidance produced the next
defect. The check itself only tests whether the value is templated, so no
enforcement changes here.
variants.md gains the reason rather than a corrected list. A variant entry is
baked into host_vars raw, which makes its top-level key the inventory path, so
a key no meta topic produces is silently discarded. The previous list drifted
precisely because it stated the set without stating why it was the set.
design/variants.md was wrong twice over: the old root, and an argument order in
lookup('config', 'credentials', '<provider>') that could never have resolved.
The erpnext key was checked against svc-db-mariadb/vars/main.yml rather than
prefixed on faith.
Left alone on purpose: test_credentials_roundtrip and the layout lint name
meta/schema.yml and schema/main.yml as history and as the legacy directory the
lint forbids. Both are correct.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
cf0c7f2a40 |
test(variants): whitelist the top-level keys instead of one blacklist
The only shape check on meta/variants.yml rejected a single key, `compose`.
Its docstring stated the actual rule -- top keys must be payload roots -- and
never enforced it, and had itself gone stale: it still named `credentials`,
the root
|
||
|
|
8a812c20ec |
fix(variants): pin the dev password where the role reads it
A variant entry is baked into host_vars raw, not deep-merged, so its top-level
key IS the inventory path. keycloak and lam still pinned their Playwright
administrator_password under credentials, while
|
||
|
|
8d45159233 |
style(lint): let ansible-lint say why it failed
The runner sent both streams to /dev/null and then classified by exit code, so a failure produced one line -- "FAILED ansible-lint" -- with no code and no message. A lint finding, a crash and a lock conflict were indistinguishable, which is exactly the state a quality run left me in while ansible-lint was clean on the host at the same commit. Output now goes to a temp file, is discarded on success because ansible-lint is verbose when it passes, and is written to stderr together with the exit code when it fails. The accepted codes are untouched, so a passing run behaves exactly as before. run_ruff already reports this way, which is why the TID252 error it hit was fixable on sight. This changes no outcome. The failure that motivated it has not recurred, so the new path is still unexercised against a real fault. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d86913225b |
fix(inventory): exempt credentials at their renamed root
compare_application_keys skips generated credentials because no default exists for a secret, but it recognised them by the pre-rename prefix. Once the generator started writing them again, every credential arrived as secrets.credentials.<key> and the exemption stopped matching, so validation rejected three keys per credential -- the leaf and both intermediate nodes, which recursive_keys emits separately -- and aborted the deploy before the playbook ran. The condition now admits the bare secrets node and the secrets.credentials subtree, leaving a sibling such as secrets.other flagged. The check in the inventory manager stays on the bare prefix on purpose: it walks meta/secrets.yml, whose own root key is credentials. test_credentials_keys_are_skipped fed the validator the old shape, so it kept passing while real inventories were being rejected. Evidence: run 31655559799, job 94311023723, "Missing default for web-app-dashboard: secrets.credentials.sso_proxy_cookie_secret". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
32d9755763 |
style(credentials): import the emit helpers absolutely
provision and credentials are sibling packages, so reaching the emit helpers with a parent-relative import tripped TID252. Same-package imports stay relative, as everywhere else in the package. The blank line before the role_resolver import keeps the first-party and relative groups apart, which I001 requires. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0fd99b38ee |
style(inventory): drop narration the assertions already carry
The four comment lines above the assertNotIn block restate what those three assertions state executably, and unlike them they cannot fail when the claim stops holding. test_comments_valid lints only changed targets, so the block stayed invisible until the previous commit made the file one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
1ba543dcc4 |
test(inventory): put the overrides test back in its class
test_apply_vars_overrides_from_file_deep_merge_and_overwrite sat at module level while still taking self, so pytest treated self as a fixture request and errored the test out at collection instead of running it. The assertions it carries for apply_vars_overrides_from_file have therefore never executed. Re-indenting it into HostVarsTestCase makes it a real test again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
8edabb3246 |
fix(credentials): merge snippets at the renamed root
The host_vars merger still wrote applications.<app>.credentials and read
"credentials" out of the generated snippet.
|