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 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` 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..8ad2346e8 100644 --- a/src/playbooks/deploy/deploy.yaml +++ b/src/playbooks/deploy/deploy.yaml @@ -26,6 +26,24 @@ 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: deploy_network + vars: + deploy_network_name: foreman-proxy + when: + - "enabled_features | has_feature('foreman-proxy')" - role: postgresql when: - database_mode == 'internal' @@ -40,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/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/src/roles/backup/tasks/main.yaml b/src/roles/backup/tasks/main.yaml index f73999b17..1b9207c37 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 }}" @@ -128,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/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/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/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/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 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/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/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/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/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: 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/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/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/src/vars/database.yml b/src/vars/database.yml index 7efcfb7d8..c57cf6566 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: @@ -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/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 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/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: 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/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 }}" 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/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 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 }}" 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"