Skip to content

bug(ansible): PPA/repo add fetches its signing key from the keyserver with no retry — one transient failure aborts provisioning (8 call sites) #17230

Closed

Description

@mrveiss

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: false

So 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_repository task in roles/_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
    become failed_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 of repo_tests/ansible_shared_cert_become_16020_test.py and
    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


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

  1. added this to the v0.9.0 milestone on Sep 21, 2026
  2. mrveiss commented on Sep 27, 2026

    @mrveiss
    OwnerAuthor

    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/main blobs.

    • The apt_repository task in roles/_shared/tasks/add_apt_repository_idempotent.yml retries.
      :91-107 — the add task registers _apt_repo_add, then until: _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_when and no ignore_errors; the comment at :102 states the rule — "it is never downgraded to failed_when: false". The one failed_when: false in 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:136 pins 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: :127 exactly one repo-add task exists (so a second, unguarded copy is a failure), :143/:149 the checker accepts a correct task and rejects broken variants, :191 no file overrides the retry budget, :242 the override detector matches assignments only and not prose, :261 the 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 reference add_apt_repository_idempotent, and 8 of those references are include_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

    • The retry covers the repo add. A failure in the surrounding steps — the device-shipped-config detection at :58, or the key import elsewhere in the role — is unchanged by this issue.
    • The guard is a static reader of the task file and the variable files. It proves the task declares a retry and that nothing overrides the budget; it does not exercise a real transient apt failure, which would need a network fixture. That limit is the reason :127's "exactly one repo-add task" assertion matters: it is what keeps the static check pointed at the task that actually runs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions