The onion glyph alone left a job name ambiguous: no glyph meant either
'clearnet' or 'this mode has no tor axis'. Swarm and compose entries now open
with the onion or the globe, so the name states which of the two networks the
job tested. Host keeps neither — it shares the matrix builder but has no onion,
and a glyph there would claim something the job never did.
'enabled' becomes 'enforced' so the manual selector says what it does: tor on
every role that can take one, not tor on everything.
'exclusive' is the new mode. keep_tor_variants runs before assign_tor and cuts
each entry down to its tor-capable variants, dropping entries left with none, so
a run tests the onion and nothing else. A bundle can come out with a hole in it
— variants 0 and 2 capable, 1 not, yields '0,2' — which filter_plan_to_variants
already handles by set membership; both shapes are now pinned by tests.
Note the word is overloaded: services.tor.exclusive drops a deployed app's
clearnet vhost, while this mode only narrows what CI runs. The docstring says so
where a maintainer will look.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
meta/schema.yml held credential definitions and their runtime values, but the
name said 'schema' and read like structural metadata next to services.yml and
volumes.yml. Nothing in the file is a schema in the sense the other meta files
use the word, and a contributor had to open it to learn it holds secrets.
62 role files renamed. ROLE_FILE_META_SCHEMA becomes ROLE_FILE_META_SECRETS and
keeps its role as the SPOT the path is built from.
Two readers bypassed that constant and spelled the name themselves:
utils/cache/applications.py built meta_dir / 'schema.yml' twice, and
plugins/lookup/application_gid.py carries the filename in the marker list it
detects application roles by. Left behind, the rename would not have raised —
credentials would have resolved empty and the role would have stopped counting
as an application.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3a1ef70cb removed the rerun workflow's rows from the actions configuration
page and left two blank lines at the end of the file, which MD012 rejects.
That is what failed the Lint job on 777f7f0fa.
The two Python files carry the reflow ruff 0.16.2 produces; 6320da703
brought that version in with the main merge. Committing it keeps the tree
matching the formatter, since scripts/lint/python.sh runs ruff format in
write mode and would otherwise rewrite them on every run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
b384f4825 put two of them above churned_past_grace and has_fatal_task, and
e41508f48 put one in the force_shared_db module docstring. The prefix is not
a licence to keep explanatory prose; the comment rule wants it deleted.
The docstring paragraph keeps its content and loses only the prefix: it
records that Magento hard-fails setup:install against the central MariaDB
major, which is the reason the manager-pinned carve-out exists at all.
No executable line changes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3f51d4ed9 bumped the recorder from v0.16.2 to v0.17.1. Upstream 0.17 drops
the rabbitmq transport for REST and reshapes [controller]: MR !746, listed
under the 0.17.0 changelog, replaces domain and insecure with url and a
mandatory api_key. recorder.toml.j2 still writes the old shape, and 0.17
also started warning about unknown configuration fields. Back to v0.16.2
until the template is migrated.
The frontend, the controller and the recorder are released as one
coordinated set, so all three carry a nocheck marker now. Bumping them
individually is what produced this. LiveKit and RabbitMQ keep updating:
they version independently of OpenTalk.
The marker sits after the value; utils/update/docker.py resolves it with
the default same-or-above placement, which the suppression catalog lists
for docker-version.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The login filter searched sAMAccountName, an Active Directory attribute.
This directory is OpenLDAP: LDAP.USER.ATTRIBUTES.ID resolves to uid, and
svc-db-openldap creates every user as dn: uid=<name>,<ou>. The filter could
not match a single entry, so LDAP login never worked. web-app-semaphore and
web-app-minio already build their filter from the same variable.
AUTHENTICATION_METHOD was written by both branches, so enabling LDAP and
OIDC together emitted the key twice and the outcome depended on the reader
taking the last duplicate. It is now set once, above both blocks.
OIDC_AUTHORIZATION_REDIRECT_URI is dropped. EspoCRM derives the callback in
ConfigDataProvider::getRedirectUri() as siteUrl . '/oauth-callback.php' and
reads no configured value; the oidcAuthorizationRedirectUri key exists
upstream only in entityDefs, the Oidc authentication metadata and the i18n
labels, and its settings field computes the same URI client-side purely to
show the admin what to register with the IdP. The path we set,
/oidc/callback, does not exist in EspoCRM at all.
Rendering the template across all four LDAP/OIDC combinations before and
after shows no other key changing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Erlang's ssl stack does not read SSL_CERT_FILE, and hackney ships certifi,
so the Keycloak strategy only trusted public roots. Every server-side token
and userinfo call against the stack's own Keycloak failed certificate
verification.
The CA plumbing already mounts the anchor and exports SSL_CERT_FILE into
every compose service, so the config reads that path and hands it to the
strategy as cacertfile. Ueberauth merges the whole keyword list into
OAuth2.Client.new/1, where request_opts is a struct field that oauth2 2.1.0
forwards to hackney unchanged.
The key is ssl_options, not ssl: Mobilizon configures
Tesla.Adapter.Hackney, whose call passes opts straight into
:hackney.request/5, and hackney reads maps:get(ssl_options, Opts, []). An
ssl key would have been dropped without a word. Only the httpc adapter
spells it ssl.
Overriding ssl_options replaces hackney's default trust store, so
customize_hostname_check restores the https hostname match that
ssl_verify_fun would otherwise have provided.
With no SSL_CERT_FILE the value stays [] and nothing changes.
Verified by rendering the template with Ansible's Jinja settings and
evaluating it under Config.Reader on elixir:1.17-alpine: the empty branch
yields [], the populated branch yields the ssl_options list with
:public_key.pkix_verify_hostname_match_fun/1 resolved to a real function.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
public/index.php requires vendor/autoload_runtime.php, which symfony/runtime
writes from its ComposerPlugin on the post-autoload-dump event. Composer's
AutoloadGenerator guards that dispatch behind `if ($this->runScripts)`, and
every composer install in this Dockerfile passes --no-scripts, so the event
never fired and the file was never written. The image shipped, then fataled
on the first request.
A dump-autoload without --no-scripts fires the event. It fires nothing else
here: SuiteCRM 8.9.1 declares no post-autoload-dump script, and its
auto-scripts (cache:clear, cache:warmup, assets:install,
scrm:copy-legacy-assets) plus Google\Task\Composer::cleanup hang off
post-install-cmd, which dump-autoload does not dispatch. The only listener
left is the runtime plugin.
It runs after the --no-dev prune rather than after the first install, so the
autoloader reflects the vendor tree that actually ships and nothing re-dumps
behind it.
The test -f after the seed COPY turns a future regression into a failed
build instead of an image that only breaks at runtime.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The PR-close canceller had no concurrency group at all, so cancelling
depended entirely on scripts/github/cancel/pull_request_runs.sh, which
needs a runner before it can call the API. Runners were 20 minutes to 21
hours out, and run 31451265380 stayed alive that whole time.
The canceller now joins the group entry-pr-change-orchestrate.yml renders
for the same PR. GitHub clears the group when the run is created, before a
runner is assigned, so the orchestrator dies immediately and the script
becomes a backstop instead of the only path.
The group spells pull_request out literally rather than interpolating
github.event_name. This workflow is triggered by pull_request_target, so
the interpolated form would join the twin the orchestrator's own job
guards already skip on a same-repo PR and leave the working run untouched.
A fork PR runs both twins; the pull_request_target one is still left to
the script.
The branch-delete sweep filtered runs by head_branch alone and shot this
canceller mid-cancel. It now excludes the canceller's own path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
force_shared_db flipped every DB service to shared with no exemption, so
web-app-magento's deliberate embedded MariaDB 11.4 - declared shared:
false with a comment warning that no bot may move the major underneath
it - became dead config and setup:install hit the central engine's 12.3.2,
outside Magento's supported range. The run log names the override:
'swarm force-shared: web-app-magento.services.mariadb -> shared=true'.
Exempts a DB service whose role meta declares a literal shared: false AND
whose role is manager-pinned. The second condition is not decoration:
utils/storage/nfs.py keeps a manager-pinned role's volumes node-local, and
InnoDB over NFS is why the harness force-shares in the first place. Of the
two roles with a literal local DB, magento qualifies and bigbluebutton
(unpinned) does not, so its behaviour is unchanged.
derive_includes._force_shared_db_view documents itself as mirroring
force_shared_db, so it takes the same exemption - without it the dep walk
would compute magento's include closure against the central engine while
the deploy runs a local sidecar.
The sidecar carried no deploy block at all, so an exempted service would
have been free-floating with one task per node over a single-writer
datadir. The block now comes from a container_deploy lookup shared with
sys-svc-container/templates/deploy.yml.j2, which replaces the replicas
ternary and the duplicated placement constraint there. Its unit test
caught a real defect while being written: with compose_mode_force=swarm
over DEPLOYMENT_MODE=compose the delegated compose_replicas returned
empty, emitting a deploy: key with no child.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
22 commits, mostly dependabot and docker image-version bumps, plus two fixes:
5153f3030 loads keycloak-js 26 as an ES module, 87a053518 makes the coredns
Corefile readable to the container user.
One conflict, in roles/web-app-dashboard/templates/javascript/oidc.js.j2, resolved
as a union because the two sides touched the same line for unrelated reasons.
edb96e9ae had hoisted the asset URL into KC_JS_ASSET_URL and rewrote it onto the
onion mirror; 5153f3030 changed the path to lib/keycloak.js and declared a local
Keycloak binding, since the adapter now arrives as a module default export instead
of a window global. The onion rewrite stays at the conflict site, the local binding
is kept because loadKeycloakAdapter returns the constructor rather than setting a
global, and the path change moved to the constant's definition.
An import() fetch always runs in CORS mode, which is why 5153f3030 gave the CDN
aca_origin. The onion route reaches the same asset through web-svc-mirror, whose
proxy.conf.j2 already sets that header, so the onion flavor needs no counterpart.
make quality green on the merge result: test-external, test-integration, test-lint,
test-unit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Autoformat output, not a hand edit. YAML folds a plain scalar's continuation
line to a single space, so Jinja still parses OIDC.CLIENT.ISSUER_URL is
search(...) as one expression.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tor coverage was decided per role variant and nowhere else: 1d9d355bc pins
services.tor.enabled true in variant 0 and false in every other variant, so an
operator had no way to steer it and a job name never said whether it ran behind
an onion. INFINITO_TOR now controls the axis from manual steer down to the
drill, and the entries that get an onion wear one in their label.
assign_tor decides per entry. 'enabled' gives tor to every capable entry,
'disabled' to none, 'auto' to every second one with the run number's parity
flipping which one starts — measured over the full matrix: 397 entries, 257
capable, 129 on even runs, 128 on odd, union 257, overlap 0. Two consecutive
runs therefore cover every capable entry exactly once.
tor_capable reads the entry's own variants, not just meta/services.yml.
Reading only the base config claimed an onion for 131 of 388 entries whose
variant had pinned the gate off — a third of the glyphs would have lied. A
bundle counts as capable when any variant it covers can use tor; a reactive
Jinja flag counts as capable, since that is the role saying 'onion when the
node is dark'.
The 'disable' token is the working half: it prunes svc-net-tor from the include
closure (utils/tests/swarm/derive_includes.py) and clears the service flags in
host_vars (cli/administration/inventory/provision/services_disabler.py). Both
halves are required — with only the first, extend_inventory walks the provider
back in. It runs after the variant overlay, so it also overrides a variant that
pinned the gate true.
Only swarm and compose join the axis. Host discovery shares the same matrix
builder, so without the allowlist its jobs would carry onion glyphs for a mode
that has no onion at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
integration_mastodon's connect returned 401 with
'cURL error 60: unable to get local issuer certificate ... for
https://microblog.infinito.example/api/v1/apps', byte-identical across
attempt and retry. The CA plumbing the repo already injects - the CA
mount, SSL_CERT_FILE, CURL_CA_BUNDLE, php.ini curl.cainfo - cannot reach
that call: OC\Http\Client\Client pins Guzzle's VERIFY to the
certificate manager's own bundle, which defaults to the shipped
resources/config/ca-bundle.crt.
occ security:certificates:import is the only lever that reaches it.
Verified in nextcloud:34: the command is registered as
'security:certificates:import' taking a required path, and
addCertificate() calls createCertificateBundle(), which writes the merged
rootcerts.crt that getAbsoluteBundlePath() then returns. Public TLS keeps
working because the import appends to the shipped bundle. Every
integration_* app talking to an internal host over HTTPS was broken the
same way, not just Mastodon.
Registers the GitLab OAuth application with the scopes the client
actually requests: integration_gitlab/src/utils.js:26 sends
'read_user read_api read_repository', and Doorkeeper's ScopeChecker does
plain string-set containment, so 'api' does not subsume 'read_api' and
consent would have been refused. Also least privilege.
The gitlab spec sampled the URL the page settled on after GitLab's 302,
but GitLab keeps the return path in session[:user_return_to], so that URL
carries no query and the assertion looked for evidence GitLab structurally
does not provide. It now captures the authorize request itself and
additionally asserts redirect_uri points back at this Nextcloud and that
the partner answered below 400.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
c8c996083 declared services.email so the msmtp_curl healthcheck would finally
emit its mail branch, but stopped at the declaration. Three integration tests
went red on the omission: bond>=1 requires a dynamic flag to be pinned true in
variant 0, the coverage rule additionally requires a variant pinning it false,
and every service with an 'enabled:' key needs its <NAME>_SERVICE_ENABLED line
in playwright.env.j2.
The suppression above the flag is the repo-wide convention for email — no spec
anywhere gates on it, because delivery is covered by the sys-svc-mail
integration tests rather than the persona surface; gitea and nextcloud carry
the identical marker.
Pinning email.enabled true in variant 0 does not grow the closure: derive_includes
reports 24 roles for variant 0 with mailu both before and after, because mailu was
already reachable through another edge.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The service resolves to three replicas - no replicas key, so
len(groups['web-app-bookwyrm']) over mgr-01, wrk-01 and wrk-02 - and
every one of them ran 'manage.py migrate' and 'manage.py initdb' from
its start command against the same Postgres.
The migration race self-healed; the seed race did not. init_settings()
is an unguarded check-then-create on a table with no unique constraint,
so two racers both inserted a SiteSettings row and every later
trusted-header login died in user.save(). That is the Django 500 page
photographed by all three failing specs, and the rescue artifact shows
five containers 12-14s apart each dying on a different migration,
alternating 'already exists' and 'does not exist'.
Moves both commands into a one-shot migrate service pinned to one
replica. bookwyrm and worker keep the topology default, so the swarm job
still exercises the multi-replica path. Pinning the bookwyrm key instead
would have been wrong: resource_filter falls back to the role's entity
name, which IS 'bookwyrm', so it would have silently pinned worker too -
invisibly to test_single_replica_justified, which only sees a literal
replicas: 1 line.
Drops the '|| true' around initdb. Command.handle() at v0.9.2 is a plain
sequence of get_or_create steps with no already-initialized abort, so
there was nothing legitimate for it to swallow.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The role had no migration step anywhere in its history
(git log --all -S migrate over the role is empty). The api container runs
'command: gunicorn', which the image entrypoint execs straight into
without touching the database, so the schema was never created and
POST /api/v2/users/login died with
django.db.utils.ProgrammingError: relation "users_user" does not exist.
Adds a one-shot migrate service using the image's own documented
bootstrap. Verified against funkwhale/api:2.0.6 on an empty postgres:
'migrate' runs funkwhale-manage migrate without exec, applies the full
graph and exits 0, so the container reaches Shutdown/Complete and the
swarm converge gate's completed-oneshot carve-out accepts it. The same
probe showed the api container boots and stays up against an empty
schema rather than exiting, so it cannot trip the gate's fail-fast path
while migrate is still running.
CACHE_URL is the second blocker on the same request path: funkwhale keeps
sessions in the cache, so without it auth.login() writes to the image
default redis://127.0.0.1:6379/0 where nothing listens - the celeryworker
already proved that with ECONNREFUSED against that exact URL. Migrate
alone would have moved the 500 from the database layer to the session
layer.
LDAP_USER_ATTR_MAP was single-quoted JSON while funkwhale parses it with
env.list() then split(":"), which yielded keys like '{"username' and
populated no profile field.
The native-login spec asserted on the canonical domain as a substring.
Keycloak's authorization URL carries that domain inside redirect_uri and
oauth2-proxy serves /oauth2/* on the canonical host, so the poll could
never become true; it now requires the host AND the absence of the auth
chain.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The flowise image measures 5.73 GB, the largest in the fleet, and nothing warms a worker's image cache before a stack deploy, so the first worker-side pull is charged against the converge probe's budget. Docker events on the worker show 409 s of pull before container create, while the 150-retry default expires after roughly 370 s, so the stack was reported as not converged 45 s before it came up.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
assert_surface decides which of two different readers a failure belongs to,
and nothing exercised it: contract.py only runs inside the probe container
against a live server, so the logic was verified by reading alone.
The load helper patches the environment before import because the module
reads MCP_URL, MCP_TRANSPORT and MCP_AUTH_HEADER at import time.
Five cases: a matching surface passes; a policy mismatch fails; drift is
reported before policy when the recorded surface is stale; a recorded
difference still fails policy while no gateway narrows it; and a provider
that records no difference is judged on policy alone, which is the case for
every role whose allowlist already equals what its upstream serves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
clickOidcLoginLink watched only the opener. EspoCRM hands off to
Keycloak via window.open, so the opener correctly never navigates and
the helper abandoned a fully rendered Keycloak form in the child window;
the persona then died 45s later at the generic 'did NOT reach an
authenticated surface' gate. The trace shows BrowserContext.page with an
openerPageId, and the click itself succeeded - 'element is visible,
enabled and stable' with no error on the after-event. The [disabled]
Sign in button in the snapshot is EspoCRM's own signIn() disableForm(),
set after the click, not before it.
The helper now races the opener against the popup with Promise.any over
branches that only fulfil once a page reaches openid-connect/auth, and
returns that page; biber and admin pass it to performKeycloakLogin.
Promise.any rather than race: EspoCRM closes the popup after it
authenticates the opener by XHR, and a race would let that losing branch
resolve null and truncate the opener's budget. Measured on a stub
harness: popup closed at 500ms with the opener reaching the IdP at
1200ms returns the opener, where a race returns null. The 15s popup
window matches the idiom already used in the nextcloud addon specs; the
2s window this replaces would have returned null for any adapter that
runs PKCE or discovery before window.open.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The probe compared the served tools against the allowlist and nothing else,
so a vendor growing the tool surface produced the same failure text as a
provider serving the wrong set. The August image bumps therefore read as
contract violations, which sent them to the wrong reader: the contract was
fine, the recorded surface had gone stale.
Compare against upstream_serves first and report what appeared and what
disappeared, then compare against the allowlist. Two failures, two audiences.
Both transports carried the identical comparison, so the check lived twice;
it is now one function.
A provider that records no differing surface, or records it as dynamic,
passes an empty list and is judged on policy alone. Without that, nextcloud's
runtime-composed surface would report drift on every deploy.
Resolving the value needs its own task: within one set_fact a variable is not
guaranteed to see another.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tools.allowlist meant two things at once: the surface a consumer may reach,
which is a decision of ours, and the surface the upstream offers, which the
vendor changes without asking. Conflating them made a version bump read as a
contract violation, and left no place to record what was actually measured.
Split them. allowlist stays policy; upstream_serves records the observation at
supported_version. State the second only where it DIFFERS, because a provider
that serves exactly what is allowed would otherwise carry the same list twice
and the copies would drift apart. Absence means identical.
The difference between the two measures unenforced exposure: mattermost 103,
gitlab 2, and zero wherever the fields collapse. A non-empty difference is what
tells us a provider needs a gateway in front of it.
Require a non-empty allowlist for an enabled, non-adapter, server-capable
provider. An empty one used to make the probe's comparison vacuous, which read
as a pass while every tool the upstream serves stayed reachable. Nextcloud was
in exactly that state.
Three exemptions, all semantic: an adapter authors its tools here against a
REST upstream, so there is nothing to observe; a provider declared
enabled: false is never admitted; and upstream_serves: dynamic covers an
upstream composing its surface at runtime, which nextcloud does by proxying
MCP servers an administrator configures.
Reject an allowlist naming a tool the upstream does not serve. That found
mattermost's add_user_to_channel, gone since 11.10.0 renamed it
add_channel_member, which would have been advertised and never callable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The adapter could only translate REST to MCP, so a provider whose upstream
already serves MCP tools had no way to sit behind it. Those providers are
exactly the ones with no enforcement point: their declared allowlist is a
statement about the upstream rather than a constraint on it, and a client
can reach any operation the upstream happens to serve by naming it.
Add the policy layer for that case. An MCP upstream exposes no HTTP method,
so a tool's blast radius cannot be inferred the way the REST layer infers
it from GET/HEAD; every tool must declare whether it mutates, and one that
does not say is refused at startup rather than assumed harmless.
Forwarding builds its own JSON-RPC envelope with a fixed method and a fresh
id. The client's envelope is never passed through, or a caller could reach
another method by nesting it in params. A single SSE frame is unwrapped,
because a streamable-http server may answer one call that way and plain
JSON parsing would choke on the data prefix.
Routing is deliberately total: a contract that omits upstream_kind is a
REST one, which is what every adapter renders today, so the ten existing
instances are untouched.
Report an upstream that grew a tool instead of filtering it forever, and
one that dropped a contracted tool, so drift is visible either way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
swarmkit's default failure_action is pause, and the updater returns early
on a paused state without ever reaching its completion branch, so a
service that latches never leaves that state on its own. The gate every
role runs read replica counts and nothing else, so a latched update looked
exactly like a slow one and burned the full retry budget before failing
without a cause. That is what turned one corrupted openclaw task into a
25-minute silent timeout.
Read UpdateStatus before comparing replicas. The order carries the fix: a
rolled-back service reports N/N of the PREVIOUS spec, so a replica check
alone reports it as converged. Guard the format against a service with no
update in flight, since a bare .UpdateStatus.State nil-pointers and would
fail the whole multi-service inspect.
The handler has to honour the give-up or the gate keeps polling. It stops
only on the explicit give-up and still retries every other exit code, so a
timeout returning 124 does not turn into a failed deploy. That deviates
from wait_converged.yml, which stops on anything but 1; the deviation is
deliberate.
Stop treating rollback_completed as success in the per-service probe. It
is inert today because nothing here issues a rollback, and it is the guard
that keeps a silent revert from returning as a green deploy.
failure_action is deliberately left at the default. Rolling back would
revert a failed image bump while reporting success, and a daily cron opens
those bumps; it would also place the old binary against a schema ~28 roles
migrate on start.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two callers polled the same stack_ready.sh with different budgets: the
handler read swarm_stack_converge_retries while post_deploy hardcoded
150. Eleven roles had set the override and were unprotected at the
second caller anyway. Both now read one SPOT in group_vars.
The gate also had no way to give up. A crash-looping espocrm burned 600
polls (20 min) before failing, while flowise was killed 45 s short of a
409 s image pull. has_fatal_task now exits 2 - and both callers stop at
once via 'until: rc in [0, 2]' - when a slot's newest attempt is
Rejected or Pending, or when a slot burned three attempts after the
service churned past swarm_stack_fatal_grace. Patience only grows where
nothing is broken.
report_tasks loses its 'awk !/error=$/' filter, which printed only rows
that HAVE an error and so stayed silent in exactly the no-error case
that occurred.
Measured on a live single-node swarm: a service failing 3x and
converging on the 4th returned rc 2 at t+15s before this change and rc 0
at t+32s after; a 3-replica stop-first rolling update gave 68/68 samples
rc 0; a permanent crash loop still reaches rc 2. The deploy epoch comes
from 'docker service inspect .UpdatedAt.Unix' rather than a /tmp marker,
which was reproduced contaminating a later deploy.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hourly sweep fought the rest of the CI control plane instead of
helping it: it preempted itself (17 of the last 29 runs concluded
cancelled 1-2s after their successor was created, for 8s of work behind
a 74-80min queue wait) and shipped MAX_ATTEMPTS=0, which the script
reads as no ceiling while its own README promised the opposite. The 24h
age cutoff could not bound it either, because every rerun refreshes the
run's updatedAt and so keeps it inside the window.
Removes the workflow, the two scripts it was the sole consumer of, its
unit test, and the CI_RERUN_* documentation that would otherwise point
at a deleted file. The workflow is disabled in both origin and fork so
no sweep fires before this lands.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Without MM_CONFIG mattermost uses its file store at
/mattermost/config/config.json, which lives on a volume the swarm rewrite
places on the shared NFS export. The role is unpinned, so it runs one task
per node, and mattermost rewrites that file at startup while normalising
and migrating its config. Several replicas then write the same file over
NFS.
Point MM_CONFIG at the database instead. Read at the pinned 11.10.0 tag:
getConfigDSN falls back to env MM_CONFIG, server.go hands the result to
NewStoreFromDSN, and that selects NewDatabaseStore whenever IsDatabaseDSN
matches a postgres:// prefix. The file store is then never constructed.
Nothing in this role reads or patches config.json; the only reference is
the volume mount.
This externalises the state rather than capping the role at one replica,
so mattermost stays horizontally scalable.
Resolve the DSN once in vars/main.yml from the database lookup's url_full,
which already assembles scheme, credentials, host, port and name, and
reference that constant from both MM_SQLSETTINGS_DATASOURCE and MM_CONFIG.
The template previously spelled the DSN out across six lookups and
hardcoded the postgres:// scheme that url_full derives. Appending options
to url_full follows the pattern web-app-openproject already uses.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A retrigger carried the workspace and instructions toggles over from the
run title, so a suite that had already gone green was dispatched again
and spent runners on work with a known answer.
Derive each toggle from the source run's job record instead: a suite is
turned off only when every one of its jobs reports success. A suite that
was skipped, cancelled or never dispatched has not passed, so it is still
asked for; absence of a failure is not evidence of a success. One failing
shard keeps the whole suite on.
Match against the job path rather than the run title by dropping the
workflow-name segment, and fail open when nothing matches, so an unknown
job layout re-runs the suite rather than silently skipping it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The provisioner probed a stored token first, so a token that outlived a
password change kept the run green while the deployed password no longer
authenticated. Authenticate with the inventory password first and consult
the stored token only once that succeeds; when it fails, converge the
password onto the existing user.
The write targets /Users/Password?userId=<id>: omitting the query
parameter retargets the caller's own account, and an administrator acting
on another user is why no current password is required. Accept 200 as
well as 204, matching the sibling calls; demanding 204 alone would have
failed a successful convergence.
The repair branch is only reached when authentication failed and always
writes, so it now reports CHANGED. It previously printed UNCHANGED
whenever the user already existed, which told Ansible nothing had changed
while a credential had just been rotated.
configure-auth.sh no longer continues past an administrator it cannot
authenticate. Both call sites logged a warning and carried on, finishing
green with SSO and LDAP silently unconfigured.
Adds the role's first unit tests, covering token reuse, token staleness,
the convergence write and its rejection path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>