Repository navigation
bug(ansible): PPA/repo add fetches its signing key from the keyserver with no retry — one transient failure aborts provisioning (8 call sites) #17230
Description
Symptom (live)
Provisioning aborts during the python_interpreter role at approximately task 18:
gpg: keyserver receive failed: End of file
This is the current provisioning blocker. It fires long before the nginx/cert
failure chain that #17172 / #16020 addressed (fixed in #17184), so clearing that
chain does not clear this one.
Root cause
roles/_shared/tasks/add_apt_repository_idempotent.yml:89-96 is the final task of
the shared repo-add helper:
- name: "{{ apt_repo_label }} | Add apt repository (not previously configured)"
ansible.builtin.apt_repository:
repo: "{{ apt_repo_spec }}"
state: present
filename: "{{ apt_repo_filename }}"
become: true
when: _apt_repo_present.stdout | trim == 'MISSING'When apt_repo_spec is a ppa: specification, ansible.builtin.apt_repository
retrieves the repository's signing key from the Launchpad keyserver at
provisioning time. That is a network fetch with no local fallback.
The task declares no retries, no until, no delay, and no timeout. A single
transient keyserver failure therefore aborts the entire play.
Caller that trips it today — roles/python_interpreter/tasks/main.yml:47-55:
- name: "python_interpreter | Add deadsnakes PPA (idempotent, #7218)"
ansible.builtin.include_tasks: ../../_shared/tasks/add_apt_repository_idempotent.yml
vars:
apt_repo_match: "deadsnakes/ppa"
apt_repo_spec: "ppa:deadsnakes/ppa"The asymmetry that makes this a defect rather than bad luck
The role already anticipates exactly this flakiness — one task too late.
roles/python_interpreter/tasks/main.yml:57-71 bounds the cache refresh against
an unreachable PPA:
# Only fires when the interpreter is missing, but when it does it must tolerate
# flaky PPAs (the deadsnakes PPA was just added; if it's unreachable, the
# next deploy retry will still need to install the interpreter).
- name: "python_interpreter | Refresh apt cache (best-effort, bounded; #6719)"
ansible.builtin.command:
cmd: >-
timeout 60 apt-get update -qq
-o Acquire::Retries=0
-o Acquire::http::Timeout=10
failed_when: falseSo the step after the PPA add tolerates the PPA being unreachable, while the step
that adds the PPA — the one that actually performs the network key fetch — does
not. The hardening was applied to the wrong task.
Blast radius
The helper is shared. 8 call sites across 7 roles inherit the same unretried
network fetch:
| Role | File |
|---|---|
python_interpreter |
roles/python_interpreter/tasks/main.yml |
docker |
roles/docker/tasks/main.yml |
postgresql |
roles/postgresql/tasks/install.yml |
redis |
roles/redis/tasks/main.yml |
monitoring |
roles/monitoring/tasks/grafana.yml |
slm_manager |
roles/slm_manager/tasks/grafana.yml |
agent_config |
roles/agent_config/tasks/openvino.yml |
All paths relative to autobot-slm-backend/ansible/.
Only the ppa: call sites do a keyserver fetch; the deb https://… ones still
perform an unretried network operation against the repository host, so the
fragility is shared even where the key path differs.
Acceptance criteria
- The
apt_repositorytask inroles/_shared/tasks/add_apt_repository_idempotent.yml
retries on failure with a bounded backoff (retries/delay/until), so a
single transient keyserver or repository-host failure does not abort the play. - Failure after the retry budget is exhausted still fails loudly — this must not
becomefailed_when: false. A repo that genuinely cannot be added has to stop the
deploy, matching the existing fail-loud stance at
roles/python_interpreter/tasks/main.yml:81-87. - The retry behaviour is covered by a guard test under
repo_tests/, following the
pattern ofrepo_tests/ansible_shared_cert_become_16020_test.pyand
repo_tests/ansible_nginx_cert_before_install_17172_test.py. - Verified against all 8 call sites — the helper is shared, so the fix lands once and
the guard asserts it once.
Out of scope
Pinning/shipping repository signing keys locally instead of fetching them at
provisioning time would remove the network dependency entirely rather than making it
tolerant. That is a larger supply-chain change and belongs in its own issue if the
owner wants it.
Related
- fix(ansible): cert provisioning chain — own privilege, and before nginx's install (#16020, #17172) #17184 — slice 1 of the provisioning chain (cert
become+ ordering), merged - ansible.cfg puts become under [defaults], where ansible ignores it — the global escalation default has never applied #14699 — provisioning umbrella (
ansible.cfgbecomeplacement) - guards(ansible): 13 shared tasks touch root-owned paths without declaring become — privilege is ambient #17185 — shared tasks touching root paths without declaring
become - ci(docker): an unreachable Ubuntu mirror surfaces as a misleading 'held broken packages' error; apt steps neither retry nor fail on index errors #16291 — the CI-Docker analogue: unreachable apt mirror, no retry, misleading error
- tech-debt(install.sh+ansible): audit ALL apt_repository sites for noble-conflict pattern (#7144 follow-up) #7218 — the issue this helper was written for
Correction (2026-09-21, same day as filing). Two line ranges were off.
:56-70 started on a blank line — the comment block begins at :57 and the task it describes
runs :61-71, so the range is now :57-71. :81-86 stopped one line short of the task, which
ends at :87 (the last line of the file). Substance unchanged; both ranges re-derived from the
file with explicit line numbering rather than from a sed window, which is what hid the
leading blank line the first time.
Activity
test(ansible): match the apt repo retry's until exactly and pair the …
test(ansible): pin the apt repo retry budget against overrides and st…
test(ansible): read the retry budget only from its exact template, fl…
test(ansible): declare the override sweep's reach through repo_tests.…
test(ansible): pin each retry budget to its own variable and default,…
fix(ansible): retry the shared apt repository add on a transient keys…
mrveiss commentedon Sep 27, 2026
Acceptance evidence, verified against merged code — 4 of 4 met
Closed by #17525. Neither the issue nor the PR body carried a criterion-by-criterion argument, so here it is against
origin/mainblobs.The
apt_repositorytask inroles/_shared/tasks/add_apt_repository_idempotent.ymlretries.:91-107— the add task registers_apt_repo_add, thenuntil: _apt_repo_add is succeeded,retries: "{{ apt_repo_retries | default(5) }}",delay: "{{ apt_repo_retry_delay | default(10) }}". Both knobs are documented as optional parameters at:40-41, so a caller can tighten or widen the budget without editing the helper.Failure after the retry budget is exhausted still fails loudly.
The task carries no
failed_whenand noignore_errors; the comment at:102states the rule — "it is never downgraded tofailed_when: false". The onefailed_when: falsein the file is at:58, on the detection step that looks for a device-shipped repo config, where a miss is a normal outcome rather than an error.repo_tests/ansible_apt_repo_add_retry_17230_test.py:136pins the pairing:test_the_repo_add_retries_and_still_fails_loudly.The retry behaviour is covered by a guard test under
repo_tests/.Seven tests, and the ones that make it able to fail rather than agree with itself:
:127exactly one repo-add task exists (so a second, unguarded copy is a failure),:143/:149the checker accepts a correct task and rejects broken variants,:191no file overrides the retry budget,:242the override detector matches assignments only and not prose,:261the scan reads every variable-file type Ansible loads — which is the check that stops the override sweep from passing by looking in too few places.Verified against all 8 call sites — the helper is shared, so the fix lands once.
Measured on
origin/main: 8 files referenceadd_apt_repository_idempotent, and 8 of those references areinclude_tasks/import_tasks. So every call site routes through the helper and none carries its own copy of the task.What this closure does not cover
:58, or the key import elsewhere in the role — is unchanged by this issue.:127's "exactly one repo-add task" assertion matters: it is what keeps the static check pointed at the task that actually runs.fix(guards): cite the task not its line number, and let the #17230 gu…
fix(install): a preserved apt repo must serve the package before the …