From dfaeac4729b1e98e0ff4f18b75c36224edf7c170 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20M=C3=A9ndez=20Hern=C3=A1ndez?= Date: Wed, 12 Aug 2026 00:58:22 +0200 Subject: [PATCH 01/10] Add deploy_network role for creating Podman bridge networks Introduces a shared role that creates a Podman network with a given driver/internal/isolation configuration, used by later commits to move core services off host networking onto dedicated bridge networks. Netavark 2.0 flipped the bridge driver's isolation default from opt-in to opt-out (containers/netavark#709), so the role always passes an explicit isolate value rather than relying on the installed netavark's own default. Because containers.podman 1.20.2 serializes that boolean incorrectly for Podman 6 + Netavark 2 (until containers/ansible-podman-collections picks up commit d45819fd6561), the role falls back to creating/inspecting the network via the podman CLI directly on newer Podman/Netavark, and only uses the containers.podman module on older versions. Co-authored-by: Cursor --- src/roles/deploy_network/defaults/main.yaml | 12 ++++ src/roles/deploy_network/tasks/main.yaml | 3 + src/roles/deploy_network/tasks/podman.yaml | 78 +++++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 src/roles/deploy_network/defaults/main.yaml create mode 100644 src/roles/deploy_network/tasks/main.yaml create mode 100644 src/roles/deploy_network/tasks/podman.yaml diff --git a/src/roles/deploy_network/defaults/main.yaml b/src/roles/deploy_network/defaults/main.yaml new file mode 100644 index 000000000..d453947c3 --- /dev/null +++ b/src/roles/deploy_network/defaults/main.yaml @@ -0,0 +1,12 @@ +--- +deploy_network_name: foreman-network +deploy_network_driver: bridge +deploy_network_internal: false +deploy_network_isolate: false +deploy_network_ipv6: false + +# Optional explicit network settings. Podman auto-assigns these when omitted. +# deploy_network_subnet: "10.89.0.0/24" +# deploy_network_gateway: "10.89.0.1" +# deploy_network_dns: +# - 8.8.8.8 diff --git a/src/roles/deploy_network/tasks/main.yaml b/src/roles/deploy_network/tasks/main.yaml new file mode 100644 index 000000000..d340eb0e8 --- /dev/null +++ b/src/roles/deploy_network/tasks/main.yaml @@ -0,0 +1,3 @@ +--- +- name: Deploy network + ansible.builtin.include_tasks: podman.yaml diff --git a/src/roles/deploy_network/tasks/podman.yaml b/src/roles/deploy_network/tasks/podman.yaml new file mode 100644 index 000000000..151c58e2d --- /dev/null +++ b/src/roles/deploy_network/tasks/podman.yaml @@ -0,0 +1,78 @@ +--- +- name: Gather Podman system info for network creation + containers.podman.podman_system_info: + register: deploy_network_podman_system_info + failed_when: false + changed_when: false + +- name: Set deploy_network creation strategy + ansible.builtin.set_fact: + deploy_network_is_netavark2_plus: >- + {{ + ( + deploy_network_podman_system_info.podman_system_info.version.Version + | default('0') + ).split('.')[0] | int >= 6 + or + ( + ( + deploy_network_podman_system_info.podman_system_info.host.networkBackendInfo.version + | default('netavark 0') + ).split()[-1].split('.')[0] | int + ) >= 2 + }} + +# netavark 2.0 changed the bridge driver's isolation default from opt-in to +# opt-out (containers/netavark#709): a bridge network created with no explicit +# isolate option is now isolated from every other bridge network by default, +# whereas netavark 1.x left it non-isolated by default. We always pass an +# explicit isolate value (strict or false) so behavior is deterministic +# regardless of the installed netavark's own default, rather than relying on +# omission (which silently produces isolation on netavark 2+ for every +# network we did NOT intend to isolate, breaking hairpin NAT between them). +# +# containers.podman 1.20.2 serializes opt.isolate booleans as Python-style +# True/False, which Podman 6 + netavark 2 reject ("invalid isolate option +# \"True\""/"\"False\""). Upstream fixed this on main in +# containers/ansible-podman-collections commit d45819fd6561, but until that +# reaches a released collection we create/normalize these networks via the +# CLI with the explicit lowercase value form Podman currently documents, +# instead of going through the module on those versions. +- name: Create network with explicit isolation via compatibility fallback for {{ deploy_network_name }} + when: deploy_network_is_netavark2_plus + block: + - name: Inspect existing network {{ deploy_network_name }} + ansible.builtin.command: + cmd: "podman network inspect {{ deploy_network_name | quote }}" + register: deploy_network_existing_inspect + changed_when: false + failed_when: false + + - name: Create network {{ deploy_network_name }} + ansible.builtin.command: + cmd: >- + podman network create + --driver {{ deploy_network_driver | quote }} + {% if deploy_network_internal %} --internal{% endif %} + {% if deploy_network_ipv6 %} --ipv6{% endif %} + {% if deploy_network_subnet is defined %} --subnet {{ deploy_network_subnet | quote }}{% endif %} + {% if deploy_network_gateway is defined %} --gateway {{ deploy_network_gateway | quote }}{% endif %} + {% for dns_server in deploy_network_dns | default([]) %} --dns {{ dns_server | quote }}{% endfor %} + --opt isolate={{ 'strict' if (deploy_network_isolate | bool) else 'false' }} + {{ deploy_network_name | quote }} + changed_when: true + when: deploy_network_existing_inspect.rc != 0 + +- name: Create network {{ deploy_network_name }} + containers.podman.podman_network: + name: "{{ deploy_network_name }}" + driver: "{{ deploy_network_driver }}" + internal: "{{ deploy_network_internal }}" + ipv6: "{{ deploy_network_ipv6 }}" + subnet: "{{ deploy_network_subnet | default(omit) }}" + gateway: "{{ deploy_network_gateway | default(omit) }}" + dns: "{{ deploy_network_dns | default(omit) }}" + opt: + isolate: "{{ deploy_network_isolate | bool }}" + state: present + when: not deploy_network_is_netavark2_plus From 73f9b3ba4544372c68233d463b33d6fbc9b236b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20M=C3=A9ndez=20Hern=C3=A1ndez?= Date: Wed, 12 Aug 2026 01:00:29 +0200 Subject: [PATCH 02/10] Move core services onto Podman bridge networks PostgreSQL, Valkey, Candlepin, Foreman, and Pulp move off host networking onto dedicated bridge networks created by deploy_network: foreman-db (internal PostgreSQL and its clients), foreman-cache (Valkey and its clients), and foreman-app (Foreman, Candlepin, Pulp). Services now reach each other by container DNS name (postgresql, valkey, candlepin) instead of localhost/host.containers.internal. PostgreSQL now also listens on a shared Unix socket (postgresql_socket_dir) in addition to the bridge network, and the postgresql/check_*/backup roles that ran administrative queries as the postgres superuser switch to socket-based login rather than password+host auth, since that socket is reachable from any container sharing the mount without needing to open localhost access. The backup role's own database dump connection info still needs a real reachable host, so it picks the socket for internal databases and the configured database_host otherwise via a new backup_database_host fact. Candlepin gets an explicit candlepin_healthcheck_host/hostname pair and a --resolve-based healthcheck so its container binds 0.0.0.0 while still presenting the right SNI/Host identity used by its own cert (added in a later commit). Foreman and Pulp's Redis/Candlepin URLs move from localhost to their new container DNS names, driven by new foreman_networks/foreman_migration_networks and pulp_networks/pulp_migration_networks variables that also add the foreman-proxy network to Foreman's rake/console containers when that feature is enabled. Co-authored-by: Cursor --- src/playbooks/deploy-proxy/deploy-proxy.yaml | 15 ++++++ src/playbooks/deploy/deploy.yaml | 13 ++++++ src/roles/backup/tasks/main.yaml | 12 ++++- src/roles/candlepin/defaults/main.yml | 7 ++- src/roles/candlepin/tasks/main.yml | 8 +++- .../tasks/main.yaml | 3 +- src/roles/check_foreman_tasks/tasks/main.yaml | 3 +- .../check_host_facts_count/tasks/main.yaml | 3 +- src/roles/foreman/defaults/main.yaml | 13 +++++- src/roles/foreman/tasks/main.yaml | 8 ++-- src/roles/foreman/templates/katello.yaml.j2 | 2 +- src/roles/foreman/templates/settings.yaml.j2 | 2 +- src/roles/postgresql/defaults/main.yml | 3 +- src/roles/postgresql/tasks/main.yml | 25 ++++++++-- src/roles/pulp/defaults/main.yaml | 11 ++++- src/roles/pulp/tasks/main.yaml | 10 ++-- src/roles/valkey/defaults/main.yml | 1 + src/roles/valkey/tasks/main.yaml | 4 +- src/vars/database.yml | 2 +- tests/unit/backup_role_test.py | 46 +++++++++++++++++++ 20 files changed, 158 insertions(+), 33 deletions(-) create mode 100644 tests/unit/backup_role_test.py diff --git a/src/playbooks/deploy-proxy/deploy-proxy.yaml b/src/playbooks/deploy-proxy/deploy-proxy.yaml index 5ba00c2a1..426d36479 100644 --- a/src/playbooks/deploy-proxy/deploy-proxy.yaml +++ b/src/playbooks/deploy-proxy/deploy-proxy.yaml @@ -25,6 +25,21 @@ certificate_checks_certificate: "{{ server_certificate }}" certificate_checks_key: "{{ server_key }}" certificate_checks_ca: "{{ server_ca_certificate }}" + - role: deploy_network + vars: + deploy_network_name: foreman-db + deploy_network_internal: true + deploy_network_isolate: true + when: + - database_mode == 'internal' + - role: deploy_network + vars: + deploy_network_name: foreman-cache + deploy_network_internal: true + deploy_network_isolate: true + - role: deploy_network + vars: + deploy_network_name: foreman-app - role: oauth_from_bundle - role: postgresql when: diff --git a/src/playbooks/deploy/deploy.yaml b/src/playbooks/deploy/deploy.yaml index 4e77b6398..e75a4046e 100644 --- a/src/playbooks/deploy/deploy.yaml +++ b/src/playbooks/deploy/deploy.yaml @@ -26,6 +26,19 @@ certificate_checks_certificate: "{{ server_certificate }}" certificate_checks_key: "{{ server_key }}" certificate_checks_ca: "{{ server_ca_certificate }}" + - role: deploy_network + vars: + deploy_network_name: foreman-db + deploy_network_internal: true + deploy_network_isolate: true + - role: deploy_network + vars: + deploy_network_name: foreman-cache + deploy_network_internal: true + deploy_network_isolate: true + - role: deploy_network + vars: + deploy_network_name: foreman-app - role: postgresql when: - database_mode == 'internal' diff --git a/src/roles/backup/tasks/main.yaml b/src/roles/backup/tasks/main.yaml index f73999b17..9b0263fbf 100644 --- a/src/roles/backup/tasks/main.yaml +++ b/src/roles/backup/tasks/main.yaml @@ -34,6 +34,14 @@ ansible.builtin.include_tasks: file: preflight.yaml + - name: Select database access host for backup + ansible.builtin.set_fact: + backup_database_host: >- + {{ + (backup_database_mode == 'internal') + | ternary((postgresql_socket_dir | default('/var/run/postgresql')), database_host) + }} + - name: Create timestamped backup directory ansible.builtin.file: path: "{{ backup_dir_full }}" @@ -63,7 +71,7 @@ - name: Wait for PostgreSQL readiness ansible.builtin.command: - cmd: pg_isready -h {{ database_host }} -p {{ database_port }} + cmd: pg_isready -h {{ backup_database_host }} -p {{ database_port }} register: backup_pg_ready retries: "{{ backup_postgresql_ready_retries }}" delay: "{{ backup_postgresql_ready_delay }}" @@ -77,7 +85,7 @@ db_entry: name: "{{ item.name }}" database: "{{ item.database }}" - host: "{{ database_host }}" + host: "{{ backup_database_host }}" port: "{{ database_port }}" user: "{{ item.user }}" password: "{{ item.password }}" diff --git a/src/roles/candlepin/defaults/main.yml b/src/roles/candlepin/defaults/main.yml index f96bf2a82..05b414eb9 100644 --- a/src/roles/candlepin/defaults/main.yml +++ b/src/roles/candlepin/defaults/main.yml @@ -1,6 +1,9 @@ --- candlepin_ssl_port: 23443 -candlepin_hostname: localhost +candlepin_hostname: "0.0.0.0" +candlepin_healthcheck_host: candlepin +candlepin_networks: >- + {{ ((database_mode == 'internal') | ternary(['foreman-db'], [])) + ['foreman-app'] }} candlepin_tls_versions: - "TLSv1.2" - "TLSv1.3" @@ -13,7 +16,7 @@ candlepin_container_image: quay.io/foreman/candlepin candlepin_container_tag: "4.4.14" candlepin_secret_mount_opts: "mode=0440,uid=0,gid=53,type=mount" -candlepin_database_host: localhost +candlepin_database_host: postgresql candlepin_database_port: 5432 candlepin_database_ssl: false candlepin_database_ssl_mode: disable diff --git a/src/roles/candlepin/tasks/main.yml b/src/roles/candlepin/tasks/main.yml index 876e4000e..54adb537f 100644 --- a/src/roles/candlepin/tasks/main.yml +++ b/src/roles/candlepin/tasks/main.yml @@ -74,7 +74,7 @@ name: "candlepin" image: candlepin.image state: quadlet - network: host + network: "{{ candlepin_networks }}" hostname: "{{ ansible_facts['hostname'] }}.local" secrets: - 'candlepin-ca-cert,target=/etc/candlepin/certs/candlepin-ca.crt,{{ candlepin_secret_mount_opts }}' @@ -97,7 +97,11 @@ After=valkey.service postgresql.service [Service] TimeoutStartSec=300 - healthcheck: curl --fail --insecure --noproxy localhost https://localhost:23443/candlepin/status + healthcheck: >- + curl --fail --cacert /etc/candlepin/certs/candlepin-ca.crt + --noproxy {{ candlepin_healthcheck_host }} + --resolve {{ candlepin_healthcheck_host }}:{{ candlepin_ssl_port }}:127.0.0.1 + https://{{ candlepin_healthcheck_host }}:{{ candlepin_ssl_port }}/candlepin/status sdnotify: healthy - name: Run daemon reload to make Quadlet create the service files diff --git a/src/roles/check_duplicate_permissions/tasks/main.yaml b/src/roles/check_duplicate_permissions/tasks/main.yaml index 0bdaf4fa4..dc4381573 100644 --- a/src/roles/check_duplicate_permissions/tasks/main.yaml +++ b/src/roles/check_duplicate_permissions/tasks/main.yaml @@ -5,7 +5,8 @@ login_db: "{{ foreman_database_name }}" login_user: "{{ foreman_database_user }}" login_password: "{{ foreman_database_password }}" - login_host: "{{ foreman_database_host }}" + login_host: "{{ (database_mode == 'internal') | ternary(omit, foreman_database_host) }}" + login_unix_socket: "{{ (database_mode == 'internal') | ternary(postgresql_socket_dir | default('/var/run/postgresql'), omit) }}" query: | SELECT id, name FROM permissions p diff --git a/src/roles/check_foreman_tasks/tasks/main.yaml b/src/roles/check_foreman_tasks/tasks/main.yaml index f3820cd55..43847c625 100644 --- a/src/roles/check_foreman_tasks/tasks/main.yaml +++ b/src/roles/check_foreman_tasks/tasks/main.yaml @@ -4,7 +4,8 @@ login_db: "{{ foreman_database_name }}" login_user: "{{ foreman_database_user }}" login_password: "{{ foreman_database_password }}" - login_host: "{{ foreman_database_host }}" + login_host: "{{ (database_mode == 'internal') | ternary(omit, foreman_database_host) }}" + login_unix_socket: "{{ (database_mode == 'internal') | ternary(postgresql_socket_dir | default('/var/run/postgresql'), omit) }}" query: | SELECT count(*) AS count FROM foreman_tasks_tasks diff --git a/src/roles/check_host_facts_count/tasks/main.yaml b/src/roles/check_host_facts_count/tasks/main.yaml index 7400013ea..9c3d05ca2 100644 --- a/src/roles/check_host_facts_count/tasks/main.yaml +++ b/src/roles/check_host_facts_count/tasks/main.yaml @@ -4,7 +4,8 @@ login_db: "{{ foreman_database_name }}" login_user: "{{ foreman_database_user }}" login_password: "{{ foreman_database_password }}" - login_host: "{{ foreman_database_host }}" + login_host: "{{ (database_mode == 'internal') | ternary(omit, foreman_database_host) }}" + login_unix_socket: "{{ (database_mode == 'internal') | ternary(postgresql_socket_dir | default('/var/run/postgresql'), omit) }}" query: | SELECT fact_values.host_id, count(fact_values.id) as count FROM fact_values diff --git a/src/roles/foreman/defaults/main.yaml b/src/roles/foreman/defaults/main.yaml index 1ccd3fdea..5793dd9ae 100644 --- a/src/roles/foreman/defaults/main.yaml +++ b/src/roles/foreman/defaults/main.yaml @@ -5,12 +5,21 @@ foreman_container_name: foreman foreman_database_name: foreman foreman_database_user: foreman -foreman_database_host: localhost +foreman_database_host: postgresql foreman_database_port: 5432 foreman_database_pool: 9 foreman_database_ssl_mode: disable foreman_database_ssl_ca: # noqa: no-empty-defaults foreman_database_ssl_ca_path: /etc/foreman/db-ca.crt +foreman_networks: >- + {{ + ((database_mode == 'internal') | ternary(['foreman-db'], [])) + + ['foreman-cache', 'foreman-app'] + + ((enabled_features | has_feature('foreman-proxy')) | ternary(['foreman-proxy'], [])) + }} +foreman_migration_networks: "{{ ['foreman-db'] if database_mode == 'internal' else ['foreman-app'] }}" +foreman_rails_cache_url: "redis://valkey:6379/4" +foreman_dynflow_redis_url: "redis://valkey:6379/6" foreman_name: "{{ ansible_facts['fqdn'] }}" foreman_listen_stream: localhost:3000 @@ -55,7 +64,7 @@ foreman_env: FOREMAN_PUMA_WORKERS: "{{ foreman_puma_workers }}" foreman_dynflow_extra_env: - DYNFLOW_REDIS_URL: "redis://localhost:6379/6" + DYNFLOW_REDIS_URL: "{{ foreman_dynflow_redis_url }}" REDIS_PROVIDER: "DYNFLOW_REDIS_URL" foreman_dynflow_env: "{{ foreman_env | ansible.builtin.combine(foreman_dynflow_extra_env) }}" diff --git a/src/roles/foreman/tasks/main.yaml b/src/roles/foreman/tasks/main.yaml index 2ac36f12f..0d1a7e1e8 100644 --- a/src/roles/foreman/tasks/main.yaml +++ b/src/roles/foreman/tasks/main.yaml @@ -113,7 +113,7 @@ image: foreman.image state: quadlet sdnotify: true - network: host + network: "{{ foreman_networks }}" hostname: "{{ ansible_facts['hostname'] }}.local" volume: - 'foreman-data-run:/var/run/foreman:rw,z,U' @@ -158,7 +158,7 @@ image: foreman.image state: quadlet sdnotify: true - network: host + network: "{{ foreman_networks }}" hostname: "{{ ansible_facts['hostname'] }}.local" volume: - 'foreman-data-run:/var/run/foreman:rw,z,U' @@ -212,7 +212,7 @@ state: quadlet image: foreman.image sdnotify: false - network: host + network: "{{ foreman_networks }}" hostname: "{{ ansible_facts['hostname'] }}.local" command: "foreman-rake {{ item.rake }}" volume: @@ -256,7 +256,7 @@ state: quadlet image: foreman.image sdnotify: false - network: host + network: "{{ foreman_migration_networks }}" command: bash -c "bin/rails db:migrate && bin/rails db:seed" env: "{{ foreman_env }}" secrets: diff --git a/src/roles/foreman/templates/katello.yaml.j2 b/src/roles/foreman/templates/katello.yaml.j2 index 14450c508..8f2f6d641 100644 --- a/src/roles/foreman/templates/katello.yaml.j2 +++ b/src/roles/foreman/templates/katello.yaml.j2 @@ -3,7 +3,7 @@ :rest_client_timeout: 3600 :candlepin: - :url: https://localhost:23443/candlepin + :url: https://candlepin:23443/candlepin :oauth_key: "katello" :oauth_secret: "{{ candlepin_oauth_secret }}" :ca_cert_file: /etc/foreman/katello-default-ca.crt diff --git a/src/roles/foreman/templates/settings.yaml.j2 b/src/roles/foreman/templates/settings.yaml.j2 index cfbaf3a9d..591f404ff 100644 --- a/src/roles/foreman/templates/settings.yaml.j2 +++ b/src/roles/foreman/templates/settings.yaml.j2 @@ -17,7 +17,7 @@ :rails_cache_store: :type: redis :urls: - - redis://localhost:6379/4 + - {{ foreman_rails_cache_url }} :options: :compress: true :namespace: foreman diff --git a/src/roles/postgresql/defaults/main.yml b/src/roles/postgresql/defaults/main.yml index 8c1b6bb49..f495db666 100644 --- a/src/roles/postgresql/defaults/main.yml +++ b/src/roles/postgresql/defaults/main.yml @@ -2,8 +2,9 @@ postgresql_container_image: quay.io/sclorg/postgresql-16-c10s postgresql_container_tag: "latest" postgresql_container_name: postgresql -postgresql_network: host +postgresql_network: foreman-db postgresql_restart_policy: always +postgresql_socket_dir: /var/run/postgresql postgresql_data_dir: /var/lib/pgsql/data diff --git a/src/roles/postgresql/tasks/main.yml b/src/roles/postgresql/tasks/main.yml index 55d7c4d71..f5d919d1c 100644 --- a/src/roles/postgresql/tasks/main.yml +++ b/src/roles/postgresql/tasks/main.yml @@ -10,6 +10,22 @@ owner: 26 group: 26 +- name: Ensure PostgreSQL socket directory exists at boot + ansible.builtin.copy: + dest: /usr/lib/tmpfiles.d/foremanctl-postgresql.conf + content: "d {{ postgresql_socket_dir }} 0755 26 26 -\n" + mode: "0644" + owner: root + group: root + +- name: Create PostgreSQL socket directory + ansible.builtin.file: + path: "{{ postgresql_socket_dir }}" + state: directory + mode: "0755" + owner: "26" + group: "26" + - name: Create Podman secret for PostgreSQL admin password containers.podman.podman_secret: name: postgresql-admin-password @@ -27,9 +43,10 @@ state: quadlet healthcheck: pg_isready sdnotify: healthy - network: host + network: "{{ postgresql_network }}" volumes: - "{{ postgresql_data_dir }}:/var/lib/pgsql/data:rw,Z" + - "{{ postgresql_socket_dir }}:{{ postgresql_socket_dir }}:rw,Z" secrets: - 'postgresql-admin-password,target=POSTGRESQL_ADMIN_PASSWORD,type=env' env: @@ -115,8 +132,7 @@ name: "{{ item.name }}" password: "{{ item.password }}" login_user: postgres - login_password: "{{ postgresql_admin_password }}" - login_host: localhost + login_unix_socket: "{{ postgresql_socket_dir }}" role_attr_flags: "{{ item.role_attr_flags | default(omit) }}" state: present loop: "{{ postgresql_users }}" @@ -127,7 +143,6 @@ name: "{{ item.name }}" owner: "{{ item.owner }}" login_user: postgres - login_password: "{{ postgresql_admin_password }}" - login_host: localhost + login_unix_socket: "{{ postgresql_socket_dir }}" state: present loop: "{{ postgresql_databases }}" diff --git a/src/roles/pulp/defaults/main.yaml b/src/roles/pulp/defaults/main.yaml index 08d7f3821..b47fea37f 100644 --- a/src/roles/pulp/defaults/main.yaml +++ b/src/roles/pulp/defaults/main.yaml @@ -46,7 +46,14 @@ pulp_enabled_plugins: "{{ pulp_default_plugins + pulp_plugins }}" pulp_database_name: pulp pulp_database_user: pulp -pulp_database_host: localhost +pulp_database_host: postgresql +pulp_networks: >- + {{ + ((database_mode == 'internal') | ternary(['foreman-db'], [])) + + ['foreman-cache', 'foreman-app'] + }} +pulp_migration_networks: "{{ ['foreman-db'] if database_mode == 'internal' else ['foreman-app'] }}" +pulp_redis_url: "redis://valkey:6379/8" pulp_database_port: 5432 pulp_database_ssl_mode: disabled pulp_database_ssl_ca: # noqa: no-empty-defaults @@ -73,7 +80,7 @@ pulp_settings_other_env: PULP_ANSIBLE_API_HOSTNAME: "{{ pulp_content_origin }}" PULP_ANSIBLE_CONTENT_HOSTNAME: "{{ pulp_content_origin }}/pulp/content" PULP_ANSIBLE_PERMISSION_CLASSES: "[]" - PULP_REDIS_URL: "redis://localhost:6379/8" + PULP_REDIS_URL: "{{ pulp_redis_url }}" PULP_REMOTE_USER_ENVIRON_NAME: "HTTP_REMOTE_USER" PULP_REST_FRAMEWORK__DEFAULT_AUTHENTICATION_CLASSES: >- ['rest_framework.authentication.SessionAuthentication', 'pulpcore.app.authentication.PulpRemoteUserAuthentication'] diff --git a/src/roles/pulp/tasks/main.yaml b/src/roles/pulp/tasks/main.yaml index 0e4a68306..bc42d284b 100644 --- a/src/roles/pulp/tasks/main.yaml +++ b/src/roles/pulp/tasks/main.yaml @@ -106,7 +106,7 @@ state: quadlet sdnotify: true command: pulp-api - network: host + network: "{{ pulp_networks }}" hostname: "pulp-api.{{ ansible_facts['hostname'] }}.local" volumes: "{{ pulp_volumes }}" security_opt: @@ -146,7 +146,7 @@ state: quadlet sdnotify: true command: pulp-content - network: host + network: "{{ pulp_networks }}" hostname: "pulp-content.{{ ansible_facts['hostname'] }}.local" volumes: "{{ pulp_volumes }}" security_opt: @@ -179,7 +179,7 @@ image: pulp.image state: quadlet command: pulp-worker - network: host + network: "{{ pulp_networks }}" hostname: "pulp-worker-%i.{{ ansible_facts['hostname'] }}.local" volumes: "{{ pulp_volumes }}" security_opt: @@ -230,7 +230,7 @@ image: pulp.image sdnotify: false command: pulpcore-manager migrate --noinput - network: host + network: "{{ pulp_migration_networks }}" volumes: "{{ pulp_volumes }}" secrets: - 'pulp-symmetric-key,type=mount,target=/etc/pulp/certs/database_fields.symmetric.key' @@ -251,7 +251,7 @@ image: pulp.image sdnotify: false command: pulpcore-manager reset-admin-password --random - network: host + network: "{{ pulp_migration_networks }}" volumes: "{{ pulp_volumes }}" secrets: - 'pulp-symmetric-key,type=mount,target=/etc/pulp/certs/database_fields.symmetric.key' diff --git a/src/roles/valkey/defaults/main.yml b/src/roles/valkey/defaults/main.yml index 257e3e412..d3f6f5a29 100644 --- a/src/roles/valkey/defaults/main.yml +++ b/src/roles/valkey/defaults/main.yml @@ -1,3 +1,4 @@ --- valkey_container_image: quay.io/sclorg/valkey-8-c10s valkey_container_tag: "latest" +valkey_network: foreman-cache diff --git a/src/roles/valkey/tasks/main.yaml b/src/roles/valkey/tasks/main.yaml index 8e054c065..0fdeb6adc 100644 --- a/src/roles/valkey/tasks/main.yaml +++ b/src/roles/valkey/tasks/main.yaml @@ -18,9 +18,9 @@ name: valkey image: valkey.image state: quadlet - network: host + network: "{{ valkey_network }}" sdnotify: true - command: ["run-valkey", "--supervised", "systemd", "--loglevel", "{{ valkey_log_level }}", "--bind", "127.0.0.1", "-::1"] + command: ["run-valkey", "--supervised", "systemd", "--loglevel", "{{ valkey_log_level }}"] volumes: - /var/lib/valkey:/data:rw,Z quadlet_options: diff --git a/src/vars/database.yml b/src/vars/database.yml index 7efcfb7d8..e2ec8f952 100644 --- a/src/vars/database.yml +++ b/src/vars/database.yml @@ -1,5 +1,5 @@ --- -database_host: localhost +database_host: postgresql database_port: 5432 database_ssl_mode: disable database_ssl_ca: diff --git a/tests/unit/backup_role_test.py b/tests/unit/backup_role_test.py new file mode 100644 index 000000000..6c7cb96b3 --- /dev/null +++ b/tests/unit/backup_role_test.py @@ -0,0 +1,46 @@ +import os + +import yaml + +TEST_DIR = os.path.dirname(os.path.abspath(__file__)) +REPO_DIR = os.path.abspath(os.path.join(TEST_DIR, '..', '..')) +BACKUP_MAIN = os.path.join(REPO_DIR, 'src', 'roles', 'backup', 'tasks', 'main.yaml') + + +def _load_task(task_name): + with open(BACKUP_MAIN, 'r') as task_file: + tasks = yaml.safe_load(task_file) + + def iter_tasks(task_list): + for task in task_list: + yield task + if "block" in task: + yield from iter_tasks(task["block"]) + + return next(task for task in iter_tasks(tasks) if task.get('name') == task_name) + + +def test_backup_selects_socket_host_for_internal_database(): + task = _load_task("Select database access host for backup") + + actual = " ".join(task["ansible.builtin.set_fact"]["backup_database_host"].split()) + expected = ( + "{{ (backup_database_mode == 'internal') | ternary((postgresql_socket_dir | " + "default('/var/run/postgresql')), database_host) }}" + ) + + assert actual == expected + + +def test_backup_readiness_uses_selected_database_host(): + task = _load_task("Wait for PostgreSQL readiness") + + assert task["ansible.builtin.command"]["cmd"] == ( + "pg_isready -h {{ backup_database_host }} -p {{ database_port }}" + ) + + +def test_backup_database_dump_config_uses_selected_database_host(): + task = _load_task("Build database backup configuration") + + assert task["vars"]["db_entry"]["host"] == "{{ backup_database_host }}" From ebf7611fa4c0a6aa458c108ffdd5835976e83338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20M=C3=A9ndez=20Hern=C3=A1ndez?= Date: Wed, 12 Aug 2026 01:01:41 +0200 Subject: [PATCH 03/10] Add wait_for_smart_proxy readiness role; move foreman-proxy to its own bridge network foreman-proxy joins a new foreman-proxy bridge network (still also published on host port 8443 via foreman_proxy_ports) rather than running on host networking, while keeping its public identity (foreman_proxy_name/foreman_proxy_url) unchanged: it still registers with Foreman under its own real FQDN, so this is purely a network plumbing change for how Foreman reaches the proxy container, not an identity change. Podman's aardvark-dns needs a moment to reconverge after the proxy container (re)starts before Foreman can resolve/reach it again on the bridge, so a new shared wait_for_smart_proxy role polls the proxy's /v2/features endpoint (from inside the foreman container, using its client cert) with retries before anything tries to register or use the proxy. foreman_proxy's own tasks now restart the container as a plain task (not a handler notify+flush) immediately before this readiness check and the initial registration, since flushing handlers here would also prematurely fire the paired "Refresh Foreman Proxy" handler before registration has happened. The same readiness check runs again after the role's normal end-of-role handler flush, since that flush restarts the container again on effectively every deploy run. The backup role reuses the same shared wait_for_reachable.yaml task after restarting foreman.target, for the same reconvergence reason. Co-authored-by: Cursor --- src/playbooks/deploy/deploy.yaml | 9 ++ src/roles/backup/tasks/main.yaml | 3 + src/roles/foreman_proxy/defaults/main.yaml | 3 + src/roles/foreman_proxy/tasks/main.yaml | 39 ++++- .../tasks/wait_for_reachable.yaml | 19 +++ .../wait_for_smart_proxy/defaults/main.yaml | 3 + .../wait_for_smart_proxy/tasks/main.yaml | 21 +++ tests/unit/foreman_proxy_role_test.py | 143 ++++++++++++++++++ tests/unit/wait_for_smart_proxy_role_test.py | 58 +++++++ 9 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 src/roles/foreman_proxy/tasks/wait_for_reachable.yaml create mode 100644 src/roles/wait_for_smart_proxy/defaults/main.yaml create mode 100644 src/roles/wait_for_smart_proxy/tasks/main.yaml create mode 100644 tests/unit/foreman_proxy_role_test.py create mode 100644 tests/unit/wait_for_smart_proxy_role_test.py diff --git a/src/playbooks/deploy/deploy.yaml b/src/playbooks/deploy/deploy.yaml index e75a4046e..8ad2346e8 100644 --- a/src/playbooks/deploy/deploy.yaml +++ b/src/playbooks/deploy/deploy.yaml @@ -39,6 +39,11 @@ - role: deploy_network vars: deploy_network_name: foreman-app + - role: deploy_network + vars: + deploy_network_name: foreman-proxy + when: + - "enabled_features | has_feature('foreman-proxy')" - role: postgresql when: - database_mode == 'internal' @@ -53,6 +58,10 @@ - "enabled_features | has_feature('iop')" - database_mode == 'internal' - role: foreman_proxy + vars: + foreman_proxy_network: foreman-proxy + foreman_proxy_ports: + - "0.0.0.0:8443:8443" when: - "enabled_features | has_feature('foreman-proxy')" - role: hammer diff --git a/src/roles/backup/tasks/main.yaml b/src/roles/backup/tasks/main.yaml index 9b0263fbf..1b9207c37 100644 --- a/src/roles/backup/tasks/main.yaml +++ b/src/roles/backup/tasks/main.yaml @@ -136,6 +136,9 @@ name: foreman.target state: started + - name: Wait for Foreman Proxy API to be reachable from Foreman + ansible.builtin.include_tasks: ../../foreman_proxy/tasks/wait_for_reachable.yaml + - name: Display backup completion ansible.builtin.debug: msg: | diff --git a/src/roles/foreman_proxy/defaults/main.yaml b/src/roles/foreman_proxy/defaults/main.yaml index cd4200fd9..350e63f0a 100644 --- a/src/roles/foreman_proxy/defaults/main.yaml +++ b/src/roles/foreman_proxy/defaults/main.yaml @@ -6,6 +6,9 @@ foreman_proxy_name: "{{ ansible_facts['fqdn'] }}" foreman_proxy_https_port: 8443 foreman_proxy_url: "https://{{ foreman_proxy_name }}:{{ foreman_proxy_https_port }}" +foreman_proxy_network: host +foreman_proxy_ports: [] + # Settings foreman_proxy_trusted_hosts: [] diff --git a/src/roles/foreman_proxy/tasks/main.yaml b/src/roles/foreman_proxy/tasks/main.yaml index 8033c9457..295b6f68e 100644 --- a/src/roles/foreman_proxy/tasks/main.yaml +++ b/src/roles/foreman_proxy/tasks/main.yaml @@ -14,8 +14,11 @@ image: foreman-proxy.image state: quadlet sdnotify: true - network: host + network: "{{ foreman_proxy_network }}" + ports: "{{ foreman_proxy_ports if foreman_proxy_ports | length > 0 else omit }}" hostname: "{{ ansible_facts['hostname'] }}.local" + volume: + - "/etc/hosts:/etc/hosts:ro" secrets: - 'foreman-proxy-settings-yml,type=mount,target=/etc/foreman-proxy/settings.yml' - 'foreman-proxy-ssl-ca,type=mount,target=/etc/foreman-proxy/ssl_ca.pem' @@ -66,6 +69,28 @@ state: started register: _foreman_proxy_service +- name: Restart Foreman Proxy ahead of registration + ansible.builtin.systemd: + name: foreman-proxy + state: "{{ (_foreman_proxy_service is changed) | ternary('started', 'restarted') }}" + # Deliberately NOT done via `notify: Restart Foreman Proxy` + `meta: flush_handlers` + # here: this role's feature tasks notify "Restart Foreman Proxy" and + # "Refresh Foreman Proxy" together (matching master), and flush_handlers has no + # way to flush one without the other. "Refresh Foreman Proxy" calls + # smart_proxy_refresh, which requires the proxy to already be registered with + # Foreman - it must not run before "Register Foreman Proxy to Foreman" below. + # We still need the container running with up-to-date config *before* the + # readiness check and registration, because - unlike master's host-networking + # setup - this deployment uses a bridge network, so Foreman can only reach the + # proxy (and validate it during registration) once the container has actually + # picked up its current secrets/config. Doing this restart as a plain task + # sidesteps the handler pair entirely, at the cost of a harmless extra restart + # when "Restart Foreman Proxy" also fires at the normal end-of-role handler + # flush below. + +- name: Wait for Foreman Proxy API to be reachable from Foreman + ansible.builtin.include_tasks: wait_for_reachable.yaml + - name: Register Foreman Proxy to Foreman theforeman.foreman.smart_proxy: name: "{{ foreman_proxy_name }}" @@ -77,3 +102,15 @@ - name: Flush handlers to restart services ansible.builtin.meta: flush_handlers + # This flush restarts the foreman-proxy container again whenever any + # earlier task (certs/configs/features) notified "Restart Foreman Proxy" - + # which happens on effectively every deploy run, not just the first one + # (see the comment above "Restart Foreman Proxy ahead of registration"). + # On the bridge-network setup, that means aardvark-dns needs another + # moment to reconverge before Foreman can reach the proxy again, so the + # readiness check below must be repeated here too - otherwise callers + # that use the proxy immediately after this role returns could hit it + # before DNS has reconverged. + +- name: Wait for Foreman Proxy API to be reachable from Foreman + ansible.builtin.include_tasks: wait_for_reachable.yaml diff --git a/src/roles/foreman_proxy/tasks/wait_for_reachable.yaml b/src/roles/foreman_proxy/tasks/wait_for_reachable.yaml new file mode 100644 index 000000000..15a78ddca --- /dev/null +++ b/src/roles/foreman_proxy/tasks/wait_for_reachable.yaml @@ -0,0 +1,19 @@ +--- +# Shared by foreman_proxy/tasks/main.yaml (both the initial deploy-time proxy +# restart ahead of registration, and the end-of-role handler flush that can +# restart the proxy again on every deploy run) and the backup role +# (post-backup restart of foreman.target). +# Self-contained (including the `when`) so callers don't need to duplicate +# the feature gating: only meaningful when both Foreman and its proxy are +# part of the deployment being acted on. +- name: Wait for Foreman Proxy API to be reachable from Foreman + ansible.builtin.include_role: + name: wait_for_smart_proxy + vars: + # Spelled out with ansible_facts['fqdn'] rather than foreman_proxy_url: + # this task is also included from the backup role's play, which doesn't + # load the foreman_proxy role's defaults. + wait_for_smart_proxy_url: "https://{{ ansible_facts['fqdn'] }}:8443" + when: + - "enabled_features | has_feature('foreman')" + - "enabled_features | has_feature('foreman-proxy')" diff --git a/src/roles/wait_for_smart_proxy/defaults/main.yaml b/src/roles/wait_for_smart_proxy/defaults/main.yaml new file mode 100644 index 000000000..34a3956a1 --- /dev/null +++ b/src/roles/wait_for_smart_proxy/defaults/main.yaml @@ -0,0 +1,3 @@ +--- +wait_for_smart_proxy_retries: 30 +wait_for_smart_proxy_delay: 5 diff --git a/src/roles/wait_for_smart_proxy/tasks/main.yaml b/src/roles/wait_for_smart_proxy/tasks/main.yaml new file mode 100644 index 000000000..58ea1e866 --- /dev/null +++ b/src/roles/wait_for_smart_proxy/tasks/main.yaml @@ -0,0 +1,21 @@ +--- +# Shared by foreman_proxy (directly, and via the backup role) and iop_core: +# both run their smart proxy on a Podman bridge network, where Netavark/ +# aardvark-dns needs a moment to reconverge after the container (re)starts +# before Foreman can resolve/reach it again. Callers only need to supply +# wait_for_smart_proxy_url (the proxy's own /v2/features base URL). +- name: Wait for smart proxy API to be reachable from Foreman + ansible.builtin.command: + cmd: >- + podman exec foreman curl + --silent --show-error --fail + --connect-timeout 5 --max-time 10 + --cacert /etc/foreman/katello-default-ca.crt + --cert /etc/foreman/client_cert.pem + --key /etc/foreman/client_key.pem + {{ wait_for_smart_proxy_url }}/v2/features + register: _wait_for_smart_proxy_result + changed_when: false + retries: "{{ wait_for_smart_proxy_retries }}" + delay: "{{ wait_for_smart_proxy_delay }}" + until: _wait_for_smart_proxy_result.rc == 0 diff --git a/tests/unit/foreman_proxy_role_test.py b/tests/unit/foreman_proxy_role_test.py new file mode 100644 index 000000000..081ac0937 --- /dev/null +++ b/tests/unit/foreman_proxy_role_test.py @@ -0,0 +1,143 @@ +import os + +import yaml + +TEST_DIR = os.path.dirname(os.path.abspath(__file__)) +SRC_DIR = os.path.abspath(os.path.join(TEST_DIR, '..', '..', 'src')) +ROLE_TASKS = os.path.join(SRC_DIR, 'roles', 'foreman_proxy', 'tasks', 'main.yaml') +WAIT_FOR_REACHABLE_TASKS = os.path.join(SRC_DIR, 'roles', 'foreman_proxy', 'tasks', 'wait_for_reachable.yaml') +BACKUP_ROLE_TASKS = os.path.join(SRC_DIR, 'roles', 'backup', 'tasks', 'main.yaml') + + +def _load_tasks(): + with open(ROLE_TASKS, 'r') as task_file: + return yaml.safe_load(task_file) + + +def _load_wait_for_reachable_tasks(): + with open(WAIT_FOR_REACHABLE_TASKS, 'r') as task_file: + return yaml.safe_load(task_file) + + +def _load_backup_tasks(): + with open(BACKUP_ROLE_TASKS, 'r') as task_file: + return yaml.safe_load(task_file) + + +def _load_task(task_name): + return next(task for task in _load_tasks() if task.get('name') == task_name) + + +def test_deploy_time_readiness_check_delegates_to_shared_wait_task(): + # The actual command/retry logic lives in the shared wait_for_reachable.yaml + # task file (also used by the backup role, see + # test_backup_reuses_shared_wait_for_reachable_task below) so both callers + # stay in lockstep instead of drifting apart over time. + task = _load_task("Wait for Foreman Proxy API to be reachable from Foreman") + + assert task["ansible.builtin.include_tasks"] == "wait_for_reachable.yaml" + + +def test_wait_for_proxy_probes_own_fqdn(): + task = _load_wait_for_reachable_tasks()[0] + + assert task["name"] == "Wait for Foreman Proxy API to be reachable from Foreman" + assert task["ansible.builtin.include_role"]["name"] == "wait_for_smart_proxy" + + task_vars = task["vars"] + # Spelled out with ansible_facts['fqdn'] (matching foreman_proxy_name's own + # default) rather than foreman_proxy_url, since this task is also included + # from the backup role's play, which doesn't load the foreman_proxy role's + # defaults. foreman_proxy_registration_url plays no part here: this probe + # only needs to know the proxy's own real endpoint, not whatever alternate + # URL it may tell hosts to register through. + assert task_vars["wait_for_smart_proxy_url"] == "https://{{ ansible_facts['fqdn'] }}:8443" + + when_condition = task["when"] if isinstance(task["when"], list) else [task["when"]] + assert "enabled_features | has_feature('foreman')" in when_condition + assert "enabled_features | has_feature('foreman-proxy')" in when_condition + + +def test_backup_reuses_shared_wait_for_reachable_task(): + # `foremanctl backup` restarts the whole foreman.target (including + # foreman-proxy) under the bridge-network refactor, so it needs the same + # Netavark/aardvark-dns reconvergence tolerance the deploy path already + # has. Reusing the shared task file (rather than duplicating the curl + # command/retry parameters) keeps both call sites from drifting. + tasks = _load_backup_tasks() + + def iter_tasks(task_list): + for task in task_list: + yield task + if "block" in task: + yield from iter_tasks(task["block"]) + + backup_tasks = list(iter_tasks(tasks)) + names = [task.get('name') for task in backup_tasks] + + assert 'Wait for Foreman Proxy API to be reachable from Foreman' in names + wait_task = next( + task for task in backup_tasks + if task.get('name') == 'Wait for Foreman Proxy API to be reachable from Foreman' + ) + included_path = wait_task["ansible.builtin.include_tasks"] + resolved_path = os.path.normpath( + os.path.join(SRC_DIR, 'roles', 'backup', 'tasks', included_path) + ) + assert resolved_path == WAIT_FOR_REACHABLE_TASKS + + start_index = names.index('Start Foreman services') + wait_index = names.index('Wait for Foreman Proxy API to be reachable from Foreman') + assert wait_index == start_index + 1, ( + "the proxy-reachability wait must run right after foreman.target is " + "restarted, before the backup is reported complete" + ) + + +def test_early_restart_before_readiness_check_is_a_plain_task_not_a_handler_flush(): + # This branch restarts the proxy container before the Foreman-reachability + # readiness check (needed for bridge networking, unlike master's host + # networking) via a plain imperative task rather than a notify/flush, so + # that doing so can never also flush (and thus prematurely fire) the paired + # "Refresh Foreman Proxy" handler notified from the same feature tasks. + tasks = _load_tasks() + names = [task.get('name') for task in tasks] + + assert 'Wait for Foreman Proxy API to be reachable from Foreman' in names + readiness_index = names.index('Wait for Foreman Proxy API to be reachable from Foreman') + + early_restart = tasks[readiness_index - 1] + assert early_restart.get('name') == 'Restart Foreman Proxy ahead of registration' + assert 'notify' not in early_restart + assert early_restart['ansible.builtin.systemd']['name'] == 'foreman-proxy' + + # No handler flush must occur before registration. + register_index = names.index('Register Foreman Proxy to Foreman') + for task in tasks[:register_index]: + assert 'ansible.builtin.meta' not in task, ( + f"{task.get('name')!r} flushes handlers before registration" + ) + + +def test_end_of_role_handler_flush_reuses_shared_wait_for_reachable_task(): + # The role's final `flush_handlers` restarts foreman-proxy again (any + # certs/configs/feature task notifying "Restart Foreman Proxy" earlier in + # the role - which happens on effectively every deploy run) after the + # deploy-time readiness check earlier in this file has already run, so it + # needs its own readiness check immediately afterward before control + # returns to the caller. + tasks = _load_tasks() + names = [task.get('name') for task in tasks] + + flush_indexes = [i for i, task in enumerate(tasks) if 'ansible.builtin.meta' in task] + assert len(flush_indexes) == 1, "expected exactly one handler flush in the role" + flush_index = flush_indexes[0] + + assert names.count('Wait for Foreman Proxy API to be reachable from Foreman') == 2, ( + "expected the shared readiness check both before registration and " + "after the end-of-role handler flush" + ) + + post_flush_wait = tasks[flush_index + 1] + assert post_flush_wait.get('name') == 'Wait for Foreman Proxy API to be reachable from Foreman' + assert post_flush_wait['ansible.builtin.include_tasks'] == 'wait_for_reachable.yaml' diff --git a/tests/unit/wait_for_smart_proxy_role_test.py b/tests/unit/wait_for_smart_proxy_role_test.py new file mode 100644 index 000000000..f0bde8e32 --- /dev/null +++ b/tests/unit/wait_for_smart_proxy_role_test.py @@ -0,0 +1,58 @@ +import os + +import yaml + +TEST_DIR = os.path.dirname(os.path.abspath(__file__)) +SRC_DIR = os.path.abspath(os.path.join(TEST_DIR, '..', '..', 'src')) +ROLE_DEFAULTS = os.path.join(SRC_DIR, 'roles', 'wait_for_smart_proxy', 'defaults', 'main.yaml') +ROLE_TASKS = os.path.join(SRC_DIR, 'roles', 'wait_for_smart_proxy', 'tasks', 'main.yaml') +IOP_CORE_TASKS = os.path.join(SRC_DIR, 'roles', 'iop_core', 'tasks', 'main.yaml') + + +def _load_defaults(): + with open(ROLE_DEFAULTS, 'r') as defaults_file: + return yaml.safe_load(defaults_file) + + +def _load_task(): + with open(ROLE_TASKS, 'r') as task_file: + tasks = yaml.safe_load(task_file) + assert len(tasks) == 1 + return tasks[0] + + +def _load_iop_core_task(task_name): + with open(IOP_CORE_TASKS, 'r') as task_file: + tasks = yaml.safe_load(task_file) + return next(task for task in tasks if task.get('name') == task_name) + + +def test_defaults_provide_retry_schedule(): + defaults = _load_defaults() + + assert defaults["wait_for_smart_proxy_retries"] == 30 + assert defaults["wait_for_smart_proxy_delay"] == 5 + + +def test_task_probes_caller_supplied_url_via_foreman(): + task = _load_task() + + assert task["name"] == "Wait for smart proxy API to be reachable from Foreman" + assert "{{ wait_for_smart_proxy_url }}/v2/features" in task["ansible.builtin.command"]["cmd"] + assert "podman exec foreman curl" in task["ansible.builtin.command"]["cmd"] + + assert task["changed_when"] is False + assert task["retries"] == "{{ wait_for_smart_proxy_retries }}" + assert task["delay"] == "{{ wait_for_smart_proxy_delay }}" + assert task["until"] == "_wait_for_smart_proxy_result.rc == 0" + assert task["register"] == "_wait_for_smart_proxy_result" + + +def test_iop_gateway_delegates_to_shared_wait_role(): + # iop_core's own smart proxy (iop-gateway) needs the same Netavark/ + # aardvark-dns reconvergence tolerance as foreman-proxy, so it reuses the + # same shared role instead of duplicating the curl/retry logic. + task = _load_iop_core_task("Wait for IOP Gateway smart proxy API to be reachable from Foreman") + + assert task["ansible.builtin.include_role"]["name"] == "wait_for_smart_proxy" + assert task["vars"]["wait_for_smart_proxy_url"] == "{{ iop_core_gateway_registration_url }}" From 1f7d29916bfe482fdb511db0e1f053a5e8b7e401 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20M=C3=A9ndez=20Hern=C3=A1ndez?= Date: Wed, 12 Aug 2026 01:05:23 +0200 Subject: [PATCH 04/10] Move IoP onto bridge networks and dual-home the gateway The IoP core network (iop-core-network) is now created through deploy_network instead of calling containers.podman.podman_network directly, so it picks up the same Netavark 2+ isolation-default compatibility handling as the other bridge networks: it must not be isolated from foreman-app/foreman-proxy, which it needs to reach. The gateway container is dual-homed onto both iop-core-network and foreman-app (still also published on host loopback at 24443), and registers with Foreman using its container DNS name/port (iop_core_gateway_registration_url) instead of localhost:24443, waiting for that endpoint to become reachable from Foreman first (mirroring foreman_proxy's own bridge-network readiness handling). DB-consuming IoP services (advisor, inventory, remediation, vmaas, vulnerability) join foreman-db alongside iop-core-network when the database is internal, via a new iop_database_networks variable, and iop_database_host switches from host.containers.internal to the postgresql container DNS name in that mode. iop_fdw's and iop_inventory's direct PostgreSQL admin queries (FDW server/user-mapping setup, inventory schema/view creation) move from password+host login to the same postgresql_socket_dir Unix socket the other administrative roles now use, since containers sharing that mount can reach it without depending on network reachability to the database container. Co-authored-by: Cursor --- src/roles/iop_advisor/tasks/main.yaml | 6 ++--- src/roles/iop_core/defaults/main.yaml | 3 +++ src/roles/iop_core/tasks/main.yaml | 8 +++++- src/roles/iop_fdw/defaults/main.yaml | 1 + src/roles/iop_fdw/tasks/main.yaml | 30 ++++++++++----------- src/roles/iop_gateway/defaults/main.yaml | 9 +++++++ src/roles/iop_gateway/handlers/main.yaml | 2 +- src/roles/iop_gateway/tasks/main.yaml | 10 +++---- src/roles/iop_inventory/tasks/main.yaml | 21 +++++---------- src/roles/iop_network/tasks/main.yaml | 19 ++++++++----- src/roles/iop_remediation/tasks/main.yaml | 3 +-- src/roles/iop_vmaas/defaults/main.yaml | 1 + src/roles/iop_vmaas/tasks/main.yaml | 10 +++---- src/roles/iop_vulnerability/tasks/main.yaml | 16 +++++------ src/vars/database.yml | 4 ++- tests/feature/iop/test_integration.py | 29 ++++++++++++++++++++ 16 files changed, 110 insertions(+), 62 deletions(-) diff --git a/src/roles/iop_advisor/tasks/main.yaml b/src/roles/iop_advisor/tasks/main.yaml index e3579cd51..e41844547 100644 --- a/src/roles/iop_advisor/tasks/main.yaml +++ b/src/roles/iop_advisor/tasks/main.yaml @@ -38,8 +38,7 @@ image: iop-advisor.image state: quadlet command: sh -c "./container_init.sh && api/app.sh" - network: - - iop-core-network + network: "{{ iop_database_networks }}" env: DJANGO_SESSION_KEY: "UNUSED" BOOTSTRAP_SERVERS: "iop-core-kafka:9092" @@ -82,8 +81,7 @@ image: iop-advisor.image state: quadlet command: pipenv run python service/service.py - network: - - iop-core-network + network: "{{ iop_database_networks }}" env: BOOTSTRAP_SERVERS: "iop-core-kafka:9092" ADVISOR_DB_SSL_MODE: "disable" diff --git a/src/roles/iop_core/defaults/main.yaml b/src/roles/iop_core/defaults/main.yaml index 0fec3bb3a..71bedaa92 100644 --- a/src/roles/iop_core/defaults/main.yaml +++ b/src/roles/iop_core/defaults/main.yaml @@ -1,2 +1,5 @@ --- iop_core_foreman_url: "https://{{ ansible_facts['fqdn'] }}" +iop_core_gateway_registration_host: "iop-core-gateway" +iop_core_gateway_registration_port: 8443 +iop_core_gateway_registration_url: "https://{{ iop_core_gateway_registration_host }}:{{ iop_core_gateway_registration_port }}" diff --git a/src/roles/iop_core/tasks/main.yaml b/src/roles/iop_core/tasks/main.yaml index 7bb48d4e0..25a1492e2 100644 --- a/src/roles/iop_core/tasks/main.yaml +++ b/src/roles/iop_core/tasks/main.yaml @@ -27,10 +27,16 @@ ansible.builtin.include_role: name: iop_gateway +- name: Wait for IOP Gateway smart proxy API to be reachable from Foreman + ansible.builtin.include_role: + name: wait_for_smart_proxy + vars: + wait_for_smart_proxy_url: "{{ iop_core_gateway_registration_url }}" + - name: Register IOP Gateway as smart proxy theforeman.foreman.smart_proxy: name: "iop-gateway" - url: "https://localhost:24443" + url: "{{ iop_core_gateway_registration_url }}" server_url: "{{ iop_core_foreman_url }}" oauth1_consumer_key: "{{ iop_core_foreman_oauth_consumer_key }}" oauth1_consumer_secret: "{{ iop_core_foreman_oauth_consumer_secret }}" diff --git a/src/roles/iop_fdw/defaults/main.yaml b/src/roles/iop_fdw/defaults/main.yaml index 1a69ee91d..df39ce11c 100644 --- a/src/roles/iop_fdw/defaults/main.yaml +++ b/src/roles/iop_fdw/defaults/main.yaml @@ -9,6 +9,7 @@ iop_fdw_remote_password: "{{ undef(hint='You must specify the remote FDW databas # Optional parameters - can use defaults iop_fdw_database_host: "localhost" iop_fdw_database_port: 5432 +iop_fdw_login_unix_socket: "{{ postgresql_socket_dir }}" # Constants - same for all invocations (matching puppet-iop) iop_fdw_foreign_server_name: hbi_server diff --git a/src/roles/iop_fdw/tasks/main.yaml b/src/roles/iop_fdw/tasks/main.yaml index f81358c5f..e51ced209 100644 --- a/src/roles/iop_fdw/tasks/main.yaml +++ b/src/roles/iop_fdw/tasks/main.yaml @@ -9,13 +9,13 @@ name: postgres_fdw login_db: "{{ iop_fdw_database_name }}" login_user: postgres - login_host: "{{ iop_fdw_database_host }}" + login_unix_socket: "{{ iop_fdw_login_unix_socket }}" - name: Check if foreign server exists community.postgresql.postgresql_query: login_db: "{{ iop_fdw_database_name }}" login_user: postgres - login_host: "{{ iop_fdw_database_host }}" + login_unix_socket: "{{ iop_fdw_login_unix_socket }}" query: "SELECT srvname FROM pg_foreign_server WHERE srvname = %s" positional_args: - "{{ iop_fdw_foreign_server_name }}" @@ -26,7 +26,7 @@ community.postgresql.postgresql_query: login_db: "{{ iop_fdw_database_name }}" login_user: postgres - login_host: "{{ iop_fdw_database_host }}" + login_unix_socket: "{{ iop_fdw_login_unix_socket }}" query: | CREATE SERVER {{ iop_fdw_foreign_server_name }} FOREIGN DATA WRAPPER postgres_fdw @@ -41,7 +41,7 @@ community.postgresql.postgresql_query: login_db: "{{ iop_fdw_database_name }}" login_user: postgres - login_host: "{{ iop_fdw_database_host }}" + login_unix_socket: "{{ iop_fdw_login_unix_socket }}" query: "SELECT umuser FROM pg_user_mappings WHERE srvname = %s AND usename = %s" positional_args: - "{{ iop_fdw_foreign_server_name }}" @@ -53,7 +53,7 @@ community.postgresql.postgresql_query: login_db: "{{ iop_fdw_database_name }}" login_user: postgres - login_host: "{{ iop_fdw_database_host }}" + login_unix_socket: "{{ iop_fdw_login_unix_socket }}" query: | CREATE USER MAPPING FOR {{ iop_fdw_database_user }} SERVER {{ iop_fdw_foreign_server_name }} @@ -67,7 +67,7 @@ community.postgresql.postgresql_query: login_db: "{{ iop_fdw_database_name }}" login_user: postgres - login_host: "{{ iop_fdw_database_host }}" + login_unix_socket: "{{ iop_fdw_login_unix_socket }}" query: "SELECT umuser FROM pg_user_mappings WHERE srvname = %s AND usename = 'postgres'" positional_args: - "{{ iop_fdw_foreign_server_name }}" @@ -78,7 +78,7 @@ community.postgresql.postgresql_query: login_db: "{{ iop_fdw_database_name }}" login_user: postgres - login_host: "{{ iop_fdw_database_host }}" + login_unix_socket: "{{ iop_fdw_login_unix_socket }}" query: | CREATE USER MAPPING FOR postgres SERVER {{ iop_fdw_foreign_server_name }} @@ -92,7 +92,7 @@ community.postgresql.postgresql_query: login_db: "{{ iop_fdw_database_name }}" login_user: postgres - login_host: "{{ iop_fdw_database_host }}" + login_unix_socket: "{{ iop_fdw_login_unix_socket }}" query: "GRANT USAGE ON FOREIGN SERVER {{ iop_fdw_foreign_server_name }} TO {{ iop_fdw_database_user }}" - name: Create local view schema @@ -101,7 +101,7 @@ name: "{{ iop_fdw_local_view_schema }}" owner: "{{ iop_fdw_database_user }}" login_user: postgres - login_host: "{{ iop_fdw_database_host }}" + login_unix_socket: "{{ iop_fdw_login_unix_socket }}" - name: Create local schema for foreign tables community.postgresql.postgresql_schema: @@ -109,13 +109,13 @@ name: "{{ iop_fdw_local_source_schema }}" owner: "{{ iop_fdw_database_user }}" login_user: postgres - login_host: "{{ iop_fdw_database_host }}" + login_unix_socket: "{{ iop_fdw_login_unix_socket }}" - name: Check if foreign table exists community.postgresql.postgresql_query: login_db: "{{ iop_fdw_database_name }}" login_user: postgres - login_host: "{{ iop_fdw_database_host }}" + login_unix_socket: "{{ iop_fdw_login_unix_socket }}" query: "SELECT foreign_table_name FROM information_schema.foreign_tables WHERE foreign_table_schema = %s AND foreign_table_name = %s" positional_args: - "{{ iop_fdw_local_source_schema }}" @@ -127,7 +127,7 @@ community.postgresql.postgresql_query: login_db: "{{ iop_fdw_database_name }}" login_user: postgres - login_host: "{{ iop_fdw_database_host }}" + login_unix_socket: "{{ iop_fdw_login_unix_socket }}" query: | IMPORT FOREIGN SCHEMA {{ iop_fdw_remote_table_schema }} LIMIT TO ({{ iop_fdw_remote_table_name }}) @@ -139,7 +139,7 @@ community.postgresql.postgresql_query: login_db: "{{ iop_fdw_database_name }}" login_user: postgres - login_host: "{{ iop_fdw_database_host }}" + login_unix_socket: "{{ iop_fdw_login_unix_socket }}" query: | CREATE OR REPLACE VIEW "{{ iop_fdw_local_view_schema }}"."{{ iop_fdw_local_view_name }}" AS SELECT * FROM "{{ iop_fdw_local_source_schema }}"."{{ iop_fdw_remote_table_name }}" @@ -148,7 +148,7 @@ community.postgresql.postgresql_query: login_db: "{{ iop_fdw_database_name }}" login_user: postgres - login_host: "{{ iop_fdw_database_host }}" + login_unix_socket: "{{ iop_fdw_login_unix_socket }}" query: | GRANT USAGE ON SCHEMA {{ iop_fdw_local_source_schema }} TO {{ iop_fdw_database_user }}; GRANT USAGE ON SCHEMA {{ iop_fdw_local_view_schema }} TO {{ iop_fdw_database_user }}; @@ -159,7 +159,7 @@ community.postgresql.postgresql_query: login_db: "{{ iop_fdw_remote_database_name }}" login_user: postgres - login_host: "{{ iop_fdw_database_host }}" + login_unix_socket: "{{ iop_fdw_login_unix_socket }}" query: | GRANT USAGE ON SCHEMA {{ iop_fdw_remote_table_schema }} TO {{ iop_fdw_remote_user }}; GRANT SELECT ON {{ iop_fdw_remote_table_schema }}.{{ iop_fdw_local_view_name }} TO {{ iop_fdw_remote_user }}; diff --git a/src/roles/iop_gateway/defaults/main.yaml b/src/roles/iop_gateway/defaults/main.yaml index 499794194..73d0b40c9 100644 --- a/src/roles/iop_gateway/defaults/main.yaml +++ b/src/roles/iop_gateway/defaults/main.yaml @@ -1,6 +1,15 @@ --- iop_gateway_container_image: "quay.io/iop/gateway" iop_gateway_container_tag: "foreman-3.18" +iop_gateway_service_name: "iop-core-gateway" +iop_gateway_container_port: 8443 +iop_gateway_publish_host: "127.0.0.1" +iop_gateway_publish_port: 24443 +iop_gateway_iop_network: "iop-core-network" +iop_gateway_foreman_network: "foreman-app" +iop_gateway_networks: + - "{{ iop_gateway_iop_network }}" + - "{{ iop_gateway_foreman_network }}" iop_gateway_server_certificate: "/var/lib/foremanctl/certs/certs/localhost.crt" iop_gateway_server_key: "/var/lib/foremanctl/certs/private/localhost.key" diff --git a/src/roles/iop_gateway/handlers/main.yaml b/src/roles/iop_gateway/handlers/main.yaml index 6e0dcb711..0ef5ae8b9 100644 --- a/src/roles/iop_gateway/handlers/main.yaml +++ b/src/roles/iop_gateway/handlers/main.yaml @@ -1,5 +1,5 @@ --- - name: Restart gateway ansible.builtin.systemd: - name: iop-core-gateway + name: "{{ iop_gateway_service_name }}" state: restarted diff --git a/src/roles/iop_gateway/tasks/main.yaml b/src/roles/iop_gateway/tasks/main.yaml index 4d89c0253..48a8a6433 100644 --- a/src/roles/iop_gateway/tasks/main.yaml +++ b/src/roles/iop_gateway/tasks/main.yaml @@ -53,13 +53,12 @@ - name: Deploy Gateway container containers.podman.podman_container: - name: iop-core-gateway + name: "{{ iop_gateway_service_name }}" image: iop-gateway.image state: quadlet - network: - - iop-core-network + network: "{{ iop_gateway_networks | unique }}" publish: - - "127.0.0.1:24443:8443" + - "{{ iop_gateway_publish_host }}:{{ iop_gateway_publish_port }}:{{ iop_gateway_container_port }}" secrets: - 'iop-core-gateway-server-cert,target=/etc/nginx/certs/nginx.crt,mode=0440,uid=998,gid=998,type=mount' - 'iop-core-gateway-server-key,target=/etc/nginx/certs/nginx.key,mode=0440,uid=998,gid=998,type=mount' @@ -80,6 +79,7 @@ [Install] WantedBy=multi-user.target WantedBy=default.target foreman.target + notify: Restart gateway - name: Run daemon reload to make Quadlet create the service files ansible.builtin.systemd: @@ -90,5 +90,5 @@ - name: Start Gateway service ansible.builtin.systemd: - name: iop-core-gateway + name: "{{ iop_gateway_service_name }}" state: started diff --git a/src/roles/iop_inventory/tasks/main.yaml b/src/roles/iop_inventory/tasks/main.yaml index 2a986a75d..f37b939c8 100644 --- a/src/roles/iop_inventory/tasks/main.yaml +++ b/src/roles/iop_inventory/tasks/main.yaml @@ -38,8 +38,7 @@ image: iop-inventory.image state: quadlet command: make upgrade_db - network: - - iop-core-network + network: "{{ iop_database_networks }}" env: KAFKA_BOOTSTRAP_SERVERS: "PLAINTEXT://iop-core-kafka:9092" USE_SUBMAN_ID: "true" @@ -67,8 +66,7 @@ image: iop-inventory.image state: quadlet command: make run_inv_mq_service - network: - - iop-core-network + network: "{{ iop_database_networks }}" env: KAFKA_BOOTSTRAP_SERVERS: "PLAINTEXT://iop-core-kafka:9092" USE_SUBMAN_ID: "true" @@ -98,8 +96,7 @@ image: iop-inventory.image state: quadlet command: python run_gunicorn.py - network: - - iop-core-network + network: "{{ iop_database_networks }}" env: KAFKA_BOOTSTRAP_SERVERS: "iop-core-kafka:9092" LISTEN_PORT: "8081" @@ -129,8 +126,7 @@ image: iop-inventory.image state: quadlet command: make run_host_delete_access_tags - network: - - iop-core-network + network: "{{ iop_database_networks }}" env: KAFKA_BOOTSTRAP_SERVERS: "PLAINTEXT://iop-core-kafka:9092" USE_SUBMAN_ID: "true" @@ -214,8 +210,7 @@ name: postgres_fdw login_db: "{{ iop_inventory_database_name }}" login_user: postgres - login_password: "{{ postgresql_admin_password }}" - login_host: localhost + login_unix_socket: "{{ postgresql_socket_dir }}" - name: Create inventory schema in inventory database community.postgresql.postgresql_schema: @@ -223,15 +218,13 @@ name: inventory owner: "{{ iop_inventory_database_user }}" login_user: postgres - login_password: "{{ postgresql_admin_password }}" - login_host: localhost + login_unix_socket: "{{ postgresql_socket_dir }}" - name: Create inventory.hosts view in inventory database community.postgresql.postgresql_query: login_db: "{{ iop_inventory_database_name }}" login_user: postgres - login_password: "{{ postgresql_admin_password }}" - login_host: localhost + login_unix_socket: "{{ postgresql_socket_dir }}" # TODO(RHINENG-26911): remove this view once Cyndi decommission completes # across all IoP services. # Per-org custom staleness from hbi.staleness is not supported. diff --git a/src/roles/iop_network/tasks/main.yaml b/src/roles/iop_network/tasks/main.yaml index 3f6676ec2..aca3bb585 100644 --- a/src/roles/iop_network/tasks/main.yaml +++ b/src/roles/iop_network/tasks/main.yaml @@ -1,8 +1,15 @@ --- +# Delegates to deploy_network rather than calling containers.podman.podman_network +# directly so this network also gets deploy_network's netavark 2+ isolation-default +# compatibility handling (see src/roles/deploy_network/tasks/podman.yaml): +# iop-core-network is not meant to be isolated from the other bridge networks it +# needs to reach (e.g. foreman-app, foreman-proxy), and netavark 2+ isolates bridge +# networks by default unless told otherwise. - name: Create IOP Core network - containers.podman.podman_network: - name: "{{ iop_network_name }}" - state: present - driver: "{{ iop_network_driver }}" - subnet: "{{ iop_network_subnet }}" - gateway: "{{ iop_network_gateway }}" + ansible.builtin.include_role: + name: deploy_network + vars: + deploy_network_name: "{{ iop_network_name }}" + deploy_network_driver: "{{ iop_network_driver }}" + deploy_network_subnet: "{{ iop_network_subnet }}" + deploy_network_gateway: "{{ iop_network_gateway }}" diff --git a/src/roles/iop_remediation/tasks/main.yaml b/src/roles/iop_remediation/tasks/main.yaml index 0c5f2ca2a..e41a289a9 100644 --- a/src/roles/iop_remediation/tasks/main.yaml +++ b/src/roles/iop_remediation/tasks/main.yaml @@ -42,8 +42,7 @@ name: iop-service-remediations-api image: iop-remediation.image state: quadlet - network: - - iop-core-network + network: "{{ iop_database_networks }}" command: sh -c "npm run db:migrate && exec node --max-http-header-size=16384 src/app.js" env: REDIS_ENABLED: "false" diff --git a/src/roles/iop_vmaas/defaults/main.yaml b/src/roles/iop_vmaas/defaults/main.yaml index dfe76e638..01638f83b 100644 --- a/src/roles/iop_vmaas/defaults/main.yaml +++ b/src/roles/iop_vmaas/defaults/main.yaml @@ -7,5 +7,6 @@ iop_vmaas_database_user: vmaas_admin iop_vmaas_database_password: "{{ undef(hint='Set a secure database password') }}" iop_vmaas_database_host: "host.containers.internal" iop_vmaas_database_port: "5432" +iop_vmaas_gateway_service_name: "iop-core-gateway" iop_vmaas_client_ca_certificate: "/var/lib/foremanctl/certs/certs/ca.crt" diff --git a/src/roles/iop_vmaas/tasks/main.yaml b/src/roles/iop_vmaas/tasks/main.yaml index 081d43b7e..b0cd0b461 100644 --- a/src/roles/iop_vmaas/tasks/main.yaml +++ b/src/roles/iop_vmaas/tasks/main.yaml @@ -38,7 +38,7 @@ image: iop-vmaas.image state: quadlet quadlet_dir: /etc/containers/systemd - network: iop-core-network + network: "{{ iop_database_networks }}" volumes: - iop-service-vmaas-data:/data:rw command: "/vmaas/entrypoint.sh database-upgrade reposcan" @@ -52,9 +52,9 @@ SYNC_CSAF: "yes" SYNC_RELEASES: "no" SYNC_RELEASE_GRAPH: "no" - KATELLO_URL: "http://iop-core-gateway:9090" - REDHAT_CVEMAP_URL: "http://iop-core-gateway:9090/pub/iop/data/meta/v1/cvemap.xml" - CSAF_VEX_BASE_URL: "http://iop-core-gateway:9090/pub/iop/data/csaf/v2/vex/" + KATELLO_URL: "http://{{ iop_vmaas_gateway_service_name }}:9090" + REDHAT_CVEMAP_URL: "http://{{ iop_vmaas_gateway_service_name }}:9090/pub/iop/data/meta/v1/cvemap.xml" + CSAF_VEX_BASE_URL: "http://{{ iop_vmaas_gateway_service_name }}:9090/pub/iop/data/csaf/v2/vex/" CSAF_VEX_INDEX_CSV_ENABLED: "no" POSTGRESQL_SSL_MODE: "disable" secrets: @@ -80,7 +80,7 @@ image: iop-vmaas.image state: quadlet quadlet_dir: /etc/containers/systemd - network: iop-core-network + network: "{{ iop_database_networks }}" command: "/vmaas/entrypoint.sh webapp-go" env: REPOSCAN_PUBLIC_URL: "http://iop-service-vmaas-reposcan:8000" diff --git a/src/roles/iop_vulnerability/tasks/main.yaml b/src/roles/iop_vulnerability/tasks/main.yaml index a6349f0d0..2049c00e9 100644 --- a/src/roles/iop_vulnerability/tasks/main.yaml +++ b/src/roles/iop_vulnerability/tasks/main.yaml @@ -45,7 +45,7 @@ image: iop-vulnerability.image state: quadlet quadlet_dir: /etc/containers/systemd - network: iop-core-network + network: "{{ iop_database_networks }}" command: "bash -c /engine/dbupgrade.sh" env: UNLEASH_BOOTSTRAP_FILE: "develfeatureflags.json" @@ -76,7 +76,7 @@ image: iop-vulnerability.image state: quadlet quadlet_dir: /etc/containers/systemd - network: iop-core-network + network: "{{ iop_database_networks }}" command: "/engine/entrypoint.sh manager" env: UNLEASH_BOOTSTRAP_FILE: "develfeatureflags.json" @@ -108,7 +108,7 @@ image: iop-vulnerability.image state: quadlet quadlet_dir: /etc/containers/systemd - network: iop-core-network + network: "{{ iop_database_networks }}" command: "/engine/entrypoint.sh taskomatic" env: UNLEASH_BOOTSTRAP_FILE: "develfeatureflags.json" @@ -142,7 +142,7 @@ image: iop-vulnerability.image state: quadlet quadlet_dir: /etc/containers/systemd - network: iop-core-network + network: "{{ iop_database_networks }}" command: "/engine/entrypoint.sh grouper" env: UNLEASH_BOOTSTRAP_FILE: "develfeatureflags.json" @@ -180,7 +180,7 @@ image: iop-vulnerability.image state: quadlet quadlet_dir: /etc/containers/systemd - network: iop-core-network + network: "{{ iop_database_networks }}" command: "/engine/entrypoint.sh listener" env: UNLEASH_BOOTSTRAP_FILE: "develfeatureflags.json" @@ -218,7 +218,7 @@ image: iop-vulnerability.image state: quadlet quadlet_dir: /etc/containers/systemd - network: iop-core-network + network: "{{ iop_database_networks }}" command: "/engine/entrypoint.sh evaluator" env: UNLEASH_BOOTSTRAP_FILE: "develfeatureflags.json" @@ -256,7 +256,7 @@ image: iop-vulnerability.image state: quadlet quadlet_dir: /etc/containers/systemd - network: iop-core-network + network: "{{ iop_database_networks }}" command: "/engine/entrypoint.sh evaluator" env: UNLEASH_BOOTSTRAP_FILE: "develfeatureflags.json" @@ -294,7 +294,7 @@ image: iop-vulnerability.image state: quadlet quadlet_dir: /etc/containers/systemd - network: iop-core-network + network: "{{ iop_database_networks }}" command: "/engine/entrypoint.sh vmaas-sync" env: UNLEASH_BOOTSTRAP_FILE: "develfeatureflags.json" diff --git a/src/vars/database.yml b/src/vars/database.yml index e2ec8f952..c57cf6566 100644 --- a/src/vars/database.yml +++ b/src/vars/database.yml @@ -35,8 +35,10 @@ foreman_database_port: "{{ database_port }}" foreman_database_ssl_mode: "{{ database_ssl_mode }}" foreman_database_ssl_ca: "{{ database_ssl_ca }}" -iop_database_host: host.containers.internal +iop_database_host: "{{ (database_mode == 'internal') | ternary('postgresql', 'host.containers.internal') }}" iop_database_port: 5432 +iop_database_networks: >- + {{ ['iop-core-network'] + ((database_mode == 'internal') | ternary(['foreman-db'], [])) }} iop_inventory_database_host: "{{ iop_database_host }}" iop_inventory_database_port: "{{ iop_database_port }}" diff --git a/tests/feature/iop/test_integration.py b/tests/feature/iop/test_integration.py index 0f74469c0..3d406dcba 100644 --- a/tests/feature/iop/test_integration.py +++ b/tests/feature/iop/test_integration.py @@ -69,6 +69,35 @@ def test_iop_gateway_https_cert_auth(server, certificates): assert result.succeeded +def test_foreman_container_reaches_registered_iop_gateway(server): + result = server.run( + "podman exec foreman curl --fail -s -o /dev/null " + "--cacert /etc/foreman/katello-default-ca.crt " + "--cert /etc/foreman/client_cert.pem " + "--key /etc/foreman/client_key.pem " + "https://iop-core-gateway:8443/v2/features" + ) + assert result.succeeded + + +def test_iop_gateway_bridges_foreman_and_iop_networks(server): + result = server.run( + "podman inspect iop-core-gateway --format '{{.NetworkSettings.Networks}}'" + ) + assert result.succeeded + assert "iop-core-network" in result.stdout + assert "foreman-app" in result.stdout + + +def test_foreman_container_is_not_on_iop_core_network(server): + result = server.run( + "podman inspect foreman --format '{{.NetworkSettings.Networks}}'" + ) + assert result.succeeded + assert "foreman-app" in result.stdout + assert "iop-core-network" not in result.stdout + + def test_iop_core_host_inventory_api_service(server): service_exists = server.run("systemctl list-units --type=service | grep iop-core-host-inventory-api").succeeded if service_exists: From 07df39023329ba0685b5bed02a85e2f803cd56c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20M=C3=A9ndez=20Hern=C3=A1ndez?= Date: Wed, 12 Aug 2026 01:05:47 +0200 Subject: [PATCH 05/10] Configure Podman to seed container /etc/hosts from the host Now that most services run on bridge networks instead of host networking, containers need a way to resolve host-only name mappings that operators define locally (e.g. in /etc/hosts) rather than through real DNS. Podman's base_hosts_file setting copies the host's /etc/hosts entries into each container's own /etc/hosts at container start, which keeps those host-only mappings usable from the smart proxy and other containers without reintroducing host networking. Co-authored-by: Cursor --- src/roles/pre_install/tasks/main.yaml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/roles/pre_install/tasks/main.yaml b/src/roles/pre_install/tasks/main.yaml index c24cc01af..32d206b4a 100644 --- a/src/roles/pre_install/tasks/main.yaml +++ b/src/roles/pre_install/tasks/main.yaml @@ -20,6 +20,24 @@ - podman - skopeo +- name: Create Podman containers config drop-in directory + ansible.builtin.file: + path: /etc/containers/containers.conf.d + state: directory + mode: "0755" + +- name: Configure Podman to include host /etc/hosts entries + ansible.builtin.copy: + dest: /etc/containers/containers.conf.d/foremanctl.conf + mode: "0644" + content: | + [containers] + # Prefer real DNS for cross-container and host name resolution. We still + # seed container /etc/hosts from the host for compatibility with + # deployments that define host-only names locally, but later edits to the + # host file are not propagated into containers that are already running. + base_hosts_file = "/etc/hosts" + - name: Install other dependencies ansible.builtin.package: name: From d8abcb8807b0fd19b058022e0bf692bf4d50d174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20M=C3=A9ndez=20Hern=C3=A1ndez?= Date: Wed, 12 Aug 2026 01:06:14 +0200 Subject: [PATCH 06/10] Issue a dedicated candlepin certificate; add per-hostname SAN aliases Candlepin is now reachable from Foreman as https://candlepin:23443 rather than https://localhost:23443, so it needs a server certificate valid for the candlepin hostname instead of sharing the localhost certificate. certificates_hostnames gains candlepin, and candlepin_tomcat_key/candlepin_tomcat_certificate now point at the newly issued candlepin cert/key pair (candlepin_key/ candlepin_certificate in vars/certificates.yml) rather than the localhost ones. The IoP gateway keeps using the localhost certificate (it is still reachable at both localhost and, once dual-homed onto foreman-app, iop-core-gateway), so certificates now support a certificates_hostname_aliases map of extra SAN names per issued hostname. When the iop feature is enabled, base.yaml adds iop-core-gateway as an alias for the localhost certificate so Foreman's client-cert validation of the gateway succeeds over the bridge network too. Co-authored-by: Cursor --- src/roles/certificates/defaults/main.yml | 1 + src/roles/certificates/tasks/issue.yml | 9 ++++++--- src/vars/base.yaml | 9 +++++++-- src/vars/certificates.yml | 2 ++ tests/certificates_test.py | 9 +++++++++ 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/src/roles/certificates/defaults/main.yml b/src/roles/certificates/defaults/main.yml index d194c2bcc..639d1e37c 100644 --- a/src/roles/certificates/defaults/main.yml +++ b/src/roles/certificates/defaults/main.yml @@ -12,6 +12,7 @@ certificates_output_directory_keys: "{{ certificates_output_directory }}/private certificates_output_directory_requests: "{{ certificates_output_directory }}/requests" certificates_ca_subject: 'Foreman Self-signed CA' certificates_server_aliases: [] +certificates_hostname_aliases: {} certificates_algorithm_type: RSA certificates_algorithm_size: 4096 certificates_ca_validity_days: 7300 diff --git a/src/roles/certificates/tasks/issue.yml b/src/roles/certificates/tasks/issue.yml index 0886fb257..02797236b 100644 --- a/src/roles/certificates/tasks/issue.yml +++ b/src/roles/certificates/tasks/issue.yml @@ -1,7 +1,7 @@ --- - name: Issue server certificate when: - - (certificates_source != 'custom_server') or (certificates_hostname == 'localhost') + - (certificates_source != 'custom_server') or (certificates_hostname in ['localhost', 'candlepin']) block: - name: 'Create server private key' community.crypto.openssl_privatekey: @@ -22,8 +22,11 @@ extended_key_usage: - serverAuth vars: - _certificates_extra_sans: "{{ certificates_server_aliases if certificates_hostname != 'localhost' else [] }}" - _certificates_desired_server_sans: "{{ ([certificates_hostname] + _certificates_extra_sans) | map('regex_replace', '^', 'DNS:') | list }}" + _certificates_extra_sans: >- + {{ (certificates_server_aliases if certificates_hostname != 'localhost' else []) + + ((certificates_hostname_aliases | default({})).get(certificates_hostname, [])) }} + _certificates_desired_server_sans: >- + {{ ([certificates_hostname] + _certificates_extra_sans) | unique | map('regex_replace', '^', 'DNS:') | list }} - name: 'Sign server certificate' community.crypto.x509_certificate: diff --git a/src/vars/base.yaml b/src/vars/base.yaml index 45e5d8c87..984fe0017 100644 --- a/src/vars/base.yaml +++ b/src/vars/base.yaml @@ -2,6 +2,11 @@ certificates_hostnames: - "{{ ansible_facts['fqdn'] }}" - localhost + - candlepin +certificates_hostname_aliases: >- + {{ + {'localhost': ['iop-core-gateway']} if (enabled_features | has_feature('iop')) else {} + }} oauth_directory: "{{ obsah_state_path }}/oauth" @@ -13,8 +18,8 @@ certificates_oauth_directory: /var/lib/foremanctl/oauth candlepin_ca_key: "{{ ca_key }}" candlepin_ca_certificate: "{{ ca_certificate }}" -candlepin_tomcat_key: "{{ localhost_key }}" -candlepin_tomcat_certificate: "{{ localhost_certificate }}" +candlepin_tomcat_key: "{{ candlepin_key }}" +candlepin_tomcat_certificate: "{{ candlepin_certificate }}" candlepin_client_key: "{{ client_key }}" candlepin_client_certificate: "{{ client_certificate }}" diff --git a/src/vars/certificates.yml b/src/vars/certificates.yml index c2349252d..61f99acf4 100644 --- a/src/vars/certificates.yml +++ b/src/vars/certificates.yml @@ -14,6 +14,8 @@ localhost_key: "{{ certificates_ca_directory }}/private/localhost.key" localhost_certificate: "{{ certificates_ca_directory }}/certs/localhost.crt" localhost_client_key: "{{ certificates_ca_directory }}/private/localhost-client.key" localhost_client_certificate: "{{ certificates_ca_directory }}/certs/localhost-client.crt" +candlepin_key: "{{ certificates_ca_directory }}/private/candlepin.key" +candlepin_certificate: "{{ certificates_ca_directory }}/certs/candlepin.crt" iop_gateway_server_certificate: "{{ certificates_ca_directory }}/certs/localhost.crt" iop_gateway_server_key: "{{ certificates_ca_directory }}/private/localhost.key" diff --git a/tests/certificates_test.py b/tests/certificates_test.py index ec827325f..f8c4db49a 100644 --- a/tests/certificates_test.py +++ b/tests/certificates_test.py @@ -88,6 +88,15 @@ def test_localhost_certificate_issued_by_internal_ca(server, certificates, custo "Localhost certificate should be issued by the internal CA even with custom server certs" +def test_candlepin_certificate_issued_by_internal_ca(server, certificates, custom_certificates): + if not server.file(certificates['candlepin_certificate']).exists: + pytest.skip("candlepin certificate not present in proxy deployment") + candlepin_info = certificate_info(server, certificates['candlepin_certificate']) + ca_info = certificate_info(server, certificates['ca_certificate']) + assert candlepin_info['issuer'] == ca_info['subject'], \ + "Candlepin certificate should be issued by the internal CA even with custom server certs" + + def test_ca_bundle_exists(server, certificates): f = server.file(certificates['ca_bundle']) assert f.exists From d60c7277b5129688fed559175b9cf4ebd212a923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20M=C3=A9ndez=20Hern=C3=A1ndez?= Date: Wed, 12 Aug 2026 01:06:38 +0200 Subject: [PATCH 07/10] Normalize internal DB host to container DNS name during migration foreman-installer answers migrated with an internal (db_manage) database used to carry loopback hosts like localhost or 127.0.0.1, which matched the installer's own host-networked PostgreSQL. Now that internal PostgreSQL runs on the foreman-db bridge network as the postgresql container, migrate_answers rewrites those loopback values to postgresql for internal database_mode, leaving external database hosts untouched. Co-authored-by: Cursor --- src/plugins/modules/migrate_answers.py | 12 +++++++ tests/unit/migrate_test.py | 47 ++++++++++++++++++++++---- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/plugins/modules/migrate_answers.py b/src/plugins/modules/migrate_answers.py index d420eaa08..e357e7aa0 100755 --- a/src/plugins/modules/migrate_answers.py +++ b/src/plugins/modules/migrate_answers.py @@ -113,6 +113,16 @@ def flatten_nested_dict(nested_dict, parent_key=''): return dict(items) +def normalize_internal_database_host(mapped_config): + """Rewrite installer loopback DB hosts to container DNS for internal DB deployments.""" + if mapped_config.get('database_mode') != 'internal': + return + + database_host = mapped_config.get('database_host') + if database_host in {'localhost', '127.0.0.1', '::1'}: + mapped_config['database_host'] = 'postgresql' + + def apply_mappings(old_config): """ Transform old config to new format using mapping table. @@ -152,6 +162,8 @@ def apply_mappings(old_config): param_name = str(old_key) unmappable.append(param_name) + normalize_internal_database_host(result) + return { 'mapped': result, 'unmappable': unmappable diff --git a/tests/unit/migrate_test.py b/tests/unit/migrate_test.py index c40eaae38..6080d3457 100644 --- a/tests/unit/migrate_test.py +++ b/tests/unit/migrate_test.py @@ -18,13 +18,15 @@ def test_simple_parameter_mapping(self): old_config = { 'foreman': { 'db_host': 'localhost', - 'db_port': 5432 + 'db_port': 5432, + 'db_manage': True, } } result = migrate_answers.apply_mappings(old_config) - assert result['mapped']['database_host'] == 'localhost' + assert result['mapped']['database_host'] == 'postgresql' + assert result['mapped']['database_mode'] == 'internal' assert result['mapped']['database_port'] == 5432 assert result['unmappable'] == [] @@ -49,7 +51,8 @@ def test_ignore_parameters(self): old_config = { 'foreman': { 'db_manage_rake': True, - 'db_host': 'localhost' + 'db_host': 'localhost', + 'db_manage': True, } } @@ -57,7 +60,7 @@ def test_ignore_parameters(self): assert 'db_manage_rake' not in result['mapped'] assert 'db_manage_rake' not in str(result['unmappable']) - assert result['mapped']['database_host'] == 'localhost' + assert result['mapped']['database_host'] == 'postgresql' def test_certificate_parameters_ignored(self): """Test that certificate path parameters are ignored (handled by migration role)""" @@ -66,7 +69,8 @@ def test_certificate_parameters_ignored(self): 'server_ssl_cert': '/etc/pki/katello/certs/server.crt', 'server_ssl_key': '/etc/pki/katello/private/server.key', 'server_ssl_ca': '/etc/pki/katello/certs/ca.crt', - 'db_host': 'localhost' + 'db_host': 'localhost', + 'db_manage': True, } } @@ -76,7 +80,7 @@ def test_certificate_parameters_ignored(self): assert 'server_key' not in result['mapped'] assert 'ca_certificate' not in result['mapped'] assert not any('ssl' in p for p in result['unmappable']) - assert result['mapped']['database_host'] == 'localhost' + assert result['mapped']['database_host'] == 'postgresql' def test_unmappable_parameters(self): """Test that unmappable parameters are reported""" @@ -124,11 +128,40 @@ def test_mixed_config(self): assert 'foreman::unknown_param' in result['unmappable'] assert len(result['unmappable']) == 1 + def test_internal_database_host_moves_off_localhost(self): + """Internal database migrations should use container DNS instead of loopback.""" + old_config = { + 'foreman': { + 'db_host': '127.0.0.1', + 'db_manage': True, + } + } + + result = migrate_answers.apply_mappings(old_config) + + assert result['mapped']['database_mode'] == 'internal' + assert result['mapped']['database_host'] == 'postgresql' + + def test_external_database_host_keeps_original_value(self): + """External database migrations must preserve the installer host.""" + old_config = { + 'foreman': { + 'db_host': 'localhost', + 'db_manage': False, + } + } + + result = migrate_answers.apply_mappings(old_config) + + assert result['mapped']['database_mode'] == 'external' + assert result['mapped']['database_host'] == 'localhost' + def test_skip_none_values_in_unmappable(self): """Test that None values are not added to unmappable list""" old_config = { 'foreman': { 'db_host': 'localhost', + 'db_manage': True, 'unknown_param': None, 'another_unknown': 'value' } @@ -136,7 +169,7 @@ def test_skip_none_values_in_unmappable(self): result = migrate_answers.apply_mappings(old_config) - assert result['mapped']['database_host'] == 'localhost' + assert result['mapped']['database_host'] == 'postgresql' assert 'foreman::unknown_param' not in result['unmappable'] assert 'foreman::another_unknown' in result['unmappable'] assert len(result['unmappable']) == 1 From dd62d40f7032b76ee72a0ccb28b40341879d3ad6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20M=C3=A9ndez=20Hern=C3=A1ndez?= Date: Wed, 12 Aug 2026 01:06:55 +0200 Subject: [PATCH 08/10] Keep development deployments on host networking Development deployments run the Rails process directly on the host, so postgresql/valkey/candlepin/pulp keep network: host and their localhost-based URLs there rather than adopting the new bridge networks, avoiding a parallel set of development-only service URL rewrites. remote-database.yaml, which stands up a standalone PostgreSQL instance, needs the same explicit postgresql_network: host override now that the role's own default changed to foreman-db. Co-authored-by: Cursor --- development/playbooks/deploy-dev/deploy-dev.yaml | 15 +++++++++++++++ .../remote-database/remote-database.yaml | 2 ++ 2 files changed, 17 insertions(+) diff --git a/development/playbooks/deploy-dev/deploy-dev.yaml b/development/playbooks/deploy-dev/deploy-dev.yaml index 74b2446bf..0c9ba4659 100644 --- a/development/playbooks/deploy-dev/deploy-dev.yaml +++ b/development/playbooks/deploy-dev/deploy-dev.yaml @@ -46,10 +46,25 @@ - role: systemd_target - role: certificates - role: postgresql + vars: + postgresql_network: host - role: valkey + vars: + valkey_network: host - role: candlepin + vars: + candlepin_networks: host + candlepin_database_host: localhost + candlepin_healthcheck_host: localhost + candlepin_tomcat_key: "{{ localhost_key }}" + candlepin_tomcat_certificate: "{{ localhost_certificate }}" - role: httpd - role: pulp + vars: + pulp_networks: host + pulp_migration_networks: host + pulp_database_host: localhost + pulp_redis_url: "redis://localhost:6379/8" - role: foreman_development vars: foreman_development_oauth_consumer_key: "{{ foreman_oauth_consumer_key }}" diff --git a/development/playbooks/remote-database/remote-database.yaml b/development/playbooks/remote-database/remote-database.yaml index 346f91682..904e8311a 100644 --- a/development/playbooks/remote-database/remote-database.yaml +++ b/development/playbooks/remote-database/remote-database.yaml @@ -18,6 +18,8 @@ - role: pre_install - role: certificates - role: postgresql + vars: + postgresql_network: host tasks: - name: Fetch PostgreSQL SSL CA From 9b5576ab2eee04464db5bacbbfc5d42c964d3cf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20M=C3=A9ndez=20Hern=C3=A1ndez?= Date: Wed, 12 Aug 2026 01:07:11 +0200 Subject: [PATCH 09/10] Document the container networking model Add a "Container networking" section to docs/developer/deployment.md describing the four bridge networks, how services reach each other by container DNS name, the foreman-proxy public/internal identity split, why development deployments stay on host networking, and the base_hosts_file DNS fallback. Update docs/iop.md's architecture, service table, database, and certificate sections to reflect the gateway's dual-homed networking and the internal database's bridge access. Co-authored-by: Cursor --- docs/developer/deployment.md | 19 +++++++++++++++++++ docs/iop.md | 10 +++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/developer/deployment.md b/docs/developer/deployment.md index 7b9a92d03..6ed308680 100644 --- a/docs/developer/deployment.md +++ b/docs/developer/deployment.md @@ -441,6 +441,25 @@ The external authentication configuration is managed through `foremanctl` comman If `hammer` feature is enabled and `--external-authentication` is set to `ipa_with_api`, `hammer` will be configured to use negotiate-based authentication. +## Container networking + +Server deployments create four named Podman bridge networks: + +- `foreman-db`: internal, isolated network for the internal PostgreSQL container and its clients +- `foreman-cache`: internal, isolated network for Valkey and its clients +- `foreman-app`: shared application network for Foreman, Candlepin, and Pulp +- `foreman-proxy`: smart-proxy network used when the `foreman-proxy` feature is enabled + +Services communicate across these bridges by container DNS name instead of `localhost`. The internal database and cache containers are not exposed on host TCP ports. Foreman and Pulp continue to integrate with host `httpd` through the existing systemd socket activation and Unix-socket backends, so the bridge migration does not reintroduce loopback port publishing for those services. + +Candlepin is reachable from Foreman as `https://candlepin:23443/candlepin`, so the deployment issues a dedicated certificate for the `candlepin` DNS name and mounts that certificate into the Candlepin container. Foreman validates that hostname using the existing installer CA trust. + +The `foreman-proxy` deployment always registers the smart proxy with Foreman under its own real FQDN URL (`foreman_proxy_name`/`foreman_proxy_url`); this identity is unaffected by the bridge-network split. `--registration-url` is a separate, optional override: when set, it is written into the proxy's own `settings.d/registration.yml` as the endpoint the proxy tells newly-registering hosts to actually use (for example, a load-balancer DNS name in front of multiple proxies), so registration traffic really does go wherever it points. What it does *not* do is change how Foreman itself manages the proxy, or require the proxy's own certificate to cover that hostname: foremanctl's deployment automation neither dials that URL nor validates it against any certificate, leaving both concerns (does the URL actually work for hosts, is it certificate-valid on whatever terminates TLS there) to the operator, the same way master does today. + +Development deployments intentionally keep `postgresql`, `valkey`, `candlepin`, and `pulp` on `network: host`. In that workflow the Rails process runs directly on the host, so preserving `localhost` endpoints avoids a parallel set of development-only service URL rewrites. + +Containers inherit host `/etc/hosts` entries through Podman's `base_hosts_file` setting. This keeps host-only name mappings usable from inside the smart proxy and other containers even when bridge DNS is in use. + ## Deployment architecture The primary way of deployment is to install `foremanctl` on a system and then let `foremanctl` deploy the various components on the same system. diff --git a/docs/iop.md b/docs/iop.md index e767f7395..724922326 100644 --- a/docs/iop.md +++ b/docs/iop.md @@ -10,7 +10,7 @@ The `iop` feature depends on `rh-cloud`, which installs the `foreman_rh_cloud` p ## Architecture -IOP runs as a set of containerized services managed via podman quadlets on the `iop-core-network` (bridge, `10.130.0.0/24`). The gateway is registered as a Foreman smart proxy at `https://localhost:24443`. +IOP runs as a set of containerized services managed via podman quadlets on the isolated `iop-core-network` (bridge, `10.130.0.0/24`). Foreman stays on the shared `foreman-app` bridge, while the gateway is dual-homed onto both networks and registered as a smart proxy at `https://iop-core-gateway:8443`. The same gateway remains exposed on host loopback at `https://localhost:24443` for host-side access. ```mermaid graph TB @@ -79,10 +79,10 @@ graph TB | puptoo | `iop-core-puptoo` | - | Puppet/system facts processor | | yuptoo | `iop-core-yuptoo` | - | Yum/package data processor | | engine | `iop-core-engine` | - | Insights rules engine | -| gateway | `iop-core-gateway` | 127.0.0.1:24443 | nginx proxy, smart proxy relay to Foreman | +| gateway | `iop-core-gateway` | 8443 (internal), 127.0.0.1:24443 (host loopback) | nginx proxy, smart proxy relay to Foreman | | inventory | `iop-core-host-inventory`, `iop-core-host-inventory-api` | 8081 (internal) | Host inventory with MQ consumer and REST API | | advisor | `iop-service-advisor-backend-api`, `iop-service-advisor-backend-service` | 8000 (internal) | Advisor recommendations | -| remediation | `iop-service-remediations-api` | 3000 (host network) | Remediation playbook generation | +| remediation | `iop-service-remediations-api` | 3000 (internal) | Remediation playbook generation | | vmaas | `iop-service-vmaas-reposcan`, `iop-service-vmaas-webapp-go` | - | Vulnerability metadata and advisory sync | | vulnerability | 8 containers (manager, taskomatic, grouper, listener, evaluators, vmaas-sync) | 8443 (internal) | Vulnerability assessment pipeline | @@ -96,7 +96,7 @@ Advisor and vulnerability frontend assets are extracted from container images an ### Databases -IOP creates five PostgreSQL databases, all accessible to containers via `host.containers.internal:5432`: +IOP creates five PostgreSQL databases. In the supported internal database mode, IoP database clients join the `foreman-db` bridge and reach PostgreSQL as `postgresql:5432`: | Database | User | |----------|------| @@ -122,7 +122,7 @@ Set in the playbook vars or inventory to match your Foreman deployment: ### Certificates -Gateway certificates use the default certificate paths: +Gateway certificates continue to use the default certificate paths. The gateway server certificate is issued for both `localhost` and `iop-core-gateway` so the same endpoint works from the host and from the shared Foreman container network without introducing a separate host-side DNS mapping: - Server: `/var/lib/foremanctl/certs/certs/localhost.crt` - Client: `/var/lib/foremanctl/certs/certs/localhost-client.crt` From cc66858ae6f09e4b7b3a1a026fef957c7990ddcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20M=C3=A9ndez=20Hern=C3=A1ndez?= Date: Wed, 12 Aug 2026 01:07:57 +0200 Subject: [PATCH 10/10] Update integration tests for bridge-networked deployments Adjust the remaining feature/integration tests to match services no longer being reachable via host networking: PostgreSQL and Valkey are no longer expected to have host-published ports (asserting instead that the PostgreSQL Unix socket exists and that Valkey responds via podman exec), Candlepin's status/TLS checks now run curl and openssl s_client from inside the foreman container against the candlepin container DNS name instead of curling localhost from the host, and the webhook listener fixture targets host.containers.internal since containers can no longer reach the host via localhost. Remote execution tests now install the foreman-proxy container's SSH key into the client's authorized_keys via a new remote_execution_authorized_proxy_key fixture and verify the client is DNS-resolvable from the proxy container, since the proxy's SSH-based remote execution now runs from a bridge-networked container rather than the host. Co-authored-by: Cursor --- tests/conftest.py | 48 +++++++++++++++++++ tests/feature/ansible/base_test.py | 11 ++++- tests/feature/foreman-proxy/base_test.py | 22 +++++++++ tests/feature/foreman/base_test.py | 6 +++ tests/feature/katello/candlepin_test.py | 61 ++++++++++++++---------- tests/feature/katello/client_test.py | 15 +++++- tests/feature/webhooks/base_test.py | 3 +- tests/postgresql_test.py | 6 +-- tests/valkey_test.py | 23 +++++---- 9 files changed, 155 insertions(+), 40 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index b52d0f178..aef6300c2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -160,6 +160,45 @@ def client(client_hostname): yield get_paramiko_host(client_hostname) +@pytest.fixture +def remote_execution_authorized_proxy_key(server, client): + proxy_public_key = server.check_output( + "podman exec foreman-proxy cat /usr/share/foreman-proxy/.ssh/id_rsa_foreman_proxy.pub" + ).strip() + + client.run_test( + "python3 - <<'PY'\n" + "from pathlib import Path\n" + "ssh_dir = Path('/root/.ssh')\n" + "ssh_dir.mkdir(mode=0o700, parents=True, exist_ok=True)\n" + "authorized_keys = ssh_dir / 'authorized_keys'\n" + "authorized_keys.touch()\n" + "authorized_keys.chmod(0o600)\n" + f"proxy_public_key = {proxy_public_key!r}\n" + "lines = authorized_keys.read_text().splitlines()\n" + "if proxy_public_key not in lines:\n" + " with authorized_keys.open('a', encoding='utf-8') as fh:\n" + " fh.write(proxy_public_key + '\\n')\n" + "PY" + ) + + yield + + client.run( + "python3 - <<'PY'\n" + "from pathlib import Path\n" + "authorized_keys = Path('/root/.ssh/authorized_keys')\n" + f"proxy_public_key = {proxy_public_key!r}\n" + "if authorized_keys.exists():\n" + " filtered = [line for line in authorized_keys.read_text().splitlines() if line != proxy_public_key]\n" + " if filtered:\n" + " authorized_keys.write_text('\\n'.join(filtered) + '\\n', encoding='utf-8')\n" + " else:\n" + " authorized_keys.unlink()\n" + "PY" + ) + + @pytest.fixture(scope="module") def database(database_mode, server): if database_mode == 'external': @@ -278,6 +317,15 @@ def wait_for_metadata_generate(foremanapi): wait_for_tasks(foremanapi, 'label = Actions::Katello::Repository::MetadataGenerate') +def assert_container_resolves_hostname(server, container_name, hostname): + dns_result = server.run(f"podman exec {container_name} getent hosts {hostname}") + assert dns_result.succeeded, f"DNS-resolvable host {hostname} not found from {container_name} container" + + +def assert_container_resolves_server_fqdn(server, container_name, server_fqdn): + assert_container_resolves_hostname(server, container_name, server_fqdn) + + def pytest_configure(config): config.addinivalue_line("markers", "feature(name): mark a test as requiring a feature") diff --git a/tests/feature/ansible/base_test.py b/tests/feature/ansible/base_test.py index 6619ffb48..0c13bbf73 100644 --- a/tests/feature/ansible/base_test.py +++ b/tests/feature/ansible/base_test.py @@ -39,10 +39,19 @@ def test_import_ansible_role(ansible_role, foremanapi): @pytest.fixture -def registered_client(client_environment, activation_key, organization, foremanapi, client, client_fqdn): +def registered_client( + client_environment, + activation_key, + organization, + foremanapi, + client, + client_fqdn, + remote_execution_authorized_proxy_key, +): client.run('dnf install -y subscription-manager') rcmd = foremanapi.create('registration_commands', {'organization_id': organization['id'], 'insecure': True, 'activation_keys': [activation_key['name']], 'force': True}) client.run_test(rcmd['registration_command']) + yield client_fqdn try: foremanapi.delete('hosts', {'id': client_fqdn}) diff --git a/tests/feature/foreman-proxy/base_test.py b/tests/feature/foreman-proxy/base_test.py index 8c92e63c1..d7b180313 100644 --- a/tests/feature/foreman-proxy/base_test.py +++ b/tests/feature/foreman-proxy/base_test.py @@ -4,6 +4,7 @@ import pytest from tests.conftest import FOREMAN_PROXY_PORT +from tests.conftest import assert_container_resolves_server_fqdn @pytest.fixture(scope="module") @@ -52,6 +53,27 @@ def test_foreman_proxy_port(server): assert foreman_proxy.port(FOREMAN_PROXY_PORT).is_reachable +@pytest.mark.feature('foreman') +def test_foreman_reaches_proxy_via_bridge_network(server, proxy_base_url): + cmd = server.run( + "podman exec foreman curl " + "--silent --show-error --fail " + "--connect-timeout 5 --max-time 10 " + "--cacert /etc/foreman/katello-default-ca.crt " + "--cert /etc/foreman/client_cert.pem " + "--key /etc/foreman/client_key.pem " + f"{proxy_base_url}/v2/features" + ) + assert cmd.succeeded, ( + "Foreman container could not reach the proxy over the bridge " + f"network: {cmd.stderr}" + ) + + +def test_foreman_proxy_resolves_server_fqdn(server, server_fqdn): + assert_container_resolves_server_fqdn(server, "foreman-proxy", server_fqdn) + + @pytest.mark.xfail(reason='Fails until report feature is available') def test_foreman_proxy_client_auth_to_foreman(curl_request): test_report = {"config_report": {"host": "test.example.com", "reported_at": datetime.datetime.now(datetime.UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}} diff --git a/tests/feature/foreman/base_test.py b/tests/feature/foreman/base_test.py index 839e31c17..71afe8abc 100644 --- a/tests/feature/foreman/base_test.py +++ b/tests/feature/foreman/base_test.py @@ -2,6 +2,8 @@ import pytest +from tests.conftest import assert_container_resolves_server_fqdn + FOREMAN_SOCKET = '/run/httpd.foreman.sock' RECURRING_INSTANCES = [ @@ -45,6 +47,10 @@ def test_foreman_status_cache(foreman_status): assert foreman_status['results']['foreman']['cache']['servers'][0]['status'] == 'ok' +def test_foreman_resolves_server_fqdn(server, server_fqdn): + assert_container_resolves_server_fqdn(server, "foreman", server_fqdn) + + @pytest.mark.feature('katello') @pytest.mark.parametrize("katello_service", ['candlepin', 'candlepin_auth', 'foreman_tasks', 'katello_events', 'pulp3', 'pulp3_content']) def test_katello_services_status(foreman_status, katello_service): diff --git a/tests/feature/katello/candlepin_test.py b/tests/feature/katello/candlepin_test.py index 4d88ac8ae..6e0294ad0 100644 --- a/tests/feature/katello/candlepin_test.py +++ b/tests/feature/katello/candlepin_test.py @@ -4,6 +4,16 @@ def assert_secret_content(server, secret_name, secret_value): assert secret.stdout.strip() == secret_value +def run_candlepin_status_from_foreman(server): + return server.run( + "podman exec foreman curl " + "--cacert /etc/foreman/katello-default-ca.crt " + "--noproxy candlepin " + "--silent --output /dev/null --write-out '%{http_code}' " + "https://candlepin:23443/candlepin/status" + ) + + def test_candlepin_service(server): candlepin = server.service("candlepin") assert candlepin.is_running @@ -26,33 +36,27 @@ def test_candlepin_runs_as_tomcat(server): assert secret_ownership == 'root:tomcat 440' -def test_candlepin_port(server): +def test_candlepin_port_not_published(server): candlepin = server.addr("localhost") - assert candlepin.port("23443").is_reachable + assert not candlepin.port("23443").is_reachable -def test_candlepin_status(server, certificates): - status = server.run(f"curl --cacert {certificates['ca_certificate']} --silent --output /dev/null --write-out '%{{http_code}}' https://localhost:23443/candlepin/status") +def test_candlepin_status(server): + status = run_candlepin_status_from_foreman(server) assert status.succeeded assert status.stdout == '200' -def test_candlepin_logs_in_journal(server, certificates): - server.run( - f"curl --cacert {certificates['ca_certificate']} --silent --output /dev/null " - f"https://localhost:23443/candlepin/status" - ) +def test_candlepin_logs_in_journal(server): + run_candlepin_status_from_foreman(server) journal = server.run("journalctl -u candlepin --since '2 min ago' --no-pager").stdout assert 'candlepin/status' in journal assert 'LoggingFilter' in journal -def test_candlepin_tomcat_logs_in_journal(server, certificates): - server.run( - f"curl --cacert {certificates['ca_certificate']} --silent --output /dev/null " - f"https://localhost:23443/candlepin/status" - ) +def test_candlepin_tomcat_logs_in_journal(server): + run_candlepin_status_from_foreman(server) journal = server.run("journalctl -u candlepin --no-pager").stdout assert '"GET /candlepin/status HTTP/1.1"' in journal @@ -60,14 +64,21 @@ def test_candlepin_tomcat_logs_in_journal(server, certificates): def test_tls(server): - result = server.run('nmap --script +ssl-enum-ciphers localhost -p 23443') - result = result.stdout - assert "TLSv1.3" in result - assert "TLSv1.2" in result - - # Test that older TLS versions are disabled - assert "TLSv1.1" not in result - assert "TLSv1.0" not in result - - # Test that the least cipher strength is "strong" or "A" - assert "least strength: A" in result + for flag in ("-tls1_2", "-tls1_3"): + result = server.run( + "podman exec foreman bash -lc " + f"\"echo Q | openssl s_client -connect candlepin:23443 -servername candlepin {flag} " + "-CAfile /etc/foreman/katello-default-ca.crt 2>&1\"" + ) + assert result.succeeded + assert "Verify return code: 0 (ok)" in result.stdout + + for flag in ("-tls1_1", "-tls1"): + result = server.run( + "podman exec foreman bash -lc " + f"\"echo Q | openssl s_client -connect candlepin:23443 -servername candlepin {flag} " + "-CAfile /etc/foreman/katello-default-ca.crt 2>&1\"" + ) + assert result.failed + assert "no protocols available" in result.stdout + assert "no peer certificate available" in result.stdout diff --git a/tests/feature/katello/client_test.py b/tests/feature/katello/client_test.py index 1bcef15c0..2f824c777 100644 --- a/tests/feature/katello/client_test.py +++ b/tests/feature/katello/client_test.py @@ -1,3 +1,6 @@ +from tests.conftest import assert_container_resolves_hostname + + def test_foreman_content_view(client_environment, activation_key, organization, foremanapi, client): client.run('dnf install -y subscription-manager') rcmd = foremanapi.create('registration_commands', {'organization_id': organization['id'], 'insecure': True, 'activation_keys': [activation_key['name']], 'force': True}) @@ -10,10 +13,20 @@ def test_foreman_content_view(client_environment, activation_key, organization, client.run('subscription-manager clean') -def test_foreman_rex(client_environment, activation_key, organization, foremanapi, client, client_fqdn): +def test_foreman_rex( + client_environment, + activation_key, + organization, + foremanapi, + client, + server, + client_fqdn, + remote_execution_authorized_proxy_key, +): client.run('dnf install -y subscription-manager') rcmd = foremanapi.create('registration_commands', {'organization_id': organization['id'], 'insecure': True, 'activation_keys': [activation_key['name']], 'force': True}) client.run_test(rcmd['registration_command']) + assert_container_resolves_hostname(server, "foreman-proxy", client_fqdn) job = foremanapi.create('job_invocations', {'feature': 'run_script', 'inputs': {'command': 'uptime'}, 'search_query': f'name = {client_fqdn}', 'targeting_type': 'static_query'}) task = foremanapi.wait_for_task(job['task']) assert task['result'] == 'success' diff --git a/tests/feature/webhooks/base_test.py b/tests/feature/webhooks/base_test.py index 811faf002..0667506fd 100644 --- a/tests/feature/webhooks/base_test.py +++ b/tests/feature/webhooks/base_test.py @@ -4,6 +4,7 @@ import pytest LISTENER_PORT = 9999 +LISTENER_HOST = "host.containers.internal" @pytest.fixture @@ -40,7 +41,7 @@ def webhook(foremanapi, server_fqdn, webhook_listener, webhook_template): "webhooks", { "name": str(uuid.uuid4()), - "target_url": f"http://localhost:{LISTENER_PORT}", + "target_url": f"http://{LISTENER_HOST}:{LISTENER_PORT}", "http_method": "POST", "event": "domain_created.event.foreman", "http_content_type": "application/json", diff --git a/tests/postgresql_test.py b/tests/postgresql_test.py index 55c0185b5..6e639d1ac 100644 --- a/tests/postgresql_test.py +++ b/tests/postgresql_test.py @@ -8,9 +8,9 @@ def test_postgresql_service(database): assert postgresql.is_running -def test_postgresql_port(database): - postgresql = database.addr("localhost") - assert postgresql.port("5432").is_reachable +def test_postgresql_socket(database): + socket = database.file("/var/run/postgresql/.s.PGSQL.5432") + assert socket.exists def test_postgresql_password_encryption(database): diff --git a/tests/valkey_test.py b/tests/valkey_test.py index 19b7aa7bb..06ef89778 100644 --- a/tests/valkey_test.py +++ b/tests/valkey_test.py @@ -1,5 +1,4 @@ -VALKEY_HOST = 'localhost' -VALKEY_PORT = 6379 +import pytest def test_valkey_service(server): @@ -12,12 +11,18 @@ def test_redis_service_absent(server): assert not redis.exists -def test_valkey_port(server): - valkey = server.addr(VALKEY_HOST) - assert valkey.port(VALKEY_PORT).is_reachable +def test_valkey_not_exposed_on_host(server): + valkey = server.addr("localhost") + assert not valkey.port("6379").is_reachable -def test_valkey_listens_on_localhost_only(server): - result = server.run(f"ss -tlnH sport = :{VALKEY_PORT}") - assert f'127.0.0.1:{VALKEY_PORT}' in result.stdout - assert f'0.0.0.0:{VALKEY_PORT}' not in result.stdout +@pytest.mark.feature('foreman') +def test_valkey_resolves_from_foreman(server): + result = server.run("podman exec foreman getent hosts valkey") + assert result.succeeded + + +def test_valkey_ping(server): + result = server.run("podman exec valkey valkey-cli ping") + assert result.succeeded + assert result.stdout.strip() == "PONG"