From 3661043f85a54587d33c78ac8aa247976b571e4c Mon Sep 17 00:00:00 2001 From: Charlie Wheeler-Robinson Date: Thu, 16 Sep 2021 17:11:23 +0100 Subject: [PATCH] Make public MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Michele Costa Co-authored-by: Charlie Wheeler-Robinson Co-authored-by: Marek Kochanowski Co-authored-by: Pablo Iranzo Gómez Co-authored-by: Dave Cain Co-authored-by: Hanen Garcia Co-authored-by: Arnaldo Hernandez Co-authored-by: William Caban Babilonia Co-authored-by: Ajay Simha Co-authored-by: DirectedSoul1 Co-authored-by: Ali Bokhari Co-authored-by: Alex Krzos Co-authored-by: Nati Fridman Co-authored-by: a1bokhari --- .ansible-lint | 10 + .github/ISSUE_TEMPLATE/bug-report.yml | 67 +++++ .github/ISSUE_TEMPLATE/config.yml | 1 + .github/ISSUE_TEMPLATE/feature-proposal.yml | 23 ++ .github/dependabot.yml | 21 ++ .github/workflows/ansible-lint.yml | 76 +++++ .github/workflows/broken-link-check.yml | 16 + .github/workflows/crucible-tests.yml | 19 ++ .github/workflows/merge-requirements.yml | 14 + .gitignore | 17 ++ .pre-commit-config.yaml | 20 ++ .yaspeller.json | 21 ++ CODEOWNERS | 9 + CONTRIBUTING.md | 79 +++++ LICENSE | 202 +++++++++++++ README.md | 199 ++++++++++++ ansible.cfg | 16 + deploy_cluster.yml | 86 ++++++ deploy_day2_workers.yml | 29 ++ deploy_prerequisites.yml | 92 ++++++ docs/connecting_to_hosts.md | 54 ++++ docs/images/simple_kvm.drawio | 1 + docs/images/simple_kvm.png | Bin 0 -> 91129 bytes docs/images/simple_kvm_physical.drawio | 1 + docs/images/simple_kvm_physical.png | Bin 0 -> 22197 bytes docs/images/vm_host_interfaces.drawio | 1 + docs/images/vm_host_interfaces.png | Bin 0 -> 22573 bytes docs/inventory.md | 274 +++++++++++++++++ docs/pipeline_into_the_details.md | 81 +++++ .../discovery_iso_not_booting.md | 30 ++ inventory.vault.yml.sample | 28 ++ inventory.yml.sample | 284 ++++++++++++++++++ playbooks/boot_disk.yml | 13 + playbooks/boot_iso.yml | 9 + playbooks/create_vms.yml | 22 ++ playbooks/dell_idrac_soft_reset.yml | 21 ++ .../deploy_assisted_installer_onprem.yml | 29 ++ playbooks/deploy_dns.yml | 8 + playbooks/deploy_http_store.yml | 6 + playbooks/deploy_registry.yml | 23 ++ playbooks/deploy_sushy_tools.yml | 4 + playbooks/destroy_vms.yml | 5 + playbooks/generate_discovery_iso.yml | 16 + playbooks/generate_ssh_key_pair.yml | 5 + playbooks/install_cluster.yml | 15 + playbooks/monitor_cluster.yml | 25 ++ playbooks/validate_inventory.yml | 6 + post_install.yml | 45 +++ prereq_facts_check.yml | 11 + requirements.txt | 1 + requirements.yml | 7 + roles/add_day2_node/defaults/main.yml | 5 + roles/add_day2_node/tasks/main.yml | 53 ++++ roles/approve_csrs/defaults/main.yml | 4 + roles/approve_csrs/tasks/main.yml | 7 + roles/boot_disk/defaults/main.yml | 4 + roles/boot_disk/meta/main.yml | 9 + roles/boot_disk/tasks/dell.yml | 31 ++ roles/boot_disk/tasks/dell_redfish.yml | 37 +++ roles/boot_disk/tasks/kvm.yml | 90 ++++++ roles/boot_disk/tasks/lenovo.yml | 53 ++++ roles/boot_disk/tasks/main.yml | 27 ++ roles/boot_disk/tasks/supermicro.yml | 67 +++++ roles/boot_iso/defaults/main.yml | 4 + roles/boot_iso/handlers/main.yml | 2 + roles/boot_iso/meta/main.yml | 9 + roles/boot_iso/tasks/dell.yml | 30 ++ roles/boot_iso/tasks/dell_idrac.yml | 11 + roles/boot_iso/tasks/dell_redfish.yml | 103 +++++++ roles/boot_iso/tasks/hpe.yml | 24 ++ roles/boot_iso/tasks/kvm.yml | 173 +++++++++++ roles/boot_iso/tasks/lenovo.yml | 125 ++++++++ roles/boot_iso/tasks/main.yml | 38 +++ roles/boot_iso/tasks/supermicro.yml | 89 ++++++ roles/boot_iso/templates/vitual_media.json.j2 | 4 + roles/boot_iso/tests/inventory | 1 + roles/boot_iso/tests/test.yml | 5 + roles/boot_iso/vars/main.yml | 2 + roles/create_cluster/defaults/main.yml | 26 ++ roles/create_cluster/meta/main.yml | 2 + roles/create_cluster/tasks/main.yml | 161 ++++++++++ roles/create_cluster/tasks/manifest.yml | 31 ++ .../templates/01-master-node-scheduler.yml.j2 | 8 + .../templates/02-fix-ingress-config.yml.j2 | 12 + .../templates/50-worker-nm-fix-ipv6.yml.j2 | 18 ++ .../50-worker-remove-ipi-leftovers.yml.j2 | 30 ++ .../templates/patch-discovery-ignition.j2 | 31 ++ .../templates/patch-install-config.j2 | 13 + .../templates/patch-network-type.j2 | 2 + .../templates/patch-search-registries.j2 | 19 ++ roles/create_cluster/tests/inventory | 1 + roles/create_cluster/tests/test.yml | 5 + roles/create_cluster/vars/main.yml | 5 + roles/create_day2_cluster/defaults/main.yml | 17 ++ roles/create_day2_cluster/tasks/main.yml | 50 +++ roles/create_vms/defaults/main.yml | 39 +++ roles/create_vms/meta/main.yml | 2 + roles/create_vms/tasks/main.yml | 43 +++ roles/create_vms/tasks/prepare_bridges.yml | 37 +++ roles/create_vms/tasks/prepare_firewall.yml | 9 + roles/create_vms/tasks/prepare_network.yml | 14 + .../create_vms/tasks/prepare_storage_pool.yml | 23 ++ roles/create_vms/tasks/provision_vms.yml | 28 ++ roles/create_vms/templates/create_vm.sh.j2 | 22 ++ roles/create_vms/templates/network.xml.j2 | 6 + roles/create_vms/templates/rng_device.xml.j2 | 3 + .../create_vms/templates/storage-pool.xml.j2 | 14 + roles/destroy_vms/defaults/main.yml | 15 + roles/destroy_vms/meta/main.yml | 2 + roles/destroy_vms/tasks/destroy_pools.yml | 39 +++ roles/destroy_vms/tasks/destroy_vms.yml | 32 ++ roles/destroy_vms/tasks/main.yml | 35 +++ .../generate_discovery_iso/defaults/main.yml | 25 ++ .../generate_discovery_iso/handlers/main.yml | 2 + roles/generate_discovery_iso/meta/main.yml | 53 ++++ roles/generate_discovery_iso/tasks/main.yml | 77 +++++ roles/generate_discovery_iso/tasks/static.yml | 35 +++ .../templates/nmstate.yml.j2 | 52 ++++ roles/generate_discovery_iso/tests/inventory | 1 + roles/generate_discovery_iso/tests/test.yml | 5 + roles/generate_discovery_iso/vars/main.yml | 5 + roles/generate_ssh_key_pair/defaults/main.yml | 6 + roles/generate_ssh_key_pair/meta/main.yml | 2 + roles/generate_ssh_key_pair/tasks/main.yml | 45 +++ roles/get_image_hash/README.md | 26 ++ roles/get_image_hash/defaults/main.yml | 22 ++ roles/get_image_hash/meta/main.yml | 2 + roles/get_image_hash/tasks/get_image_hash.yml | 7 + roles/get_image_hash/tasks/main.yml | 17 ++ roles/insert_dns_records/README.md | 47 +++ roles/insert_dns_records/defaults/main.yml | 21 ++ .../insert_dns_records/files/nm-dnsmasq.conf | 2 + roles/insert_dns_records/handlers/main.yml | 8 + roles/insert_dns_records/meta/main.yml | 2 + .../tasks/configure_firewall.yml | 22 ++ roles/insert_dns_records/tasks/dnsmasq.yml | 18 ++ roles/insert_dns_records/tasks/main.yml | 41 +++ .../tasks/network-manager.yml | 17 ++ .../templates/nm-dnsmasq.conf.j2 | 2 + .../templates/openshift-cluster.conf.j2 | 45 +++ roles/install_cluster/defaults/main.yml | 17 ++ roles/install_cluster/meta/main.yml | 2 + .../install_cluster/tasks/hosts_discovery.yml | 95 ++++++ roles/install_cluster/tasks/main.yml | 104 +++++++ roles/install_cluster/tests/inventory | 1 + roles/install_cluster/tests/test.yml | 5 + roles/monitor_cluster/defaults/main.yml | 15 + roles/monitor_cluster/meta/main.yml | 2 + roles/monitor_cluster/tasks/main.yml | 27 ++ roles/monitor_host/defaults/main.yml | 15 + roles/monitor_host/meta/main.yml | 2 + roles/monitor_host/tasks/hosts_monitoring.yml | 23 ++ roles/monitor_host/tasks/main.yml | 28 ++ .../defaults/main.yml | 54 ++++ roles/populate_mirror_registry/meta/main.yml | 2 + roles/populate_mirror_registry/tasks/main.yml | 13 + .../tasks/populate_registry.yml | 53 ++++ .../tasks/prerequisites.yml | 127 ++++++++ .../tasks/var_check.yml | 15 + roles/prereq_facts_check/README.md | 18 ++ roles/prereq_facts_check/defaults/main.yml | 3 + roles/prereq_facts_check/meta/main.yml | 2 + roles/prereq_facts_check/tasks/main.yml | 30 ++ .../defaults/main.yml | 86 ++++++ roles/setup_assisted_installer/meta/main.yml | 2 + roles/setup_assisted_installer/tasks/main.yml | 142 +++++++++ .../templates/nginx-ui.conf | 20 ++ .../templates/onprem-environment.j2 | 31 ++ roles/setup_http_store/README.md | 28 ++ roles/setup_http_store/defaults/main.yml | 8 + roles/setup_http_store/meta/main.yml | 2 + roles/setup_http_store/tasks/main.yml | 89 ++++++ roles/setup_mirror_registry/defaults/main.yml | 42 +++ roles/setup_mirror_registry/meta/main.yml | 2 + roles/setup_mirror_registry/tasks/main.yml | 15 + .../tasks/prerequisites.yml | 31 ++ .../tasks/retrieve_config.yml | 29 ++ .../tasks/setup_registry.yml | 129 ++++++++ .../setup_mirror_registry/tasks/var_check.yml | 30 ++ roles/setup_ntp/defaults/main.yml | 6 + roles/setup_ntp/handlers/main.yml | 14 + roles/setup_ntp/meta/main.yml | 2 + roles/setup_ntp/tasks/main.yml | 31 ++ roles/setup_ntp/templates/chrony.conf.j2 | 20 ++ roles/setup_selfsigned_cert/defaults/main.yml | 17 ++ roles/setup_selfsigned_cert/meta/main.yml | 2 + roles/setup_selfsigned_cert/tasks/main.yml | 100 ++++++ roles/setup_sushy_tools/defaults/main.yml | 1 + roles/setup_sushy_tools/tasks/main.yml | 35 +++ .../templates/sushy-emulator.conf.j2 | 161 ++++++++++ .../templates/sushy-tools.service.j2 | 13 + roles/validate_dns_records/defaults/main.yml | 6 + roles/validate_dns_records/meta/main.yml | 2 + roles/validate_dns_records/tasks/main.yml | 19 ++ roles/validate_http_store/defaults/main.yml | 3 + roles/validate_http_store/meta/main.yml | 2 + roles/validate_http_store/tasks/main.yml | 30 ++ .../templates/test_file.j2 | 1 + roles/validate_inventory/defaults/main.yml | 20 ++ roles/validate_inventory/tasks/ai.yml | 30 ++ roles/validate_inventory/tasks/cluster.yml | 65 ++++ roles/validate_inventory/tasks/day2.yml | 5 + roles/validate_inventory/tasks/dns.yml | 21 ++ roles/validate_inventory/tasks/main.yml | 41 +++ roles/validate_inventory/tasks/network.yml | 21 ++ roles/validate_inventory/tasks/prereqs.yml | 6 + roles/validate_inventory/tasks/secrets.yml | 47 +++ site.yml | 8 + tests/run_tests.yml | 2 + .../roles/run_suite/tasks/main.yml | 40 +++ .../roles/run_suite/tasks/run_test.yml | 13 + tests/validate_inventory/suites/cluster.yml | 59 ++++ tests/validate_inventory/suites/day2.yml | 15 + tests/validate_inventory/suites/network.yml | 14 + tests/validate_inventory/suites/prereqs.yml | 35 +++ tests/validate_inventory/suites/secrets.yml | 60 ++++ .../templates/all_reachable.yml | 41 +++ .../templates/all_unreachable.yml | 40 +++ .../templates/invalid_empty_var.yml | 25 ++ .../templates/invalid_missing_var.yml | 24 ++ .../templates/ntp_unreachable.yml | 40 +++ .../templates/test_inv.yml.j2 | 58 ++++ .../templates/test_inv_secrets.yml.j2 | 42 +++ tests/validate_inventory/tests.yml | 25 ++ 224 files changed, 7051 insertions(+) create mode 100644 .ansible-lint create mode 100644 .github/ISSUE_TEMPLATE/bug-report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature-proposal.yml create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ansible-lint.yml create mode 100644 .github/workflows/broken-link-check.yml create mode 100644 .github/workflows/crucible-tests.yml create mode 100644 .github/workflows/merge-requirements.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .yaspeller.json create mode 100644 CODEOWNERS create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 ansible.cfg create mode 100644 deploy_cluster.yml create mode 100644 deploy_day2_workers.yml create mode 100644 deploy_prerequisites.yml create mode 100644 docs/connecting_to_hosts.md create mode 100644 docs/images/simple_kvm.drawio create mode 100644 docs/images/simple_kvm.png create mode 100644 docs/images/simple_kvm_physical.drawio create mode 100644 docs/images/simple_kvm_physical.png create mode 100644 docs/images/vm_host_interfaces.drawio create mode 100644 docs/images/vm_host_interfaces.png create mode 100644 docs/inventory.md create mode 100644 docs/pipeline_into_the_details.md create mode 100644 docs/troubleshooting/discovery_iso_not_booting.md create mode 100644 inventory.vault.yml.sample create mode 100644 inventory.yml.sample create mode 100644 playbooks/boot_disk.yml create mode 100644 playbooks/boot_iso.yml create mode 100644 playbooks/create_vms.yml create mode 100644 playbooks/dell_idrac_soft_reset.yml create mode 100644 playbooks/deploy_assisted_installer_onprem.yml create mode 100644 playbooks/deploy_dns.yml create mode 100644 playbooks/deploy_http_store.yml create mode 100644 playbooks/deploy_registry.yml create mode 100644 playbooks/deploy_sushy_tools.yml create mode 100644 playbooks/destroy_vms.yml create mode 100644 playbooks/generate_discovery_iso.yml create mode 100644 playbooks/generate_ssh_key_pair.yml create mode 100644 playbooks/install_cluster.yml create mode 100644 playbooks/monitor_cluster.yml create mode 100644 playbooks/validate_inventory.yml create mode 100644 post_install.yml create mode 100644 prereq_facts_check.yml create mode 100644 requirements.txt create mode 100644 requirements.yml create mode 100644 roles/add_day2_node/defaults/main.yml create mode 100644 roles/add_day2_node/tasks/main.yml create mode 100644 roles/approve_csrs/defaults/main.yml create mode 100644 roles/approve_csrs/tasks/main.yml create mode 100644 roles/boot_disk/defaults/main.yml create mode 100644 roles/boot_disk/meta/main.yml create mode 100644 roles/boot_disk/tasks/dell.yml create mode 100644 roles/boot_disk/tasks/dell_redfish.yml create mode 100644 roles/boot_disk/tasks/kvm.yml create mode 100644 roles/boot_disk/tasks/lenovo.yml create mode 100644 roles/boot_disk/tasks/main.yml create mode 100644 roles/boot_disk/tasks/supermicro.yml create mode 100644 roles/boot_iso/defaults/main.yml create mode 100644 roles/boot_iso/handlers/main.yml create mode 100644 roles/boot_iso/meta/main.yml create mode 100644 roles/boot_iso/tasks/dell.yml create mode 100644 roles/boot_iso/tasks/dell_idrac.yml create mode 100644 roles/boot_iso/tasks/dell_redfish.yml create mode 100644 roles/boot_iso/tasks/hpe.yml create mode 100644 roles/boot_iso/tasks/kvm.yml create mode 100644 roles/boot_iso/tasks/lenovo.yml create mode 100644 roles/boot_iso/tasks/main.yml create mode 100644 roles/boot_iso/tasks/supermicro.yml create mode 100644 roles/boot_iso/templates/vitual_media.json.j2 create mode 100644 roles/boot_iso/tests/inventory create mode 100644 roles/boot_iso/tests/test.yml create mode 100644 roles/boot_iso/vars/main.yml create mode 100644 roles/create_cluster/defaults/main.yml create mode 100644 roles/create_cluster/meta/main.yml create mode 100644 roles/create_cluster/tasks/main.yml create mode 100644 roles/create_cluster/tasks/manifest.yml create mode 100644 roles/create_cluster/templates/01-master-node-scheduler.yml.j2 create mode 100644 roles/create_cluster/templates/02-fix-ingress-config.yml.j2 create mode 100644 roles/create_cluster/templates/50-worker-nm-fix-ipv6.yml.j2 create mode 100644 roles/create_cluster/templates/50-worker-remove-ipi-leftovers.yml.j2 create mode 100644 roles/create_cluster/templates/patch-discovery-ignition.j2 create mode 100644 roles/create_cluster/templates/patch-install-config.j2 create mode 100644 roles/create_cluster/templates/patch-network-type.j2 create mode 100644 roles/create_cluster/templates/patch-search-registries.j2 create mode 100644 roles/create_cluster/tests/inventory create mode 100644 roles/create_cluster/tests/test.yml create mode 100644 roles/create_cluster/vars/main.yml create mode 100644 roles/create_day2_cluster/defaults/main.yml create mode 100644 roles/create_day2_cluster/tasks/main.yml create mode 100644 roles/create_vms/defaults/main.yml create mode 100644 roles/create_vms/meta/main.yml create mode 100644 roles/create_vms/tasks/main.yml create mode 100644 roles/create_vms/tasks/prepare_bridges.yml create mode 100644 roles/create_vms/tasks/prepare_firewall.yml create mode 100644 roles/create_vms/tasks/prepare_network.yml create mode 100644 roles/create_vms/tasks/prepare_storage_pool.yml create mode 100644 roles/create_vms/tasks/provision_vms.yml create mode 100644 roles/create_vms/templates/create_vm.sh.j2 create mode 100644 roles/create_vms/templates/network.xml.j2 create mode 100644 roles/create_vms/templates/rng_device.xml.j2 create mode 100644 roles/create_vms/templates/storage-pool.xml.j2 create mode 100644 roles/destroy_vms/defaults/main.yml create mode 100644 roles/destroy_vms/meta/main.yml create mode 100644 roles/destroy_vms/tasks/destroy_pools.yml create mode 100644 roles/destroy_vms/tasks/destroy_vms.yml create mode 100644 roles/destroy_vms/tasks/main.yml create mode 100644 roles/generate_discovery_iso/defaults/main.yml create mode 100644 roles/generate_discovery_iso/handlers/main.yml create mode 100644 roles/generate_discovery_iso/meta/main.yml create mode 100644 roles/generate_discovery_iso/tasks/main.yml create mode 100644 roles/generate_discovery_iso/tasks/static.yml create mode 100644 roles/generate_discovery_iso/templates/nmstate.yml.j2 create mode 100644 roles/generate_discovery_iso/tests/inventory create mode 100644 roles/generate_discovery_iso/tests/test.yml create mode 100644 roles/generate_discovery_iso/vars/main.yml create mode 100644 roles/generate_ssh_key_pair/defaults/main.yml create mode 100644 roles/generate_ssh_key_pair/meta/main.yml create mode 100644 roles/generate_ssh_key_pair/tasks/main.yml create mode 100644 roles/get_image_hash/README.md create mode 100644 roles/get_image_hash/defaults/main.yml create mode 100644 roles/get_image_hash/meta/main.yml create mode 100644 roles/get_image_hash/tasks/get_image_hash.yml create mode 100644 roles/get_image_hash/tasks/main.yml create mode 100644 roles/insert_dns_records/README.md create mode 100644 roles/insert_dns_records/defaults/main.yml create mode 100644 roles/insert_dns_records/files/nm-dnsmasq.conf create mode 100644 roles/insert_dns_records/handlers/main.yml create mode 100644 roles/insert_dns_records/meta/main.yml create mode 100644 roles/insert_dns_records/tasks/configure_firewall.yml create mode 100644 roles/insert_dns_records/tasks/dnsmasq.yml create mode 100644 roles/insert_dns_records/tasks/main.yml create mode 100644 roles/insert_dns_records/tasks/network-manager.yml create mode 100644 roles/insert_dns_records/templates/nm-dnsmasq.conf.j2 create mode 100644 roles/insert_dns_records/templates/openshift-cluster.conf.j2 create mode 100644 roles/install_cluster/defaults/main.yml create mode 100644 roles/install_cluster/meta/main.yml create mode 100644 roles/install_cluster/tasks/hosts_discovery.yml create mode 100644 roles/install_cluster/tasks/main.yml create mode 100644 roles/install_cluster/tests/inventory create mode 100644 roles/install_cluster/tests/test.yml create mode 100644 roles/monitor_cluster/defaults/main.yml create mode 100644 roles/monitor_cluster/meta/main.yml create mode 100644 roles/monitor_cluster/tasks/main.yml create mode 100644 roles/monitor_host/defaults/main.yml create mode 100644 roles/monitor_host/meta/main.yml create mode 100644 roles/monitor_host/tasks/hosts_monitoring.yml create mode 100644 roles/monitor_host/tasks/main.yml create mode 100644 roles/populate_mirror_registry/defaults/main.yml create mode 100644 roles/populate_mirror_registry/meta/main.yml create mode 100644 roles/populate_mirror_registry/tasks/main.yml create mode 100644 roles/populate_mirror_registry/tasks/populate_registry.yml create mode 100644 roles/populate_mirror_registry/tasks/prerequisites.yml create mode 100644 roles/populate_mirror_registry/tasks/var_check.yml create mode 100644 roles/prereq_facts_check/README.md create mode 100644 roles/prereq_facts_check/defaults/main.yml create mode 100644 roles/prereq_facts_check/meta/main.yml create mode 100644 roles/prereq_facts_check/tasks/main.yml create mode 100644 roles/setup_assisted_installer/defaults/main.yml create mode 100644 roles/setup_assisted_installer/meta/main.yml create mode 100644 roles/setup_assisted_installer/tasks/main.yml create mode 100644 roles/setup_assisted_installer/templates/nginx-ui.conf create mode 100644 roles/setup_assisted_installer/templates/onprem-environment.j2 create mode 100644 roles/setup_http_store/README.md create mode 100644 roles/setup_http_store/defaults/main.yml create mode 100644 roles/setup_http_store/meta/main.yml create mode 100644 roles/setup_http_store/tasks/main.yml create mode 100644 roles/setup_mirror_registry/defaults/main.yml create mode 100644 roles/setup_mirror_registry/meta/main.yml create mode 100644 roles/setup_mirror_registry/tasks/main.yml create mode 100644 roles/setup_mirror_registry/tasks/prerequisites.yml create mode 100644 roles/setup_mirror_registry/tasks/retrieve_config.yml create mode 100644 roles/setup_mirror_registry/tasks/setup_registry.yml create mode 100644 roles/setup_mirror_registry/tasks/var_check.yml create mode 100644 roles/setup_ntp/defaults/main.yml create mode 100644 roles/setup_ntp/handlers/main.yml create mode 100644 roles/setup_ntp/meta/main.yml create mode 100644 roles/setup_ntp/tasks/main.yml create mode 100644 roles/setup_ntp/templates/chrony.conf.j2 create mode 100644 roles/setup_selfsigned_cert/defaults/main.yml create mode 100644 roles/setup_selfsigned_cert/meta/main.yml create mode 100644 roles/setup_selfsigned_cert/tasks/main.yml create mode 100644 roles/setup_sushy_tools/defaults/main.yml create mode 100644 roles/setup_sushy_tools/tasks/main.yml create mode 100644 roles/setup_sushy_tools/templates/sushy-emulator.conf.j2 create mode 100644 roles/setup_sushy_tools/templates/sushy-tools.service.j2 create mode 100644 roles/validate_dns_records/defaults/main.yml create mode 100644 roles/validate_dns_records/meta/main.yml create mode 100644 roles/validate_dns_records/tasks/main.yml create mode 100644 roles/validate_http_store/defaults/main.yml create mode 100644 roles/validate_http_store/meta/main.yml create mode 100644 roles/validate_http_store/tasks/main.yml create mode 100644 roles/validate_http_store/templates/test_file.j2 create mode 100644 roles/validate_inventory/defaults/main.yml create mode 100644 roles/validate_inventory/tasks/ai.yml create mode 100644 roles/validate_inventory/tasks/cluster.yml create mode 100644 roles/validate_inventory/tasks/day2.yml create mode 100644 roles/validate_inventory/tasks/dns.yml create mode 100644 roles/validate_inventory/tasks/main.yml create mode 100644 roles/validate_inventory/tasks/network.yml create mode 100644 roles/validate_inventory/tasks/prereqs.yml create mode 100644 roles/validate_inventory/tasks/secrets.yml create mode 100644 site.yml create mode 100644 tests/run_tests.yml create mode 100644 tests/validate_inventory/roles/run_suite/tasks/main.yml create mode 100644 tests/validate_inventory/roles/run_suite/tasks/run_test.yml create mode 100644 tests/validate_inventory/suites/cluster.yml create mode 100644 tests/validate_inventory/suites/day2.yml create mode 100644 tests/validate_inventory/suites/network.yml create mode 100644 tests/validate_inventory/suites/prereqs.yml create mode 100644 tests/validate_inventory/suites/secrets.yml create mode 100644 tests/validate_inventory/templates/all_reachable.yml create mode 100644 tests/validate_inventory/templates/all_unreachable.yml create mode 100644 tests/validate_inventory/templates/invalid_empty_var.yml create mode 100644 tests/validate_inventory/templates/invalid_missing_var.yml create mode 100644 tests/validate_inventory/templates/ntp_unreachable.yml create mode 100644 tests/validate_inventory/templates/test_inv.yml.j2 create mode 100644 tests/validate_inventory/templates/test_inv_secrets.yml.j2 create mode 100644 tests/validate_inventory/tests.yml diff --git a/.ansible-lint b/.ansible-lint new file mode 100644 index 00000000..37da5f52 --- /dev/null +++ b/.ansible-lint @@ -0,0 +1,10 @@ +skip_list: + - '106' + - '204' + - '207' + - '208' + - '301' + - '306' + - '601' + - '602' + - '701' diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 00000000..5041ceb4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,67 @@ +name: Bug report +description: File a bug report +labels: [bug] +body: + - type: textarea + id: bug_description + attributes: + label: Bug description + description: Please provide as much information as possible to describe the bug, your environment, steps to reproduce, the actual and the expected behaviour. + validations: + required: true + - type: dropdown + id: openshift_version + attributes: + label: OpenShift version + description: What version of OpenShift was chosen to be deployed by the automation suite? + options: + - "4.6" + - "4.7" + - "4.8" + - "other (provide in the description)" + validations: + required: true + - type: dropdown + id: assisted_installer_version + attributes: + label: Assisted Installer version + description: What version of Assisted Installer was chosen to be installed by the automation suite? + options: + - "v1.0.18.3" + - "v1.0.19.3" + - "v1.0.20.3" + - "v1.0.21.3" + - "v1.0.22.3" + - "v1.0.23.2" + - "v1.0.24.2" + - "other (provide in the description)" + validations: + required: true + - type: textarea + id: logs_output + attributes: + label: Relevant log output + description: | + Please copy and paste any relevant log output produced by the automation suite. Remember to **remove all sensitive details** from the pasted logs. + This field will be automatically formatted into code. + render: shell + - type: textarea + id: inventory_file + attributes: + label: Inventory file + description: | + Please copy and paste the inventory file used with the automation suite. Remember to **remove all sensitive details** from the pasted inventory file. + This field will be automatically formatted as yaml. + render: yaml + - type: checkboxes + id: terms_limited_support + attributes: + label: Required statements + description: Before submitting this issue, you must agree to the following statements. + options: + - label: I have removed all sensitive details from the attached logs and inventory files. + required: true + - label: I acknowledge that Red Hat does not provide commercial support for the content of this repository. + required: true + - label: I acknowledge that any assistance is offered purely on a best-effort basis, as resource permits. + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..3ba13e0c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature-proposal.yml b/.github/ISSUE_TEMPLATE/feature-proposal.yml new file mode 100644 index 00000000..b263ec88 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-proposal.yml @@ -0,0 +1,23 @@ +name: Feature proposal +description: Submit a feature proposal +labels: [enhancement] +body: + - type: textarea + id: feature_description + attributes: + label: Feature description + description: | + Please describe the proposed feature in as much detail as possible. + In addition, provide justification as to why the feature should be implemented. + validations: + required: true + - type: checkboxes + id: terms_limited_support + attributes: + label: Required statements + description: Before submitting this issue, you must agree to the following statements. + options: + - label: I acknowledge that Red Hat does not provide commercial support for the content of this repository. + required: true + - label: I acknowledge that any assistance is offered purely on a best-effort basis, as resource permits. + required: true diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..e97c63fa --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "pip" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "daily" + + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + commit-message: + prefix: build + prefix-development: chore + include: scope diff --git a/.github/workflows/ansible-lint.yml b/.github/workflows/ansible-lint.yml new file mode 100644 index 00000000..d0fdbd9e --- /dev/null +++ b/.github/workflows/ansible-lint.yml @@ -0,0 +1,76 @@ +name: Ansible Lint # feel free to pick your own name + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + + + steps: + # Important: This sets up your GITHUB_WORKSPACE environment variable + - uses: actions/checkout@v2 + + - uses: actions/cache@v2.1.6 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt') }} + restore-keys: | + ${{ runner.os }}-pip- + + - uses: actions/setup-python@v2.2.2 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + if [ -f test-requirements.txt ]; then pip install -r test-requirements.txt; fi + + + - name: Lint Ansible Playbook + # replace "master" with any valid ref + uses: ansible/ansible-lint-action@master + with: + # [required] + # Paths to ansible files (i.e., playbooks, tasks, handlers etc..) + # or valid Ansible directories according to the Ansible role + # directory structure. + # If you want to lint multiple ansible files, use the following syntax + # targets: | + # playbook_1.yml + # playbook_2.yml + targets: | + boot_iso.yml + deploy_cluster.yml + generate_rwn_iso.yml + install_cluster.yml + + # [optional] + # Arguments to override a package and its version to be set explicitly. + # Must follow the example syntax. + + # [optional] + # Arguments to be passed to the ansible-lint + + # Options: + # -q quieter, although not silent output + # -p parseable output in the format of pep8 + # --parseable-severity parseable output including severity of rule + # -r RULESDIR specify one or more rules directories using one or + # more -r arguments. Any -r flags override the default + # rules in ansiblelint/rules, unless -R is also used. + # -R Use default rules in ansiblelint/rules in addition to + # any extra + # rules directories specified with -r. There is no need + # to specify this if no -r flags are used + # -t TAGS only check rules whose id/tags match these values + # -x SKIP_LIST only check rules whose id/tags do not match these + # values + # --nocolor disable colored output + # --exclude=EXCLUDE_PATHS + # path to directories or files to skip. This option is + # repeatable. + # -c C Specify configuration file to use. Defaults to ".ansible-lint" + args: "" diff --git a/.github/workflows/broken-link-check.yml b/.github/workflows/broken-link-check.yml new file mode 100644 index 00000000..c88b7e48 --- /dev/null +++ b/.github/workflows/broken-link-check.yml @@ -0,0 +1,16 @@ +on: + schedule: + - cron: "0 0 * * *" # daily + repository_dispatch: # run manually + types: [check-link] + # push: + # ... + +name: Broken Link Check +jobs: + check: + name: Broken Link Check + runs-on: ubuntu-latest + steps: + - name: Broken Link Check + uses: technote-space/broken-link-checker-action@v2.2.9 diff --git a/.github/workflows/crucible-tests.yml b/.github/workflows/crucible-tests.yml new file mode 100644 index 00000000..d5f77e7e --- /dev/null +++ b/.github/workflows/crucible-tests.yml @@ -0,0 +1,19 @@ +name: Crucible tests + +on: [push, pull_request] + +jobs: + run-tests: + name: Run tests + runs-on: ubuntu-20.04 + + steps: + - uses: actions/checkout@v2 + + - name: Install required dependencies + run: | + pip install ansible + pip install -r requirements.txt + + - name: Run all tests using Ansible + run: ansible-playbook tests/run_tests.yml diff --git a/.github/workflows/merge-requirements.yml b/.github/workflows/merge-requirements.yml new file mode 100644 index 00000000..0164fdb1 --- /dev/null +++ b/.github/workflows/merge-requirements.yml @@ -0,0 +1,14 @@ +name: Merge requirements + +on: [pull_request] + +jobs: + no-fixup-commits-check: + name: All fixup commits are squashed + runs-on: ubuntu-20.04 + + steps: + - uses: actions/checkout@v2 + + - name: Test if all fixup commits are squashed + uses: 13rac1/block-fixup-merge-action@v2.0.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..06048388 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +.DS_Store +*.txt +*.pub +!requirements.txt +__stuff/** +.history/ +.vscode +te?mp +logs? +*.swp +*.pyc +inventory.yml +*.vault.yml +*.vault.yaml +fetched/ + +tests/validate_inventory/failed diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..6248cfa6 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,20 @@ +repos: + - repo: meta + hooks: + - id: check-useless-excludes + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.0.1 + hooks: + - id: check-added-large-files + - id: check-case-conflict + - id: check-executables-have-shebangs + - id: check-json + - id: check-merge-conflict + - id: check-symlinks + - id: check-yaml + args: ['--unsafe'] + - id: detect-private-key + - id: end-of-file-fixer + - id: requirements-txt-fixer + - id: trailing-whitespace diff --git a/.yaspeller.json b/.yaspeller.json new file mode 100644 index 00000000..30d19833 --- /dev/null +++ b/.yaspeller.json @@ -0,0 +1,21 @@ +{ + "ignoreUrls": true, + "findRepeatWords": true, + "maxRequests": 5, + "ignoreDigits": true, + "lang": "en", + "dictionary": [ + "OpenShift", + "yml", + "Playbook", + "Ansible", + "Nginx", + "ArgoCD", + "settable", + "hostvars", + "vars", + "endpoint", + "playbook", + "Repo" + ] +} diff --git a/CODEOWNERS b/CODEOWNERS new file mode 100644 index 00000000..72851521 --- /dev/null +++ b/CODEOWNERS @@ -0,0 +1,9 @@ +# https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/creating-a-repository-on-github/about-code-owners + +# This file lists active Maintainers of Crucible. See CONTRIBUTING.md for +# more information on receiving Code Reviews from Maintainers. + +# These owners will be the default owners for everything in the repository. +# Unless a later match takes precedence, they will be requested for review +# when someone opens a Pull Request. +* @redhat-partner-solutions/red-hat-crucible diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..9cd7654b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,79 @@ +# Contributing to Crucible + +All contributions are valued and welcomed, whether they come in the form of code, documentation, ideas or discussion. +While we have not applied a formal Code of Conduct to this, or related, repositories, we require that all contributors +conduct themselves in a professional and respectful manner. + +## Issues + +The easiest way to contribute to Crucible is through Issues. This could be by making a suggestion, reporting a +bug, or helping another user. + +### Suggestions + +To make a suggestion open an Issue in the GitHub repository describing what feature/change you think is needed, why, and +if possible give an example. + +### Bug Reports + +> ❗ _Red Hat does not provide commercial support for the content of this repo. Any assistance is purely on a best-effort basis, as resource permits._ + +If you encounter a bug then carefully examine the output. If you choose to open an issue then please include as much +information about the problem as possible as this gives the best chance someone can help. We suggest: + +- A description of your target environment +- A copy of your inventory +- Verbose Ansible logs (`-v[v[v[v]]]`) + +**This may include data you do not wish to share publicly.** In this case a more private forum is suggested. + +## Workflow + +The required workflow for making a contribution is Fork-and-Pull. This is well documented elsewhere but to summarise: + +1. Create a fork of this repository. +1. Make and test the change on your fork. +1. Submit a Pull Request asking for the change to be merged into the main repository. + +How to create and update a fork is outside the scope of this document but there are plenty of +[in-depth](https://gist.github.com/Chaser324/ce0505fbed06b947d962) +[instructions](https://reflectoring.io/github-fork-and-pull/) explaining how to go about it. + +All contributions should must have as much test coverage as possible and include relevent additions and changes to both +documentation and tooling. Once a change is implemented, tested, documented, and passing all checks then submit a Pull +Request for it to be reviewed. + +## Peer review + +At least two maintainers must "Accept" a Pull Request prior to merging a Pull Request. No Self Review is allowed. The +maintainers of Crucible are: + +- Micky Costa (nocturnalastro) +- Arnaldo Hernandez (arjuhe) +- Charlie Wheeler-Robinson (crwr45) +- Marek Kochanowski (mkochanowski) + +All contributors are strongly encouraged to review Pull Requests. Everyone is responsible for the quality of what is +produced, and review is also an excellent opportunity to learn. + +## Commits and Pull Requests + +A good commit does a *single* thing, does it completely, concisely, and describes *why*. + +The commit message should explain both what is being changed, and in the case of anything non-obvious why that change +was made. Commit messages are something that has been extensively written about so need not be discussed in more detail +here but contributors should follow [these seven rules](https://chris.beams.io/posts/git-commit/#seven-rules) and keep +individual commits focussed. + +A good Pull Request is the same; it also does a *single* thing, does it completely, and describes *why*. The difference +is that a Pull Request may contain one or more commits that together prepare for and deliver a feature. + +Instructions on how to restructure commits to create a clear and understandable set of changes is outside the scope of +this document but it's a useful skill and there are [many](https://thoughtbot.com/blog/autosquashing-git-commits) +[guides](https://git-scm.com/docs/git-rebase) and [approaches](https://nuclearsquid.com/writings/git-add/) for to do it. + +## Style Guidelines + +- Favour readability over brevity in both naming and structure +- Document the _why_ with comments, and the _what_ with clear code +- As far as Ansible allows, encapsulate diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 00000000..126919b4 --- /dev/null +++ b/README.md @@ -0,0 +1,199 @@ +# Crucible: OpenShift 4 Management Cluster Seed Playbooks + +> ❗ _Red Hat does not provide commercial support for the content of this repo. Any assistance is purely on a best-effort basis, as resource permits._ + +```bash +############################################################################## +DISCLAIMER: THE CONTENT OF THIS REPO IS EXPERIMENTAL AND PROVIDED "AS-IS" + +THE CONTENT IS PROVIDED AS REFERENCE WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +############################################################################## +``` + +This repository contains playbooks for automating the creation of an OpenShift Container Platform cluster on premise using the Developer Preview version of the OpenShift Assisted Installer. The playbooks require only minimal infrastructure configuration and do not require any pre-existing cluster. Virtual and Bare Metal deployments have been tested in restricted network environments where nodes do not have direct access to the Internet. + +These playbooks are intended to be run from a `bastion` host, running a subscribed installation of RHEL 8.4, inside the target environment. Pre-requisites can be installed manually or automatically, as appropriate. + +See [how the playbooks are intended to be run](docs/connecting_to_hosts.md) and understand [what steps the playbooks take](docs/pipeline_into_the_details.md). + +## OpenShift Versions Tested + +- 4.6 +- 4.7 +- 4.8 + +## Assisted Installer versions Tested + +- v1.0.18.3 +- v1.0.19.3 +- v1.0.20.3 +- v1.0.21.3 +- v1.0.22.3 +- v1.0.23.2 +- v1.0.24.2 + +### Dependencies + +Requires the following to be installed on the deployment host: + +- [Ansible](https://docs.ansible.com/ansible/latest/installation_guide/intro_installation.html#installing-ansible-on-specific-operating-systems) +- [netaddr](https://github.com/netaddr/netaddr) +- [skopeo](https://github.com/containers/skopeo) + +```bash +dnf install ansible python3-netaddr skopeo +``` + +There's also some required Ansible modules that can be installed with the following command: + +```bash +ansible-galaxy collection install -r requirements.yml +``` + +## Before Running The Playbook + +- Configure NTP time sync on the BMCs and confirm the system clock among the master nodes is synchronized within a second. The installation fails when system time does not match among nodes because etcd database will not be able to converge. +- Modify the provided inventory file `inventory.yml.sample`. Fill in the appropriate values that suit your environment and deployment requirements. See the sample file and [docs/inventory.md](docs/inventory.md) for more details. +- Modify the provided inventory vault file `inventory.vault.yml.sample`. Fill in the corresponding secret values according to the configuration of the inventory file. See the sample file and [docs/inventory.md#required-secrets](docs/inventory.md#required-secrets) for more details. +- Place the following prerequisites in this directory: + - OpenShift pull secret stored as `pull-secret.txt` (can be downloaded from [here](https://console.redhat.com/openshift/install/metal/installer-provisioned)) + - SSH Public Key stored as `ssh_public_key.pub` + - If `deploy_prerequisites.yml` is NOT being used; SSL self-signed certificate stored as `mirror_certificate.txt` + +### Inventory Vault File Management + +The inventory vault files should be encrypted and protected at all times, as they may contain secret values and sensitive information. + +To encrypt a vault file named `inventory.vault.yml`, issue the following command. + +```bash +ansible-vault encrypt inventory.vault.yml +``` + +An encrypted vault file can be referenced when executing the playbooks with the `ansible-playbook` command. +To that end, provide the option `-e "@{PATH_TO_THE_VAULT_FILE}"`. + +To allow Ansible to read values from an encrypted vault file, a password for decrypting the vault must be provided. Provide the `--ask-vault-pass` flag to force Ansible to ask for a password to the vault before the selected playbook is executed. + +A complete command to execute a playbook that takes advantage of both options can look like this: +```bash +ansible-playbook -i inventory ${SELECTED_PLAYBOOK} -e "@inventory.vault.yml" --ask-vault-pass +``` + +If a need arises to decrypt an encrypted vault file, issue the following command. + +```bash +ansible-vault decrypt inventory.vault.yml +``` + +For more information on working with vault files, see the [Ansible Vault documentation](https://docs.ansible.com/ansible/latest/user_guide/vault.html#encrypting-content-with-ansible-vault). + +### Pre-Deployment Validation + +Some utility playbooks are provided to perform some validation before attempting a deployment: + +```bash +ansible-playbook -i inventory prereq_facts_check.yml -e "@inventory.vault.yml" --ask-vault-pass +ansible-playbook -i inventory playbooks/validate_inventory.yml -e "@inventory.vault.yml" --ask-vault-pass +``` + +## Running The Playbooks + +There are a few main playbooks provided in this repository: + +- `deploy_prerequisites.yml`: sets up the services required by Assisted Installer, and an Assisted Installer configured to use them. +- `deploy_cluster.yml`: uses Assisted Installed to deploy a cluster +- `post_install.yml`: fetches the `kubeconfig` for the deployed cluster and places it on the bastion host. +- `site.yml` simply runs all three in order. + +Each of the playbooks requires only an inventory, and can be run like this: + +```bash +ansible-playbook -i inventory site.yml -e "@inventory.vault.yml" --ask-vault-pass +``` + +## Prerequisite Services + +Crucible can automatically set up the services required to deploy and run a cluster. Some are required for the Assisted Installer tool to run, and some are needed for the resulting cluster. + +- NTP - The NTP service helps to ensure clocks are synchronised across the resulting cluster which is a requirement for the cluster to function. +- Container Registry Local Mirror - Provides a local container registry within the target environment. The Crucible playbooks automatically populates the registry with required images for cluster installation. The registry will continue to be used by the resulting cluster. +- HTTP Store - Used to serve the Assisted Installer discovery ISO and allow it to be used as Virtual Media for nodes to boot from. +- DNS - Optionally set up DNS records for the required cluster endpoints, and nodes. If not automatically set up then the existing configuration will be validated. +- Assisted Installer - A pod running the Assisted Installer service, database store and UI. It will be configured for the target environment and is used by the cluster deployment playbook to coordinate the cluster deployment. + +While setup of each of these can be disabled if you wish to manually configure them, but it's highly recommended to use the automatic setup of all prerequisites. + +## Outputs + +Note that the exact changes made depend on which playbooks or roles are run, and the specific configuration. + +### Cluster + +The obvious output from these playbooks is a clean OCP cluster with minimal extra configuration. Each node that has been added to the resulting cluster will have: + +- CoreOS installed and configured +- The configured SSH public key as an authorised key for `root` to allow debugging + +### Prerequisite Services + +Various setup is done on the prerequisite services. These are informational and are not needed unless you encounter issues with deployment. +The following are defaults for a full setup: + +- Registry Host + + - `opt/registry` contains the files for the registry, including the certificates. + - `tmp/wip` is used during the playbook execution as a temporary file store. + +- DNS Host + + - Using dnsmasq: `/etc/dnsmasq.d/dnsmasq..conf` + - using Network Manager: `/etc/NetworkManager/dnsmasq.d/dnsmasq..conf` and `/etc/NetworkManager/conf.d/dnsmasq.conf` + +- Assisted Installer + + - A running pod containing the Assisted Installer service. + - `/opt/assisted-installer` contains all the files used by the Assisted Installer container + +- HTTP Store + - A running pod containing the `httpd` service + - The discovery image from Assisted Installer will be placed in and served from `/opt/http_store/data` + +### Bastion + +As well as deploying prerequisites and a cluster, the playbooks create or update various local artifacts in the repository root and the `fetched/` directory (configured with `fetched_dest` var in the inventory). + +- An updated `pull-secret.txt` containing an additional secret to authenticate with the deployed registry. +- The self-signed certificate created for the registry host as `domain.crt`. +- The SSH public and private keys generated for access to the nodes, if any, at `/home/redhat/ssh_keys` (temporarily stored in `/tmp/ssh_key_pair`) +- Any created CoreOS ignition files. + +When doing multiple runs ensure you retain any authentication artefacts you need between deploys. + +## Testing + +Existing tests can be run using + +```bash +ansible-playbook tests/run_tests.yml +``` + +## Related Documentation + +### General + +- [How the playbooks are intended to be run](docs/connecting_to_hosts.md) +- [How to configure the inventory file](docs/inventory.md) +- [Steps the playbooks take when executed](docs/pipeline_into_the_details.md) + +### Troubleshooting + +Some useful help for troubleshooting if you find any issues can be found in [docs/troubleshooting](docs/troubleshooting) + +- [Discovery ISO not booting](docs/troubleshooting/discovery_iso_not_booting.md) + +## References + +This software was adapted from [sonofspike/cluster_mgnt_roles](https://github.com/sonofspike/cluster_mgnt_roles) diff --git a/ansible.cfg b/ansible.cfg new file mode 100644 index 00000000..7444df0a --- /dev/null +++ b/ansible.cfg @@ -0,0 +1,16 @@ +[defaults] +host_key_checking = False +inventory = ./inventory +roles_path = ./roles +library = library +remote_tmp = /tmp +command_warnings = True +ansible_managed = "This file is managed by Ansible - changes may be lost" +retry_files_enabled = False +forks = 4 +stdout_callback = yaml +bin_ansible_callbacks = True +callback_whitelist = debug,profile_tasks + +[privilege_escalation] +become_method = sudo diff --git a/deploy_cluster.yml b/deploy_cluster.yml new file mode 100644 index 00000000..0edc3eb4 --- /dev/null +++ b/deploy_cluster.yml @@ -0,0 +1,86 @@ +--- +- name: Generate ssh keys used for debug + hosts: bastion + vars: + GENERATE_SSH_KEYS: "{{ generate_ssh_keys | default(True) }}" + roles: + - role: generate_ssh_key_pair + when: GENERATE_SSH_KEYS == True + +- name: Create cluster and generate Assisted Installer Discovery ISO + hosts: bastion + gather_facts: False + roles: + - create_cluster + - generate_discovery_iso + vars: + - generate: True + - download: True + - disconnected: "{{ use_local_mirror_registry | default(setup_registry_service | default(true)) }}" + - CLUSTER_NAME: "{{ cluster_name }}" + - CLUSTER_ID: "{{ cluster_id }}" + - BASE_DNS_DOMAIN: "{{ base_dns_domain }}" + - ASSISTED_INSTALLER_HOST: "{{ hostvars['assisted_installer']['host'] }}" + - ASSISTED_INSTALLER_PORT: "{{ hostvars['assisted_installer']['port'] }}" + - SSH_PUBLIC_KEY: "{{ ssh_public_key }}" + - PULL_SECRET: "{{ hostvars['registry_host']['pull_secret'] | default(pull_secret) }}" + - CLUSTER_NETWORK_CIDR: "{{ cluster_network_cidr }}" + - CLUSTER_NETWORK_HOST_PREFIX: "{{ cluster_network_host_prefix }}" + - SERVICE_NETWORK_CIDR: "{{ service_network_cidr }}" + - OPENSHIFT_VERSION: "{{ openshift_version }}" + - VIP_DHCP_ALLOCATION: "{{ vip_dhcp_allocation }}" + - INGRESS_VIP: "{{ ingress_vip }}" + - API_VIP: "{{ api_vip }}" + - MACHINE_NETWORK_CIDR: "{{ machine_network_cidr }}" + - DOWNLOAD_DEST_FILE: "{{ discovery_iso_name }}" + - DOWNLOAD_DEST_PATH: "{{ iso_download_dest_path | default('/opt/http_store/data') }}" + - NTP_SERVER: "{{ ntp_server }}" + +- name: Mounting, Booting the Assisted Installer Discovery ISO + hosts: masters, workers + gather_facts: False + strategy: "{{ use_boot_iso_strategy_free | default(True) | bool | ternary('free', omit) }}" + serial: "{{ use_boot_iso_strategy_free | default(True) | bool | ternary(omit, 1) }}" + roles: + - boot_iso + vars: + - debug: False + - boot_iso_url: "{{ discovery_iso_server }}/{{ discovery_iso_name }}" + +- name: Installing the cluster + hosts: bastion + gather_facts: False + roles: + - install_cluster + vars: + - install: True + - debug: False + - ASSISTED_INSTALLER_HOST: "{{ hostvars['assisted_installer']['host'] }}" + - ASSISTED_INSTALLER_PORT: "{{ hostvars['assisted_installer']['port'] }}" + - CLUSTER_ID: "{{ cluster_id }}" + - INGRESS_VIP: "{{ ingress_vip }}" + - API_VIP: "{{ api_vip }}" + - VIP_DHCP_ALLOCATION: "{{ vip_dhcp_allocation }}" + +- name: Monitoring hosts installation + hosts: masters, workers + gather_facts: False + strategy: free + roles: + - monitor_host + vars: + - debug: False + - ASSISTED_INSTALLER_HOST: "{{ hostvars['assisted_installer']['host'] }}" + - ASSISTED_INSTALLER_PORT: "{{ hostvars['assisted_installer']['port'] }}" + - CLUSTER_ID: "{{ hostvars['bastion']['cluster_id'] }}" + +- name: Monitoring cluster installation + hosts: bastion + gather_facts: False + roles: + - monitor_cluster + vars: + - debug: False + - ASSISTED_INSTALLER_HOST: "{{ hostvars['assisted_installer']['host'] }}" + - ASSISTED_INSTALLER_PORT: "{{ hostvars['assisted_installer']['port'] }}" + - CLUSTER_ID: "{{ cluster_id }}" diff --git a/deploy_day2_workers.yml b/deploy_day2_workers.yml new file mode 100644 index 00000000..17edfaec --- /dev/null +++ b/deploy_day2_workers.yml @@ -0,0 +1,29 @@ +--- +- hosts: bastion + roles: + - role: create_day2_cluster + when: groups['day2_workers'] | default([]) | length > 0 + +- hosts: bastion + pre_tasks: + roles: + - role: generate_discovery_iso + when: groups['day2_workers'] | default([]) | length > 0 + vars: + secure: false + ASSISTED_INSTALLER_HOST: "{{ hostvars['assisted_installer']['host'] }}" + ASSISTED_INSTALLER_PORT: "{{ hostvars['assisted_installer']['port'] }}" + DOWNLOAD_DEST_FILE: "{{ day2_discovery_iso_name }}" + DOWNLOAD_DEST_PATH: "{{ iso_download_dest_path | default('/opt/http_store/data') }}" + URL_ASSISTED_INSTALLER_CLUSTERS_DOWNLOAD_IMAGE: "{{ ASSISTED_INSTALLER_BASE_URL }}/clusters/{{ ADD_HOST_CLUSTER_ID }}/downloads/image" + +- hosts: day2_workers + gather_facts: false + strategy: free + roles: + - add_day2_node + +- hosts: bastion + gather_facts: false + roles: + - approve_csrs diff --git a/deploy_prerequisites.yml b/deploy_prerequisites.yml new file mode 100644 index 00000000..faa865be --- /dev/null +++ b/deploy_prerequisites.yml @@ -0,0 +1,92 @@ +--- +- name: Check facts + hosts: localhost + roles: + - prereq_facts_check + vars: + # This will be made later in the play so no need to check it here. + # This check is there to support playbooks which do not create + # the cert as part of there execution + ssh_public_check: "{{ not (generate_ssh_keys | default(False)) }}" + mirror_certificate_check: "{{ ((use_local_mirror_registry | default(False)) == True) and ((setup_registry_service | default(True)) == False) }}" + +- name: Play to populate image_hashes for relevant images + hosts: localhost + roles: + - get_image_hash + +- name: Setup NTP + hosts: ntp_host + vars: + SETUP_NTP_SERVICE: "{{setup_ntp_service | default(True)}}" + roles: + - role: setup_ntp + when: SETUP_NTP_SERVICE == True + +- name: Play to install and setup mirror registry + hosts: registry_host + vars: + downloads_path: /tmp/wip + config_file_path: /tmp/wip/config + SETUP_REGISTRY_SERVICE: "{{ setup_registry_service | default(True)}}" + roles: + - role: setup_selfsigned_cert + when: SETUP_REGISTRY_SERVICE == True + - role: setup_mirror_registry + when: SETUP_REGISTRY_SERVICE == True + - role: populate_mirror_registry + when: SETUP_REGISTRY_SERVICE == True + collections: + - containers.podman + - community.crypto + - community.general + - ansible.posix + +- name: Setup HTTP Store + hosts: http_store + vars: + SETUP_HTTP_STORE_SERVICE: "{{ setup_http_store_service | default(True) }}" + roles: + - role: setup_http_store + when: SETUP_HTTP_STORE_SERVICE == True + - role: validate_http_store + +- name: Setup DNS Records + hosts: dns_host + vars: + SETUP_DNS_SERVICE: "{{ setup_dns_service | default(True) }}" + roles: + - role: insert_dns_records + when: SETUP_DNS_SERVICE == True + - role: validate_dns_records + +- name: Deploy OpenShift Assisted Installer On Prem + hosts: assisted_installer + roles: + - role: setup_assisted_installer + when: SETUP_ASSISTED_INSTALLER == True + vars: + PULL_SECRET: "{{ pull_secret }}" + SETUP_ASSISTED_INSTALLER: "{{ setup_assisted_installer | default(True) }}" + post_tasks: + - name: Wait for up to 60 minutes for the assisted installer to come online + uri: + url: "http://{{ ansible_host }}:8090/api/assisted-install/v1/clusters" + method: GET + status_code: [200, 201] + register: result + until: result is succeeded + retries: 120 + delay: 30 + delegate_to: bastion + when: SETUP_ASSISTED_INSTALLER == True + +- import_playbook: playbooks/create_vms.yml + vars: + SETUP_VMS: "{{ setup_vms | default(true) }}" + when: SETUP_VMS == True + +- import_playbook: playbooks/deploy_sushy_tools.yml + vars: + SETUP_SUSHY_TOOLS: "{{ setup_sushy_tools | default(setup_vms | default(True)) }}" + when: SETUP_SUSHY_TOOLS == True diff --git a/docs/connecting_to_hosts.md b/docs/connecting_to_hosts.md new file mode 100644 index 00000000..04326178 --- /dev/null +++ b/docs/connecting_to_hosts.md @@ -0,0 +1,54 @@ +# Crucible | Connecting to hosts + +> ❗ _Red Hat does not provide commercial support for the content of this repo. Any assistance is purely on a best-effort basis, as resource permits._ + +--- +```bash +############################################################################## +DISCLAIMER: THE CONTENT OF THIS REPO IS EXPERIMENTAL AND PROVIDED "AS-IS" + +THE CONTENT IS PROVIDED AS REFERENCE WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +############################################################################## +``` +--- + +## Connecting to hosts as crucible user +--- +1. Connect to the bastion host as your personal user. +2. _SU_ to the crucible user +3. Execute playbooks as _crucible_ user + +``` + │ + │ ┌────────────┐ + │ │ │ + │ ┌─────┤ HTTP STORE │ + │ │ │ │ + │ │ └────────────┘ + │ │ + │ │ ┌─────────┐ + │ │ │ │┐ + ┌─────┼─────┐ ├─────┤ WORKERS ││┐ +┌────────────┐ │ │ │ │ │ │││ +│ System │ │ Bastion │ │ └─────────┘││ +│ Integrator ├─────ssh──────► │ ├──ssh as─────┤ └─────────┘│ +│ Host │ user@bastion │ Command & │ crucible@ | └─────────┘ +└────────────┘ │ Control │ | + └─────┼─────┘ │ ┌──────────┐ + | │ │ │ + | ├─────┤ REGISTRY │ + | │ │ │ + │ │ └──────────┘ + │ │ + │ │ ┌───────────┐ + │ │ │ ├┐ + │ └─────┤ SUPER1-3 │├┐ + │ │ ││| + │ └┬──────────┘│| + │ └───────────┘| + │ └───────────┘ + │ + │ +``` diff --git a/docs/images/simple_kvm.drawio b/docs/images/simple_kvm.drawio new file mode 100644 index 00000000..4cbe4943 --- /dev/null +++ b/docs/images/simple_kvm.drawio @@ -0,0 +1 @@ +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 \ No newline at end of file diff --git a/docs/images/simple_kvm.png b/docs/images/simple_kvm.png new file mode 100644 index 0000000000000000000000000000000000000000..9c47188d7a5a9dc3df312de4e512637dfbbec03e GIT binary patch literal 91129 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 u?(E#4l)4$1J-gNdWCokGF*-%ud;i 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 +!dzx;IEoQ2mOC>+9tzk276 zD}P&)`}-j?!c#@$DwyVA{ub?e2#0^McFZ#yKU%|JQ@-2x%U#&*H|;sNaml<>YhO;d zmik+A{?avp`Oj|n{l*7hX|La~E-TEx{2hCevHOds%o#WHmE4ieROD4(4vmrC;1~Py508+*iQCKI5K|+kQXl%#Blj z-1EF2UOx8bg;Sr~h-`dvw_~QSnh^LNpHv(cU+PZDgA}0Y!CW4>@DUyIBNredsDqxW$XOtu$8MFWqkSI_DNhJsrl5@@o zlB47tBAI9T_oL8WHS9N6tt1e-E2LvbZe&Ywg&XjM5(8IKcB>pDBj zFZ{(??{Ieta+6sZ15FN>dUpLwp+@`MJ|3g-BWrbOi# zDYCx6CDnD69A4U{DUkj+kd%gRAuIQ5y#S>@-`wKj)*=Hb3fCEI8Q80b&oy&vgPE%B zj!1)BgSM$}4`XETh-4cd!W5K0Qs4wo5Nm0=wSJ3z7hyS`8q(8w@vO5ee51_2HRLHG zUiw}2&TeRu$=5k&T3nxDa@CRbn7MsYB>ZlYU80_XRN0`m&@VNRNm}pmrjAs~u#G&d zYlJMK;cdy(YuoxuzNMs81Mla2u3%#}5ZQ~V^oHd2I%-okOOL^e#B8O2)xOa3~ z73sd6QS+)R2)x>{MeV>MXK(p8a4StSrw3XJ;m=Y{(urYyotg&Y3F9<9jwYS7n!_Zy zRNYF16`t~=1FdzsaUEfmk};)tweOFU9)$9WD<;je%}~RridAI&qv<3N?(<

9GsM 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/xYGI9C7goMZHNA4jMzPAXumZkOks5GyR6fkyltHSkxIzjN5jHpGALRsQuushnzMmyla68mmiaDWMle/d7/BWS+Y0E8e8ED/8nAzkSj9CbzRBYoxHL/Qa6B2+k2ShuehBnMgBh/mcZGrZYq1Zwn8Waq3DN5aJ67yI4Y3sAPB8VTtlK1TPl7F0jVguzHATblw/gAf7ALt9YPXxVddvELmVcg0Ghq3lQEnoXDUXaXLPSSqbw2UUC/o8J1OFL2WuSiwSaSItoNw6tkBSJR0moo6y8mWcJiSj93GS+CxhvJgEYYyruZtca/rfKRd01YA09w+UpVTwteyivbZOA60Q26T+ss43ONBY1Mg1V2NEp3hYjVzHWjZ0uD8Tem9L6Dcopllwo+QlrSKzW2zmgrNZpRdUIQ32LAtj399HIA1ayuzS1+DH2UKPwThNiIjf23rexpme4YnFciW7ooPwBus5W/Ap1S81RbY5zuDAQILwkIrOQEUEq12fHtQqL+ug0ozB/SIcXHUCX5Q0qmayDssqIHlU9FXGW1s//v7wH68f4B0WELCcbobYlxIQxGcXkKSHr38r1vuOMV9178K4XemQlNZaWycIr0zEPdtD8FsptIqtib/lnSZRYF78XxJ1v5g2u5LEOipJvlivD6bNNyvs0EbnqezQ2cg/8G/TxsixkTZjksmDaUqLZT5SsWR89rVKfoY6vfkltbeUabQl3vBSZRqhS5Zp1wOfKdSF9UR5LPdG+QWrN/he1ds+1wHLsdsyRGjjM3BhGYKuDM3hSt2LTr8wDUkuYqZuXI/yer3/wObsvzWVC7n0rQnvvzWd7eIEPLsVcQjsTkWB5n9D1L6lX6ikOJ0c8PliGk8kx9B6Ssh6wtgs30l9xoSivcyIW3hEXd44VAcOdQN7W+Vw4QSdi3hku23ize+Q1ol7S83wPl/KpVn/CSm1Wv9pQnd/AQ== \ No newline at end of file diff --git a/docs/images/simple_kvm_physical.png b/docs/images/simple_kvm_physical.png new file mode 100644 index 0000000000000000000000000000000000000000..bdecd6754f60eec42335b4ee332fa565a170b3e7 GIT binary patch literal 22197 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 literal 0 HcmV?d00001 diff --git a/docs/images/vm_host_interfaces.drawio b/docs/images/vm_host_interfaces.drawio new file mode 100644 index 00000000..e857130f --- /dev/null +++ b/docs/images/vm_host_interfaces.drawio @@ -0,0 +1 @@ +1VnLctowFP0aZtpFGEuyDSwDec206XSaDkm6yShYMW5sy5XlAPn6SiA/ZWwIJHVXWFfSlXTuOVcPemgSLC8ZjubX1CF+DxrOsofOehACYNjiR1pWG8twoAwu8xzVKDfceK9EGQ1lTTyHxKWGnFKfe1HZOKNhSGa8ZMOM0UW52RP1y6NG2CWa4WaGfd166zl8rlYBB7n9injuPB0Z2KNNTYDTxmol8Rw7dFEwofMemjBK+eYrWE6IL8FLcdn0u9hSm02MkZDv0sG7/PE4oEn46o6uHhG/tq+m5ASodbxgP1Er7kHbFw7HcYRDOW2+UljYfxI51/ETDflJvI7UqWgA7GiZV4ovV/5Or0XVFY156u6RpVWpRUx1M0ZqhqXhoAAskp9J4F8wHIjP8WLucXIT4Zm0LwTjhG3OA1+UgKxWERoZ0p6GBclSvPACH4fkwvP9CfUpW4+BbNvOhi5CqdB9IYyTZcGkoL0kNCCcrUQTVTtQUVY0N1P+LnLSmENlmxcIk9EDK6K6mes8luJDhXOf0I5qQluBmITOqRSJKIU0JGU0Y87oc8Z6lFkK8Bn2rAk+4pTEpYNXQMeqASe1MeJj7r2UJVkHmBrhO/XETLbFBtkVyGOasBlRnYoKqvixrBZHHDOXcM3ROnzZqneK6OvD9MvtJHld/T53h8nDr+fFz9kJ0uMnAL5RRcr4nLo0xP55bh0zQQuHSK9SBXmbr5RGKs6/CecrlXlxwmmZBWTp8TvZvW+p0r1yJr/PlsXCKi2EYqF3xcJ97kEW827rUtpvK5E2EWoge7Y3rCPQgCHckZk7U+4gjWZ5Jtfo1GM8wbLnWGyIYpZa/vwERrAP7GEfGP3BZ40T5Yi3ZMyncjqcTCbHSYfIqOTDoZ4PgWHpkjffKx0ioCFVVI9Kf/9YLn1oFRUDWvSip29hkTvc4XpCup4aGdwVQSFNUHESEQaaZQRAR3UEhmUdWZal6WhUs3NWd6bjychoAerjNdMVyZj/qWTMesnAFsnAjkoGGYO+1S3RQKseYtQCMeooxKbZsawEbQ1gfbff767zlqS07/2omFNqT6vdukfBSthNY1R2setFCsKyQN/xIlVPl+Hx6bJlPzJa9qN9KXO0C87HUMYctkR6V8pUU042wY+izKGvKW8lSJ6HBsVEBBoT0QGnmBqeNe5pHSGaVc1NA+ttRKs6smDF0TsTDUGNaNc4xC4JyHqa3whfUPZ82MHgCGeA6qOaWXPBRzXxPsZ7Z9NeWcCNhLTl3md39IBVvfbVPSfXHbCO8XrStKuUwa27ITyuH61OYh+/kI6CW91K3hFcUcz/4NlkiPxvMnT+Fw== \ No newline at end of file diff --git a/docs/images/vm_host_interfaces.png b/docs/images/vm_host_interfaces.png new file mode 100644 index 0000000000000000000000000000000000000000..e5acb4de7301141982e3fb58479c620803d7264b GIT binary patch literal 22573 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 literal 0 HcmV?d00001 diff --git a/docs/inventory.md b/docs/inventory.md new file mode 100644 index 00000000..911b86e9 --- /dev/null +++ b/docs/inventory.md @@ -0,0 +1,274 @@ +# Crucible | Inventory + +> ❗ _Red Hat does not provide commercial support for the content of this repo. Any assistance is purely on a best-effort basis, as resource permits._ + +--- + +```bash +############################################################################## +DISCLAIMER: THE CONTENT OF THIS REPO IS EXPERIMENTAL AND PROVIDED "AS-IS" + +THE CONTENT IS PROVIDED AS REFERENCE WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +############################################################################## +``` + +--- + +## Inventory Validation + +### Cluster config checks: + +- 3 or more master nodes +- 2 or more, or 0 worker nodes +- every node has required vars: + - `bmc_address` + - `bmc_password` + - `bmc_user` + - `vendor` + - `role` + - `mac` +- required vars are correctly typed +- all values of `vendor` are supported +- all values of `role` are supported +- If any nodes are virtual (vendor = KVM) then a vm_host is defined + +There three possible groups of nodes are `masters`, `workers` and `day2_workers`. + +#### Day 2 nodes + +Day 2 nodes are added to an existing cluster. The reason why the installation of day 2 nodes is built into the main path of our automation is that for assisted installer day 2 nodes can be on a different L2 network which the main flow does not allow. + +### Network checks + +- All node `bmc_address`es are reachable +- All prerequisite services `ansible_host`s are reachable +- If `setup_ntp_service` is disabled then the configured `ntp_server` must be reachable. + +Note that checks on DNS, registry, and HTTP Store are completed later in the playbooks. + +> :warning: **If you have dhcp entries already specified then the host name must match the hostname in the dhcp entry. If not procedures will fail** + +### VM spec config + +The specs of VMs created by the playbooks are configured for every node group. + +```yaml +vm_spec: + cpu_cores: 4 + ram_mib: 6144 + disk_size_gb: 20 +``` + +### Required secrets + +#### Prerequisite services + +The Container Registry service requires the following variables to be set. Set the appropriate values in the inventory vault file. + +- `REGISTRY_HTTP_SECRET` + +For Restricted Network installations, additional credentials for the registry need to be provided. + +- `disconnected_registry_user` +- `disconnected_registry_password` + +#### Nodes + +All nodes must have credentials set for the BMCs. + +- `bmc_user` +- `bmc_password` + +It is possible to specify different credentials for individual nodes. +See the sample inventory file (`inventory.yml.sample`) and the sample inventory vault file (`inventory.vault.yml.sample`) for more information. + +## Configurations + +### Network configuration + +The `network_config` entry on a node is a simplified version of the `nmstate`([nmstate.io](http://nmstate.io/)) required by the [assisted installer api](https://github.com/openshift/assisted-service/blob/3bcaca8abef5173b0e2175b5d0b722e851e39cee/docs/user-guide/restful-api-guide.md). +If you wish to use your own template you can set `network_config.template` with a path to your desired template the default can be found [here](../roles/generate_discovery_iso/templates/nmstate.yml.j2). If you wish to write the `nmstate` by hand you can use the `network_config.raw`. + +### Static IPs + +To activate static IPs in the discovery iso and resulting cluster there is some configuration required in the inventory. + +```yaml +network_config: + interfaces: + - name: "{{ interface }}" + mac: "{{ mac }}" + addresses: + ipv4: + - ip: "{{ ansible_host}}" + prefix: "{{ mask }}" + dns_server_ip: "{{dns}}" # optional + routes: # optional + - destination: 0.0.0.0/0 + address: "{{ gateway }}" + interface: "{{ interface }}" +``` + +where the variables are as follows: + +- `ip`: The static IP is set +- `dns`: IP of the DNS server +- `gateway`: IP of the gateway +- `mask`: Length of subnet mask (e.g. 24) +- `interface`: The name of the interface you wish to configure +- `mac`: Mac address of the interface you wish to configure + +#### Link Aggregation + +Here is an example of how to do link aggregation of two interfaces. + +```yaml +network_config: + interfaces: + - name: bond0 + type: bond + state: up + addresses: + ipv4: + - ip: 172.17.0.101 + prefix: 24 + link_aggregation: + mode: active-backup + options: + miimon: "1500" + slaves: + - ens7f0 + - ens7f1 + - name: ens1f0 + type: ethernet + mac: "40:A6:B7:3D:B3:70" + state: down + - name: ens1f1 + type: ethernet + mac: "40:A6:B7:3D:B3:71" + state: down + dns_resolver_ip: 10.40.0.100 + routes: + - destination: 0.0.0.0/0 + address: 172.17.0.1 + interface: bond0 +``` + +### Prerequisites + +--- + +Use the following vars to control setup of prerequisites: + +- `setup_ntp_service` +- `setup_registry_service` +- `setup_http_store_service` +- `setup_dns_service` +- `create_vms` +- `setup_sushy_tools` + +Note that if one or more of these services is pre-existing in your environment the inventory must still be configured with information needed to access those services, even when the service is not being set up by the playbooks. + +> TODO: list required vars for each service when setup automatically + +> TODO: list required vars for each service when NOT setup automatically + +### Virtual Nodes + +--- + +When using one or more virtual nodes, they are identified as such by having `vendor` set to `KVM`. They still require the BMC configuration and MAC+IP addresses common to all nodes, but with a few variations: + +- The BMC address of the virtual nodes must point to the `vm_host`; `sushy-tools` will be set up on the `vm_host` to allow the VMs to be controlled identically to the baremetal hosts. + - The BMC user and password will be set in `sushy-tools` and must therefore be the same for all virtual nodes. +- The specified MAC address will be set on the VM interface. + +### SSH Key Gen + +--- + +By default an SSH key will be generated by the `deploy_cluster.yml` playbook. This can be disabled by adding `generate_ssh_keys = False` to the inventory. It is possible to configure the task generating the SSH key (see the docs for `community.crypto.openssh_keypair`) by setting `openssh_keypair_args` with a dictionary. + +# Examples + +## Virtual Management Cluster + +One of the simplest examples is a simple cluster with no workers, virtual masters on a VM Host, and all other supporting services being configured on the bastion host. The initial environment will be something like this: + +![](images/simple_kvm_physical.png) + +That diagram gives the following excerpt from the inventory for the `bastion` and `services`: + +```yaml +# ... + children: + bastions: + hosts: + bastion: + ansible_host: 192.168.10.5 + services: + hosts: + assisted_installer: + ansible_host: "{{ hostvars['bastion']['ansible_host'] }}" + # ... + registry_host: + ansible_host: "{{ hostvars['bastion']['ansible_host'] }}" + # ... + dns_host: + ansible_host: "{{ hostvars['bastion']['ansible_host'] }}" + # ... + http_store: + ansible_host: "{{ hostvars['bastion']['ansible_host'] }}" + # ... + ntp_host: + ansible_host: "{{ hostvars['bastion']['ansible_host'] }}" + # ... + vm_host: + ansible_host: 192.168.10.6 + # ... +``` + +## VM Host in Detail + +The virtual `master` nodes in their simplest case are defined in the inventory as an address they will be accessible on, and the MAC Address that will be set when creating the VM and later used by Assisted Installer to identify the machines: + +```yaml + masters: + vars: + role: master + vendor: KVM + bmc_address: 192.168.10.6:8082 # virtual BMC is setup on VM Host port 8082 + hosts: + super1: + ansible_host: 192.168.10.10 + mac: "DE:AD:BE:EF:C0:2C" + super2: + ansible_host: 192.168.10.11 + mac: "DE:AD:BE:EF:C0:2D" + super3: + ansible_host: 192.168.10.12 + mac: "DE:AD:BE:EF:C0:2E" +``` + +For the virtual bridge configuration, in this example interface `eno1` is used for accessing the VM host, the `eno2` is assigned to the virtual bridge to allow the virtual `super` nodes to connect to the Management Network. Note that these two interfaces cannot be the same. DNS on the virtual bridge is provided by the DNS `service` configured on the Bastion host. + +The `vm_host` entry in the inventory becomes: + +```yaml + vm_host: + ansible_user: root + ansible_host: 192.168.10.6 + vm_bridge_ip: 192.168.10.7 + vm_bridge_interface: eno2 + dns: "{{ hostvars['dns_host']['ansible_host'] }}" +``` + +![](images/vm_host_interfaces.png) + +## Resulting Cluster + +Combining those pieces, along with other configuration like versions, certificates and keys, will allow Crucible to deploy a cluster like this: + +![](images/simple_kvm.png) diff --git a/docs/pipeline_into_the_details.md b/docs/pipeline_into_the_details.md new file mode 100644 index 00000000..90c01e59 --- /dev/null +++ b/docs/pipeline_into_the_details.md @@ -0,0 +1,81 @@ +## Pipeline into the details +### ‘Prerequisite’ / ‘Deployment’ / ‘Configuration’ - Management cluster +#### _Using Assisted Installer_ +--- +#### --- PRIVATE AND CONFIDENTIAL, DO NOT DISTRIBUTE --- +--- + +1. Prerequisite Stage: + 1. Manual Steps: + - BIOS & Firmware updates on all servers + - Networks configurations: + - Switch configuration + - Subnet configurations + 2. Ansible Orchestration - via jumphost: + - Generate SSH key pair used for debugging deployments + - Set up local container for the registry + - The Container registry for hosting and distributing additional container images: + - `openshift-release-dev/ocp-release` + - `ocpmetal/assisted-installer-controller` + - `ocpmetal/assisted-installer-agent` + - `ocpmetal/assisted-installer` + - `olm-index/redhat-operator-index` + - HTTP Store (optional): + - Is HTTP Store a pre-exisiting server? + - Setup HTTP Server + - DNS (optional): + - Is DNS pre-existing? + - Setup DNSMASQ DNS Server + - NTP (optional): + - Is NTP pre-existing? + - Setup Chrony NTP Server + - Podman creates the Assisted Installer pod: + - Installer + - DB + - UI + - Launch stand alone Assisted Installer with REST API capabilities. +2. Configuration: + - Assisted Installer: + - Create cluster deployment + - Configure cluster deployment + - Redirects for the repose of the registry: + - QUAY + - SSL + - Restricted network install customizations (optional) + - Network SDN drivers (optional): + - Openshift SDN + - OVN Kubernetes + - API discovery ISO customizations: + - Static IPs: + - __NOTE:__ Currently we support static IP’s on v1.0.17.3 of the Assisted Installer. + - Download the Discovery ISO: + - Copy to the HTTP Store. +3. Discovery: + - Mount the Discovery ISO from the HTTP Store on target hosts + - Set ISO to boot media on target hosts + - Reboot hosts into Discovery ISO +4. Post discovery: + - Apply additional cluster install configurations: + - Ingress VIP + - API Controller + - Assign roles to discovered hosts +5. Deploy / Install Management Cluster: + - Trigger install via the Assisted Installer REST API. + - Monitor install via the Assisted Installer REST API. +6. Post Flight: + - Retrieve kubeconfig and save it to jumphost + + + + +__Note__: Time for the above procedure is about 1.5hr, on Bare Metal Servers. + + + + +#### COMING SOON: +Apply a workload configuration manifests: +Persistent Storage +Advanced Cluster Manager +Creation of the ACM Pods on the Management Cluster(Workers). +Using the ACM in order to deploy OpenShift on the DU & CU servers. diff --git a/docs/troubleshooting/discovery_iso_not_booting.md b/docs/troubleshooting/discovery_iso_not_booting.md new file mode 100644 index 00000000..5b8cffbf --- /dev/null +++ b/docs/troubleshooting/discovery_iso_not_booting.md @@ -0,0 +1,30 @@ +# Discovery ISO not booting + +> ❗ _Red Hat does not provide commercial support for the content of this repo. Any assistance is purely on a best-effort basis, as resource permits._ + +Sometimes the discovery iso will not boot here are a few things to check. + +## Dell + +### Plugin type + +For some versions of iDRAC the plug-in type can stop the boot iso from working. If you are trying to install Redhat Openshift Container Platform (RHCOP) version `>=4.7` then follow the instructions bellow from the [ipi docs](https://docs.openshift.com/container-platform/4.7/installing/installing_bare_metal_ipi/ipi-install-installation-workflow.html) + +``` +There is a known issue with version 04.40.00.00. With iDRAC 9 firmware version 04.40.00.00, the Virtual Console plug-in defaults to eHTML5, which causes problems with the InsertVirtualMedia workflow. Set the plug-in to HTML5 to avoid this issue. The menu path is: Configuration → Virtual console → Plug-in Type → HTML5 . +``` + +Note if you are trying to install RHOCP version `4.6` then the plugin-in should be set to `eHTML5`. + +### Clear UEFI Boot entries + +For RHOCP version `4.8` sometimes wont boot despite the plug-in type correct. Try the following: + +1. (Re)Boot machine +2. Hit F11 to enter Boot Manager +3. Select "One-shot UEFI Boot Menu" +4. Scroll to the bottom and select "Delete Boot Option" +5. Select all options +6. Click "Commit Changes and Exit" +7. Click "Finish" +8. Click "Finish" & Confirm exit diff --git a/inventory.vault.yml.sample b/inventory.vault.yml.sample new file mode 100644 index 00000000..0a5faf01 --- /dev/null +++ b/inventory.vault.yml.sample @@ -0,0 +1,28 @@ +--- +####################################### +# Prerequisite services configuration # +####################################### + +# HTTP Secret for the Container Registry. +# More information on the vars used to configure the Registry can be found here: +# https://docs.docker.com/registry/configuration/#http +VAULT_REGISTRY_HOST_REGISTRY_HTTP_SECRET: SECRET + +# Credentials for the Disconnected Registry (if relevant) +VAULT_REGISTRY_HOST_DISCONNECTED_REGISTRY_USER: USER +VAULT_REGISTRY_HOST_DISCONNECTED_REGISTRY_PASSWORD: PASSWORD + +####################### +# Nodes configuration # +####################### + +# Default credentials for the BMCs +VAULT_NODES_BMC_USER: USER +VAULT_NODES_BMC_PASSWORD: PASSWORD + +# # Set custom BMC credentials for super1 and worker1 nodes. +# # These vault variables then have to be referenced in the inventory file. +# VAULT_NODES_SUPER1_BMC_USER: USER +# VAULT_NODES_SUPER1_BMC_PASSWORD: PASSWORD +# VAULT_NODES_WORKER1_BMC_USER: USER +# VAULT_NODES_WORKER1_BMC_PASSWORD: PASSWORD diff --git a/inventory.yml.sample b/inventory.yml.sample new file mode 100644 index 00000000..cff94ef5 --- /dev/null +++ b/inventory.yml.sample @@ -0,0 +1,284 @@ +all: + vars: + ################################## + # Assisted Install Configuration # + ################################## + # These options configure Assisted Installer and the resulting cluster + # https://generator.swagger.io/?url=https://raw.githubusercontent.com/openshift/assisted-service/58a6abd5c99d4e41d939be89cd0962433849a861/swagger.yaml + # See section: cluster-create-params + + # Cluster name and dns domain combine to give the cluster namespace that will contain OpenShift endpoints + # e.g. api.clustername.example.lab, worker1.clustername.example.lab + cluster_name: clustername + base_dns_domain: example.lab + + # OpenShift version + openshift_full_version: 4.6.16 + + # Virtual IP addresses used to access the resulting OpenShift cluster + api_vip: 10.60.0.96 # the IP address to be used for api.clustername.example.lab and api-int.clustername.example.lab + ingress_vip: 10.60.0.97 # the IP address to be used for *.apps.clustername.example.lab + + ## Allocate virtual IPs via DHCP server. Equivalent to the vip_dhcp_allocation configuration option of Assisted Installer + vip_dhcp_allocation: false + + # The subnet on which all nodes are (or will be) accessible. + machine_network_cidr: 10.60.0.0/24 + + # The IP address pool to use for service IP addresses + service_network_cidr: 172.30.0.0/16 + + # Cluster network settings. You are unlikely to need to change these + cluster_network_cidr: 10.128.0.0/14 # The subnet, internal to the cluster, on which pods will be assigned IPs + cluster_network_host_prefix: 23 # The subnet prefix length to assign to each individual node. + + # # Cluster network provider. Cannot be changed after cluster is created. + # # The default is OpenShift SDN unless otherwise specified. + # network_type: OVNKubernetes + # network_type: OpenShiftSDN + + ###################################### + # Prerequisite Service Configuration # + ###################################### + + # Flags to enable/disable prerequisite service setup + # You will need to ensure alternatives are available for anything that will not be automatically set up + setup_ntp_service: false + setup_dns_service: false + setup_registry_service: false # Only required for a Restricted Network installation + setup_http_store_service: true + + + # NTP Service + # ntp_server is the address at which the NTP service is (or will be) available + ntp_server: ntp.example.lab + # ntp_server_allow is the range of IPs the NTP service will respond to + # ntp_server_allow: 10.40.0.0/24 # not required if setup_ntp_service is false + + + # Mirror Registry Service parameters for a Restricted Network installation + + # use_local_mirror_registry controls if the install process uses a local container registry (mirror_registry) or not. + # Set this to true to use the mirror registry service set up when `setup_registry_service` is true. + use_local_mirror_registry: false + + # HTTP Store Configuration + # ISO name must include the `discovery` directory if you have a SuperMicro machine + discovery_iso_name: "discovery/discovery-image.iso" + + # discovery_iso_server must be discoverable from all BMCs in order for them to mount the ISO hosted there. + # It is usually necessary to specify different values for KVM nodes and/or physical BMCs if they are on different subnets. + discovery_iso_server: "http://{{ hostvars['http_store']['ansible_host'] }}" + + ############################ + # Local File Configuration # + ############################ + + # Directory in which created/updated artefacts are placed + fetched_dest: ./fetched + + # Configure possible paths for the pull secret + # first one found will be used + pull_secret_lookup_paths: + - "{{ fetched_dest }}/pull-secret.txt" + - ./pull-secret.txt + + # Configure possible paths for the ssh public key used for debugging + # first one found will be used + ssh_public_key_lookup_paths: + - "{{ fetched_dest }}/ssh_keys/{{ cluster_name }}.pub" + - ./ssh_public_key.pub + - ~/.ssh/id_rsa.pub + + # The retrieved cluster kubeconfig will be placed on the bastion host at the following location + kubeconfig_dest_dir: /home/redhat/ + kubeconfig_dest_filename: "{{ cluster_name }}-kubeconfig" + + ############################ + # LOGIC: DO NOT TOUCH # + # vvvvvvvvvvvvvvvvvvvvvvvv # + ############################ + + # pull secret logic, no need to change. Configure above + local_pull_secret_path: "{{ lookup('first_found', pull_secret_lookup_paths) }}" + pull_secret: "{{ lookup('file', local_pull_secret_path) }}" + + # ssh key logic, no need to change. Configure above + local_ssh_public_key_path: "{{ lookup('first_found', ssh_public_key_lookup_paths) }}" + ssh_public_key: "{{ lookup('file', local_ssh_public_key_path) }}" + + # provided mirror certificate logic, no need to change. + local_mirror_certificate_path: "{{ (setup_registry_service == true) | ternary(fetched_dest + '/domain.crt', './mirror_certificate.txt') }}" + mirror_certificate: "{{ lookup('file', local_mirror_certificate_path) }}" + + openshift_version: "{{ openshift_full_version.split('.')[:2] | join('.') }}" + + ############################ + # ^^^^^^^^^^^^^^^^^^^^^^^^ # + # LOGIC: DO NOT TOUCH # + ############################ + + + children: + bastions: # n.b. Currently only a single bastion is supported + hosts: + bastion: + ansible_host: 172.28.11.51 # Must be reachable from the Ansible control node + + # Configuration and access information for the pre-requisite services + # TODO: document differences needed for already-deployed and auto-deployed + services: + hosts: + assisted_installer: + ansible_host: service_host.example.lab + host: service_host.example.lab + port: 8090 # Do not change + + registry_host: + ansible_host: registry.example.lab + registry_port: 5000 + registry_fqdn: registry.example.lab # use in case of different FQDN for the cert + cert_common_name: "{{ registry_fqdn }}" + cert_country: US + cert_locality: Raleigh + cert_organization: Red Hat, Inc. + cert_organizational_unit: Lab + cert_state: NC + + # Configure the following secret values in the inventory.vault.yml file + REGISTRY_HTTP_SECRET: "{{ VAULT_REGISTRY_HOST_REGISTRY_HTTP_SECRET | mandatory }}" + disconnected_registry_user: "{{ VAULT_REGISTRY_HOST_DISCONNECTED_REGISTRY_USER | mandatory }}" + disconnected_registry_password: "{{ VAULT_REGISTRY_HOST_DISCONNECTED_REGISTRY_PASSWORD | mandatory }}" + + # # registry_container_image defaults to docker.io/library/registry:2 + # # Note that docker.io may rate-limit heavy users. Use this to provide your own image. + # registry_container_image: quay.io/example/registry:latest + + dns_host: + ansible_host: 10.40.0.100 + # write_dnsmasq_config: true # Set to false if there is already dns setup + + http_store: + ansible_host: 10.40.0.100 + + ntp_host: + ansible_host: 10.40.0.100 + + vm_host: # Required for using "KVM" nodes, ignored if not. + ansible_user: root + ansible_host: vm_host.clustername.example.lab + host_ip_keyword: ansible_host # the varname in the KVM node hostvars which contains the *IP* of the VM + images_dir: /home/redhat/libvirt/images # directory where qcow images will be placed. + vm_bridge_ip: 10.60.0.190 # IP for the bridge between VMs and machine network + vm_bridge_interface: ens7f1 # Interface to be connected to the bridge. DO NOT use your primary interface. + dns: 10.40.0.100 # DNS used by the bridge + + # Describe the desired cluster members + nodes: + # A minimum of three master nodes are required. More are supported. + # Worker nodes are not required, but if present there must be two or more. + + # Node Required Vars: + # - role + # - Must be either "master" or "worker", and must match the group + + # - mac + # - The MAC address of the node, used as a hardware identifier by Assisted Installer. + # - The value set here will be used when creating VMs and must be unique within the network. + + # - vendor + # - One of "Dell", "Lenovo", "SuperMicro", "KVM" as the supported BMC APIs. + # - "KVM" identifies a node as a VM to be created. If a "KVM" node is present, + # then a "vm_host" must be defined in the "services" group. + + # - bmc_address + # - bmc_user + # - bmc_password + # - details for the BMC that controls the node. + # - Must be set to the vm_host for "KVM" nodes. + + # Static IP Vars: + # - ip + # - defaults to "dhcp" + # - Either the static IP to be set for the node, or "dhcp" + # - If a static IP is set then "mask", "gateway", "dns" MUST also be set for the node + + # - dns + # - IP of the DNS server to be configured for static IP + + # - gateway + # - IP of the gateway to be configured for static IP + + # - mask + # - Length of subnet mask (e.g. 24) to be configured for static IP + + # - vm_spec + # - Specifications for the node: + # - cpu_cores + # - ram_mib + # - disk_size_gb + + # Optional Vars: + # - installation_disk_path + # - The value set here will be used by Assisted Installer as the installation disk device + # for a given host. + # - The value must be a path to the disk device, e.g. /dev/sda + # - If not specified, Assisted Installer will pick the first enumerated disk device for a + # given host. + vars: + # Set the login information for any BMCs. Note that these will be SET on the vm_host virtual BMC. + bmc_user: "{{ VAULT_NODES_BMC_USER | mandatory }}" + bmc_password: "{{ VAULT_NODES_BMC_PASSWORD | mandatory }}" + children: + masters: + vars: + role: master + vendor: KVM # this example is a virtual control plane + bmc_address: "vm_host.clustername.example.lab:8082" # port can be changed using sushy_tools_port on the vm_host + vm_spec: + cpu_cores: 4 + ram_mib: 6144 + disk_size_gb: 20 + hosts: + super1: + ansible_host: 10.60.0.101 + mac: "DE:AD:BE:EF:C0:2C" + + # # Uncomment to set custom BMC credentials for the node + # # These variables must be set in the inventory.vault.yml file + # bmc_user: "{{ VAULT_NODES_SUPER1_BMC_USER | mandatory }}" + # bmc_password: "{{ VAULT_NODES_SUPER1_BMC_PASSWORD | mandatory }}" + + super2: + ansible_host: 10.60.0.102 + mac: "DE:AD:BE:EF:C0:2D" + + super3: + ansible_host: 10.60.0.103 + mac: "DE:AD:BE:EF:C0:2E" + + workers: + vars: + role: worker + vendor: Dell # This example uses baremetal worker nodes + hosts: + worker1: + ansible_host: 10.60.0.104 + bmc_address: 172.28.11.25 + mac: 3C:FD:FE:78:AB:03 + + # # Uncomment to set custom BMC credentials for the node + # # These variables must be set in the inventory.vault.yml file + # bmc_user: "{{ VAULT_NODES_WORKER1_BMC_USER | mandatory }}" + # bmc_password: "{{ VAULT_NODES_WORKER1_BMC_PASSWORD | mandatory }}" + + # # Uncomment to set an alternate installation disk device for the node + # installation_disk_path: /dev/sdb + + worker2: + ansible_host: 10.60.0.105 + bmc_address: 172.28.11.26 + mac: 3C:FD:FE:78:AB:04 + + # # Uncomment to set an alternate installation disk device for the node + # installation_disk_path: /dev/sdb diff --git a/playbooks/boot_disk.yml b/playbooks/boot_disk.yml new file mode 100644 index 00000000..e57702e8 --- /dev/null +++ b/playbooks/boot_disk.yml @@ -0,0 +1,13 @@ +--- +# file: boot_disk.yml + +- name: Unmounting Assisted Installer Discovery ISO + hosts: bastion + gather_facts: False + roles: + - boot_disk + vars: + - debug: True + - CLUSTER_ID: "{{ cluster_id }}" + - ASSISTED_INSTALLER_HOST: "{{ hostvars['assisted_installer']['host'] }}" + - ASSISTED_INSTALLER_PORT: "{{ hostvars['assisted_installer']['port'] }}" diff --git a/playbooks/boot_iso.yml b/playbooks/boot_iso.yml new file mode 100644 index 00000000..ec8b2d26 --- /dev/null +++ b/playbooks/boot_iso.yml @@ -0,0 +1,9 @@ +--- +- name: Mounting, Booting the Assisted Installer Discovery ISO + hosts: masters, workers, day2_workers + gather_facts: False + strategy: free + roles: + - boot_iso + vars: + - debug: False diff --git a/playbooks/create_vms.yml b/playbooks/create_vms.yml new file mode 100644 index 00000000..f5a1aeea --- /dev/null +++ b/playbooks/create_vms.yml @@ -0,0 +1,22 @@ +--- +- name: Provision VMS + hosts: vm_host + vars: + N_KVM: "{{ + hostvars | dict2items | + selectattr('value.vendor', 'defined') | + selectattr('value.vendor', 'equalto', 'KVM') | + list | length + }}" + SETUP_VMS: "{{ setup_vms | default(N_KVM | int >= 1) }}" + roles: + - role: destroy_vms + when: SETUP_VMS == true + tags: + - destroy_vms + - setup_vms + + - role: create_vms + when: SETUP_VMS == true + tags: + - setup_vms diff --git a/playbooks/dell_idrac_soft_reset.yml b/playbooks/dell_idrac_soft_reset.yml new file mode 100644 index 00000000..e435f5ab --- /dev/null +++ b/playbooks/dell_idrac_soft_reset.yml @@ -0,0 +1,21 @@ +--- +- name: Soft reset Dell iDrac9 + hosts: masters,workers + gather_facts: false + vars: + ansible_host: "{{ bmc_address }}" + ansible_user: "{{ bmc_user }}" + ansible_password: "{{ bmc_password }}" + vars_prompt: + - name: reset_confirmation + prompt: You are about to preform a SOFT RESET of a Dell Drac. ARE YOU SURE [yes|no]? + private: no + tasks: + - name: RESETING THE DRAC AND CMC + raw: racadm racreset + when: reset_confirmation | bool + register: result + failed_when: "'RAC reset operation initiated successfully' not in result['stdout']" + - pause: + prompt: "Pausing 2 mins to allow the iDRAC and CMC to return to a usable state." + minutes: 3 diff --git a/playbooks/deploy_assisted_installer_onprem.yml b/playbooks/deploy_assisted_installer_onprem.yml new file mode 100644 index 00000000..6fdff167 --- /dev/null +++ b/playbooks/deploy_assisted_installer_onprem.yml @@ -0,0 +1,29 @@ +--- +- name: Play to populate image_hashes for relevant images + hosts: localhost + vars: + destination_hosts: + - assisted_installer + roles: + - get_image_hash + + +- name: Deploy OpenShift Assisted Installer On Prem + hosts: assisted_installer + roles: + - setup_assisted_installer + vars: + PULL_SECRET: "{{ pull_secret }}" + SETUP_ASSISTED_INSTALLER: "{{ setup_assisted_installer | default(True) }}" + post_tasks: + - name: Wait for up to 10 minutes for the assisted installer to come online + uri: + url: "http://{{ ansible_host }}:8090/api/assisted-install/v1/clusters" + method: GET + status_code: [200, 201] + register: result + until: result is succeeded + retries: 20 + delay: 30 + delegate_to: bastion + when: SETUP_ASSISTED_INSTALLER == True diff --git a/playbooks/deploy_dns.yml b/playbooks/deploy_dns.yml new file mode 100644 index 00000000..b5712b10 --- /dev/null +++ b/playbooks/deploy_dns.yml @@ -0,0 +1,8 @@ +- name: Setup DNS Records + hosts: dns_host + vars: + SETUP_DNS_SERVICE: "{{ setup_dns_service | default(True) }}" + roles: + - role: insert_dns_records + when: SETUP_DNS_SERVICE == True + - role: validate_dns_records diff --git a/playbooks/deploy_http_store.yml b/playbooks/deploy_http_store.yml new file mode 100644 index 00000000..94980d64 --- /dev/null +++ b/playbooks/deploy_http_store.yml @@ -0,0 +1,6 @@ +--- +- name: Install and http_store service + hosts: http_store + roles: + - setup_http_store + - validate_http_store diff --git a/playbooks/deploy_registry.yml b/playbooks/deploy_registry.yml new file mode 100644 index 00000000..b1e59c98 --- /dev/null +++ b/playbooks/deploy_registry.yml @@ -0,0 +1,23 @@ +--- +- name: Play to populate image_hashes for relevant images + hosts: localhost + vars: + destination_hosts: + - registry_host + roles: + - get_image_hash + +- name: Play to install and setup mirror registry + hosts: registry_host + vars: + downloads_path: /tmp/wip + config_file_path: /tmp/wip/config + roles: + - setup_selfsigned_cert + - setup_mirror_registry + - populate_mirror_registry + collections: + - containers.podman + - community.crypto + - community.general + - ansible.posix diff --git a/playbooks/deploy_sushy_tools.yml b/playbooks/deploy_sushy_tools.yml new file mode 100644 index 00000000..b07791dc --- /dev/null +++ b/playbooks/deploy_sushy_tools.yml @@ -0,0 +1,4 @@ +- name: Deploy sushy tools + hosts: vm_host + roles: + - setup_sushy_tools diff --git a/playbooks/destroy_vms.yml b/playbooks/destroy_vms.yml new file mode 100644 index 00000000..1eb6e2e2 --- /dev/null +++ b/playbooks/destroy_vms.yml @@ -0,0 +1,5 @@ +--- +- name: Destroy VMs + hosts: vm_host + roles: + - destroy_vms diff --git a/playbooks/generate_discovery_iso.yml b/playbooks/generate_discovery_iso.yml new file mode 100644 index 00000000..a0c3e23e --- /dev/null +++ b/playbooks/generate_discovery_iso.yml @@ -0,0 +1,16 @@ +--- +# file: generate_discovery_iso.yml +- hosts: bastion + roles: + - generate_discovery_iso + vars: + - download: False + - secure: False + - generate: True + - debug: True + - CLUSTER_ID: "{{ cluster_id }}" + - CLUSTER_NAME: "{{ cluster_name }}" + - ASSISTED_INSTALLER_HOST: "{{ hostvars['assisted_installer']['host'] }}" + - ASSISTED_INSTALLER_PORT: "{{ hostvars['assisted_installer']['port'] }}" + - DOWNLOAD_DEST_FILE: "{{ discovery_iso_name }}" + - DOWNLOAD_DEST_PATH: "/opt/http_store/data" diff --git a/playbooks/generate_ssh_key_pair.yml b/playbooks/generate_ssh_key_pair.yml new file mode 100644 index 00000000..4502370e --- /dev/null +++ b/playbooks/generate_ssh_key_pair.yml @@ -0,0 +1,5 @@ +--- +- name: Generate ssh keys used for debug + hosts: bastion + roles: + - generate_ssh_key_pair diff --git a/playbooks/install_cluster.yml b/playbooks/install_cluster.yml new file mode 100644 index 00000000..7e2b8246 --- /dev/null +++ b/playbooks/install_cluster.yml @@ -0,0 +1,15 @@ +--- +# file: install_cluster.yml +- hosts: bastion + gather_facts: False + roles: + - install_cluster + vars: + - install: True + - debug: True + - ASSISTED_INSTALLER_HOST: "{{ hostvars['assisted_installer']['host'] }}" + - ASSISTED_INSTALLER_PORT: "{{ hostvars['assisted_installer']['port'] }}" + - CLUSTER_ID: "{{ cluster_id }}" + - INGRESS_VIP: "{{ ingress_vip }}" + - API_VIP: "{{ api_vip }}" + - VIP_DHCP_ALLOCATION: "{{ vip_dhcp_allocation }}" diff --git a/playbooks/monitor_cluster.yml b/playbooks/monitor_cluster.yml new file mode 100644 index 00000000..1f2e327c --- /dev/null +++ b/playbooks/monitor_cluster.yml @@ -0,0 +1,25 @@ +--- +# file: monitor_cluster.yml + +- name: Monitoring hosts installation + hosts: masters, workers + gather_facts: False + strategy: free + roles: + - monitor_host + vars: + - debug: True + - ASSISTED_INSTALLER_HOST: "{{ hostvars['assisted_installer']['host'] }}" + - ASSISTED_INSTALLER_PORT: "{{ hostvars['assisted_installer']['port'] }}" + - CLUSTER_ID: "{{ cluster_id }}" + +- name: Monitoring cluster installation + hosts: bastion + gather_facts: False + roles: + - monitor_cluster + vars: + - debug: True + - ASSISTED_INSTALLER_HOST: "{{ hostvars['assisted_installer']['host'] }}" + - ASSISTED_INSTALLER_PORT: "{{ hostvars['assisted_installer']['port'] }}" + - CLUSTER_ID: "{{ cluster_id }}" diff --git a/playbooks/validate_inventory.yml b/playbooks/validate_inventory.yml new file mode 100644 index 00000000..d18ac608 --- /dev/null +++ b/playbooks/validate_inventory.yml @@ -0,0 +1,6 @@ +--- +- name: Validate Inventory + hosts: localhost + gather_facts: False + roles: + - validate_inventory diff --git a/post_install.yml b/post_install.yml new file mode 100644 index 00000000..f2461256 --- /dev/null +++ b/post_install.yml @@ -0,0 +1,45 @@ +--- +- name: Get kubeconfig + hosts: bastion + vars: + secure: false + ASSISTED_INSTALLER_BASE_URL: "{{ secure | ternary('https', 'http') }}://{{ hostvars['assisted_installer']['host'] }}:{{ hostvars['assisted_installer']['port'] }}/api/assisted-install/v1" + URL_ASSISTED_INSTALLER_CLUSTER: "{{ ASSISTED_INSTALLER_BASE_URL }}/clusters/{{ cluster_id }}" + kube_filename: "{{ kubeconfig_dest_filename | default('kubeconfig') }}" + dest_dir: "{{ kubeconfig_dest_dir | default(ansible_env.HOME) }}" + kubeconfig_path: "{{ dest_dir }}/{{ kube_filename }}" + tasks: + - name: Download kubeconfig + get_url: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTER }}/downloads/kubeconfig" + dest: "{{ kubeconfig_path }}" + mode: 0664 + + - name: Check kubectl + shell: + cmd: "kubectl --kubeconfig {{ kubeconfig_path }} explain pods" + + - name: Check cluster + block: + - name: Wait up to 10 mins for cluster to become functional + shell: + cmd: oc --kubeconfig {{ kubeconfig_path }} wait clusteroperators --all --for=condition=Available --timeout=10m + rescue: + - name: Get better info for failure message + shell: oc --kubeconfig {{ kubeconfig_path }} get co + register: co_result + + - fail: + msg: | + Cluster has not come up correctly: + {{ co_result.stdout }} + + - name: Get credentials + uri: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTER }}/credentials" + return_content: yes + register: credentials + + - name: Login to add token to kubeconfig + shell: + cmd: "oc --kubeconfig {{ kubeconfig_path }} login -u {{ credentials.json.username }} -p '{{ credentials.json.password }}'" diff --git a/prereq_facts_check.yml b/prereq_facts_check.yml new file mode 100644 index 00000000..1c6271f3 --- /dev/null +++ b/prereq_facts_check.yml @@ -0,0 +1,11 @@ +--- +- hosts: localhost + connection: local + strategy: free + become: false + gather_facts: false + roles: + - prereq_facts_check + vars: + ssh_public_check: "{{ not (generate_ssh_keys | default(False)) }}" + mirror_certificate_check: "{{ ((use_local_mirror_registry | default(False)) == True) and ((setup_registry_service | default(True)) == False) }}" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..d065208f --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +netaddr==0.8.0 diff --git a/requirements.yml b/requirements.yml new file mode 100644 index 00000000..7a43bfd2 --- /dev/null +++ b/requirements.yml @@ -0,0 +1,7 @@ +--- +collections: + - ansible.posix + - containers.podman + - community.crypto + - community.general + - community.libvirt diff --git a/roles/add_day2_node/defaults/main.yml b/roles/add_day2_node/defaults/main.yml new file mode 100644 index 00000000..3e34c289 --- /dev/null +++ b/roles/add_day2_node/defaults/main.yml @@ -0,0 +1,5 @@ +secure: false +ASSISTED_INSTALLER_HOST: "{{ hostvars['assisted_installer']['host'] }}" +ASSISTED_INSTALLER_PORT: "{{ hostvars['assisted_installer']['port'] }}" +ASSISTED_INSTALLER_BASE_URL: "{{ secure | ternary('https', 'http') }}://{{ ASSISTED_INSTALLER_HOST }}:{{ ASSISTED_INSTALLER_PORT }}/api/assisted-install/v1" +URL_ASSISTED_INSTALLER_CLUSTER: "{{ ASSISTED_INSTALLER_BASE_URL }}/clusters/{{ ADD_HOST_CLUSTER_ID }}" diff --git a/roles/add_day2_node/tasks/main.yml b/roles/add_day2_node/tasks/main.yml new file mode 100644 index 00000000..5c8efd99 --- /dev/null +++ b/roles/add_day2_node/tasks/main.yml @@ -0,0 +1,53 @@ +- name: "Boot ISO for {{inventory_hostname }}" + import_role: + name: boot_iso + vars: + - debug: "{{ debug }}" + - discovery_iso_name: "{{ day2_discovery_iso_name }}" + - boot_iso_url: "{{ discovery_iso_server }}/{{ day2_discovery_iso_name }}" + +- name: "Install node: {{inventory_hostname }}" + delegate_to: bastion + block: + - name: "Wait for 20 min until {{inventory_hostname }} discovered" + uri: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTER }}" + method: GET + status_code: [200, 201] + return_content: True + register: cluster + until: inventory_hostname in (cluster.json.hosts | map(attribute='requested_hostname')) + retries: 20 + delay: 60 + + - name: "Wait for 20 min until {{inventory_hostname }} to be ready" + uri: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTER }}" + method: GET + status_code: [200, 201] + return_content: True + register: cluster + until: "'known' in (cluster.json.hosts | selectattr('requested_hostname', 'equalto', inventory_hostname) | map(attribute='status') | list )" + retries: 20 + delay: 60 + + - name: Install cluster + uri: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTER }}/actions/install_hosts" + method: POST + status_code: [202] + return_content: True + body_format: json + body: {} + register: http_reply + + - name: Wait for 20 min until installation is complete + uri: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTER }}" + method: GET + status_code: [200, 201] + return_content: True + register: cluster + until: ( cluster.json.hosts | map(attribute='status') | list | unique == ['added-to-existing-cluster'] ) + retries: 60 + delay: 60 diff --git a/roles/approve_csrs/defaults/main.yml b/roles/approve_csrs/defaults/main.yml new file mode 100644 index 00000000..a220c9d6 --- /dev/null +++ b/roles/approve_csrs/defaults/main.yml @@ -0,0 +1,4 @@ +kube_filename: "{{ kubeconfig_dest_filename | default('kubeconfig') }}" +dest_dir: "{{ kubeconfig_dest_dir | default(ansible_env.HOME) }}" +kubeconfig_path: "{{ dest_dir }}/{{ kube_filename }}" +debug: false diff --git a/roles/approve_csrs/tasks/main.yml b/roles/approve_csrs/tasks/main.yml new file mode 100644 index 00000000..49efa5b7 --- /dev/null +++ b/roles/approve_csrs/tasks/main.yml @@ -0,0 +1,7 @@ +- name: Wait up to an 60 mins for CSRs to be approved + shell: + cmd: "export KUBECONFIG={{kubeconfig_path}} && oc get csr | grep -i pending | cut -f 1 -d ' ' | xargs -n 1 oc adm certificate approve &> /dev/null ; oc get nodes -o json" + register: oc_nodes + until: "(groups['day2_workers'] | difference(oc_nodes.stdout | default('{}') | from_json | json_query('items[].metadata.name') | list )) | length == 0" + retries: 60 + delay: 60 diff --git a/roles/boot_disk/defaults/main.yml b/roles/boot_disk/defaults/main.yml new file mode 100644 index 00000000..cbe7abd7 --- /dev/null +++ b/roles/boot_disk/defaults/main.yml @@ -0,0 +1,4 @@ +--- +# defaults file for boot_disk +boot_iso_url: "{{ discovery_iso_server }}/{{ discovery_iso_name }}" +debug: false diff --git a/roles/boot_disk/meta/main.yml b/roles/boot_disk/meta/main.yml new file mode 100644 index 00000000..8215dc00 --- /dev/null +++ b/roles/boot_disk/meta/main.yml @@ -0,0 +1,9 @@ +galaxy_info: + author: Hanen Garcia + description: Boot from disk + company: Red Hat, Inc. + license: Apache License, Version 2.0 + min_ansible_version: 2.9 + galaxy_tags: [] +dependencies: + - role: validate_inventory diff --git a/roles/boot_disk/tasks/dell.yml b/roles/boot_disk/tasks/dell.yml new file mode 100644 index 00000000..58b69beb --- /dev/null +++ b/roles/boot_disk/tasks/dell.yml @@ -0,0 +1,31 @@ +--- +- name: Discovery iDRAC versions for Dell hardware + containers.podman.podman_container: + name: "{{ hostvars[item]['bmc_address'] }}-rac-version" + network: host + image: quay.io/dphillip/racadm-image + state: started + detach: false + rm: true + command: + [ + "-v", + "-r", + "{{ hostvars[item]['bmc_address'] }}", + "-u", + "{{ hostvars[item]['bmc_user'] }}", + "-p", + "{{ hostvars[item]['bmc_password'] }}", + "-i", + "{{ boot_iso_url }}", + ] + register: drac_version + +- name: Using iDRAC ISO method for 13G and below + fail: + msg: "Not implemented" + when: drac_version.stdout | int <= 13 + +- name: Using iDRAC ISO method for 13G and below + include_tasks: dell_redfish.yml + when: drac_version.stdout | int > 13 diff --git a/roles/boot_disk/tasks/dell_redfish.yml b/roles/boot_disk/tasks/dell_redfish.yml new file mode 100644 index 00000000..c947991f --- /dev/null +++ b/roles/boot_disk/tasks/dell_redfish.yml @@ -0,0 +1,37 @@ +--- +- name: DELL Power ON + community.general.redfish_command: + category: Systems + command: PowerOn + baseuri: "{{ hostvars[item]['bmc_address'] }}" + username: "{{ hostvars[item]['bmc_user'] }}" + password: "{{ hostvars[item]['bmc_password'] }}" + +- name: DELL Eject Virtual Media (if any) + community.general.redfish_command: + category: Manager + command: VirtualMediaEject + baseuri: "{{ hostvars[item]['bmc_address'] }}" + username: "{{ hostvars[item]['bmc_user'] }}" + password: "{{ hostvars[item]['bmc_password'] }}" + virtual_media: + image_url: "{{ boot_iso_url }}" + resource_id: iDRAC.Embedded.1 + ignore_errors: yes + +- name: Set Dell OneTimeBoot Hdd + community.general.redfish_command: + category: Systems + command: SetOneTimeBoot + bootdevice: Hdd + baseuri: "{{ hostvars[item]['bmc_address'] }}" + username: "{{ hostvars[item]['bmc_user'] }}" + password: "{{ hostvars[item]['bmc_password'] }}" + +- name: DELL Restart system power gracefully + community.general.redfish_command: + category: Systems + command: PowerGracefulRestart + baseuri: "{{ hostvars[item]['bmc_address'] }}" + username: "{{ hostvars[item]['bmc_user'] }}" + password: "{{ hostvars[item]['bmc_password'] }}" diff --git a/roles/boot_disk/tasks/kvm.yml b/roles/boot_disk/tasks/kvm.yml new file mode 100644 index 00000000..bdfe0740 --- /dev/null +++ b/roles/boot_disk/tasks/kvm.yml @@ -0,0 +1,90 @@ +--- +# +# Virtual Redfish BMC +# https://docs.openstack.org/sushy-tools/latest/user/dynamic-emulator.html#uefi-boot +# +# Mount Live ISO, Boot into Live ISO (KVM only) +- name: set base url + set_fact: + base_bmc_address: "http://{{ hostvars[item]['bmc_address'] }}" + +- name: Identify System Manager + uri: + url: "{{ base_bmc_address }}/redfish/v1/Systems/{{ hostvars[item]['inventory_hostname'] }}" + user: "{{ hostvars[item]['bmc_user'] }}" + password: "{{ hostvars[item]['bmc_password'] }}" + method: GET + status_code: [200, 201] + validate_certs: no + return_content: yes + register: redfish_reply + +- name: KVM Set System UUID + set_fact: + system_uuid: "{{ redfish_reply.json['@odata.id'] }}" + system_manager: "{{ redfish_reply.json.Links.ManagedBy[0]['@odata.id'] }}" + +- name: KVM Force Power Off System {{ item }} + uri: + url: "{{ base_bmc_address }}{{ system_uuid }}/Actions/ComputerSystem.Reset" + user: "{{ hostvars[item]['bmc_user'] }}" + password: "{{ hostvars[item]['bmc_password'] }}" + method: POST + body_format: json + body: { "ResetType": "ForceOff" } + status_code: [200, 204] + force_basic_auth: no + validate_certs: no + return_content: yes + register: redfish_poweroff + ignore_errors: yes + +- name: KVM Eject Virtual Media (if any) {{ item }} + uri: + url: "{{ base_bmc_address }}{{ system_manager }}/VirtualMedia/Cd/Actions/VirtualMedia.EjectMedia" + user: "{{ hostvars[item]['bmc_user'] }}" + password: "{{ hostvars[item]['bmc_password'] }}" + method: POST + body_format: json + body: {} + status_code: [200, 204] + validate_certs: no + return_content: yes + register: redfish_reply + ignore_errors: yes + +- name: KVM Set Next Boot from HDD {{ item }} + uri: + url: "{{ base_bmc_address }}{{ system_uuid }}" + user: "{{ hostvars[item]['bmc_user'] }}" + password: "{{ hostvars[item]['bmc_password'] }}" + method: PATCH + body_format: json + body: + { + "Boot": + { + "BootSourceOverrideTarget": "Hdd", + "BootSourceOverrideMode": "UEFI", + "BootSourceOverrideEnabled": "Continuous", + }, + } + status_code: [200, 204] + force_basic_auth: no + validate_certs: no + return_content: yes + register: redfish_reply + +- name: KVM Force Power On System {{ item }} + uri: + url: "{{ base_bmc_address }}{{ system_uuid }}/Actions/ComputerSystem.Reset" + user: "{{ hostvars[item]['bmc_user'] }}" + password: "{{ hostvars[item]['bmc_password'] }}" + method: POST + body_format: json + body: { "ResetType": "ForceOn" } + status_code: [200, 204] + force_basic_auth: no + validate_certs: no + return_content: yes + register: redfish_poweron diff --git a/roles/boot_disk/tasks/lenovo.yml b/roles/boot_disk/tasks/lenovo.yml new file mode 100644 index 00000000..9bba644e --- /dev/null +++ b/roles/boot_disk/tasks/lenovo.yml @@ -0,0 +1,53 @@ +--- +- name: Mount Live ISO, Boot into Live ISO (Lenovo servers) + block: + + - name: Lenovo Eject Virtual Media {{ item }} + uri: + url: "https://{{ hostvars[item]['bmc_address'] }}/redfish/v1/Managers/1/VirtualMedia/EXT1" + user: "{{ hostvars[item]['bmc_user'] }}" + password: "{{ hostvars[item]['bmc_password'] }}" + method: PATCH + body_format: json + body: {"Image": null, "Inserted": false} + status_code: [200, 204] + force_basic_auth: yes + validate_certs: no + return_content: yes + register: redfish_reply + + - name: Lenovo Set Boot from Hard Disk {{ item }} + uri: + url: "https://{{ hostvars[item]['bmc_address'] }}/redfish/v1/Systems/1" + user: "{{ hostvars[item]['bmc_user'] }}" + password: "{{ hostvars[item]['bmc_password'] }}" + method: PATCH + body_format: json + body: { + "Boot": { + "BootSourceOverrideEnabled": "Disabled", + "BootSourceOverrideMode": "UEFI", + "BootSourceOverrideTarget": "Hdd", + "UefiTargetBootSourceOverride": null + } + } + status_code: [200, 204] + force_basic_auth: yes + validate_certs: no + return_content: yes + register: redfish_reply + + - name: Lenovo Restart the System {{ item }} + uri: + url: "https://{{ hostvars[item]['bmc_address'] }}/redfish/v1/Systems/1/Actions/ComputerSystem.Reset" + user: "{{ hostvars[item]['bmc_user'] }}" + password: "{{ hostvars[item]['bmc_password'] }}" + method: POST + body_format: json + body: {"ResetType": "ForceRestart"} + status_code: [200, 204] + force_basic_auth: yes + validate_certs: no + return_content: yes + register: redfish_reply + diff --git a/roles/boot_disk/tasks/main.yml b/roles/boot_disk/tasks/main.yml new file mode 100644 index 00000000..56ee390e --- /dev/null +++ b/roles/boot_disk/tasks/main.yml @@ -0,0 +1,27 @@ +--- +# tasks file for boot_disk + +- name: Join list for workers and masters + set_fact: + hosts: "{{ groups['masters'] + groups['workers'] | default([]) }}" + when: hosts is not defined + +- name: Reboot dell + include_tasks: dell.yml + loop: "{{ hosts }}" + when: hostvars[item]['vendor'] == 'Dell' + +- name: Reboot lenovo + include_tasks: lenovo.yml + loop: "{{ hosts }}" + when: hostvars[item]['vendor'] == 'Lenovo' + +- name: Reboot kvm + include_tasks: kvm.yml + loop: "{{ hosts }}" + when: hostvars[item]['vendor'] == 'KVM' + +- name: Reboot SuperMicro + include_tasks: supermicro.yml + loop: "{{ hosts }}" + when: hostvars[item]['vendor'] == 'SuperMicro' diff --git a/roles/boot_disk/tasks/supermicro.yml b/roles/boot_disk/tasks/supermicro.yml new file mode 100644 index 00000000..697f3494 --- /dev/null +++ b/roles/boot_disk/tasks/supermicro.yml @@ -0,0 +1,67 @@ +--- +- name: UnMount Live ISO, Boot into HDD (SuperMicro servers) + block: + - name: SuperMicro Power ON + community.general.redfish_command: + category: Systems + command: PowerOn + baseuri: "{{ hostvars[item]['bmc_address'] }}" + username: "{{ hostvars[item]['bmc_user'] }}" + password: "{{ hostvars[item]['bmc_password'] }}" + + - name: Check if there is a ISO mounted on the SuperMicro + uri: + url: "https://{{ hostvars[item]['bmc_address'] }}/redfish/v1/Managers/1/VirtualMedia/CD1" + user: "{{ hostvars[item]['bmc_user'] }}" + password: "{{ hostvars[item]['bmc_password'] }}" + method: GET + validate_certs: no + force_basic_auth: yes + return_content: yes + register: cd1_contents + + - name: Unmount SuperMicro ISO + uri: + url: "https://{{ hostvars[item]['bmc_address'] }}/redfish/v1/Managers/1/VirtualMedia/CD1/Actions/VirtualMedia.EjectMedia" + user: "{{ hostvars[item]['bmc_user'] }}" + password: "{{ hostvars[item]['bmc_password'] }}" + method: POST + headers: + content-type: application/json + Accept: application/json + body: {} + body_format: json + validate_certs: no + force_basic_auth: yes + return_content: yes + when: cd1_contents.json.Inserted | bool == True + + - name: Set Boot for the SuperMicro + uri: + url: "https://{{ hostvars[item]['bmc_address'] }}/redfish/v1/Systems/1" + user: "{{ hostvars[item]['bmc_user'] }}" + password: "{{ hostvars[item]['bmc_password'] }}" + method: PATCH + headers: + content-type: application/json + Accept: application/json + body: '{"Boot":{"BootSourceOverrideEnabled":"Continuous","BootSourceOverrideTarget":"Hdd"}}' # Despite what the docs say HDD is not the correct value + body_format: json + force_basic_auth: yes + validate_certs: no + return_content: yes + + - name: Restart the SuperMicro + uri: + url: "https://{{ hostvars[item]['bmc_address'] }}/redfish/v1/Systems/1/Actions/ComputerSystem.Reset" + user: "{{ hostvars[item]['bmc_user'] }}" + password: "{{ hostvars[item]['bmc_password'] }}" + method: POST + headers: + content-type: application/json + Accept: application/json + body: '{"ResetType": "ForceRestart"}' + body_format: json + force_basic_auth: yes + validate_certs: no + return_content: yes diff --git a/roles/boot_iso/defaults/main.yml b/roles/boot_iso/defaults/main.yml new file mode 100644 index 00000000..1e062ff5 --- /dev/null +++ b/roles/boot_iso/defaults/main.yml @@ -0,0 +1,4 @@ +--- +# defaults file for boot_iso +boot_iso_url: "{{ discovery_iso_server }}/{{ discovery_iso_name }}" +debug: false diff --git a/roles/boot_iso/handlers/main.yml b/roles/boot_iso/handlers/main.yml new file mode 100644 index 00000000..e9656f72 --- /dev/null +++ b/roles/boot_iso/handlers/main.yml @@ -0,0 +1,2 @@ +--- +# handlers file for boot_iso diff --git a/roles/boot_iso/meta/main.yml b/roles/boot_iso/meta/main.yml new file mode 100644 index 00000000..38be1629 --- /dev/null +++ b/roles/boot_iso/meta/main.yml @@ -0,0 +1,9 @@ +galaxy_info: + author: Roger Lopez + description: Boot the Discovery ISO + company: Red Hat, Inc. + license: Apache License, Version 2.0 + min_ansible_version: 2.9 + galaxy_tags: [] +dependencies: + - role: validate_inventory diff --git a/roles/boot_iso/tasks/dell.yml b/roles/boot_iso/tasks/dell.yml new file mode 100644 index 00000000..e20fd418 --- /dev/null +++ b/roles/boot_iso/tasks/dell.yml @@ -0,0 +1,30 @@ +--- +- name: Discovery iDRAC versions for Dell hardware + containers.podman.podman_container: + name: "{{ hostvars[inventory_hostname]['bmc_address'] }}-rac-version" + network: host + image: quay.io/dphillip/racadm-image + state: started + detach: false + rm: true + command: + [ + "-v", + "-r", + "{{ hostvars[inventory_hostname]['bmc_address'] }}", + "-u", + "{{ hostvars[inventory_hostname]['bmc_user'] }}", + "-p", + "{{ hostvars[inventory_hostname]['bmc_password'] }}", + "-i", + "{{ boot_iso_url }}", + ] + register: drac_version + +- name: Using iDRAC ISO method for 13G and below + include_tasks: dell_idrac.yml + when: drac_version.stdout | int <= 13 + +- name: Using iDRAC ISO method for 13G and below + include_tasks: dell_redfish.yml + when: drac_version.stdout | int > 13 diff --git a/roles/boot_iso/tasks/dell_idrac.yml b/roles/boot_iso/tasks/dell_idrac.yml new file mode 100644 index 00000000..955e8f5d --- /dev/null +++ b/roles/boot_iso/tasks/dell_idrac.yml @@ -0,0 +1,11 @@ +--- +- name: Mount Live ISO, Boot into Live ISO (Dell 13G iDRAC8 and below) + block: + - name: Racadm container to mount and boot to discovery ISO + containers.podman.podman_container: + name: "{{ hostvars[inventory_hostname]['bmc_address'] }}-rac-image" + network: host + image: quay.io/dphillip/racadm-image + state: started + rm: true + command: ["-r", "{{ hostvars[inventory_hostname]['bmc_address'] }}", "-u", "{{ hostvars[inventory_hostname]['bmc_user'] }}", "-p", "{{ hostvars[inventory_hostname]['bmc_password'] }}", "-i", "{{ boot_iso_url }}"] diff --git a/roles/boot_iso/tasks/dell_redfish.yml b/roles/boot_iso/tasks/dell_redfish.yml new file mode 100644 index 00000000..79002968 --- /dev/null +++ b/roles/boot_iso/tasks/dell_redfish.yml @@ -0,0 +1,103 @@ +--- +- name: DELL Power ON + community.general.redfish_command: + category: Systems + command: PowerOn + baseuri: "{{ hostvars[inventory_hostname]['bmc_address'] }}" + username: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + +- block: + - name: Set Dell OneTimeBoot VirtualCD (VCD-DVD) + community.general.redfish_command: + category: Systems + command: SetOneTimeBoot + bootdevice: VCD-DVD + baseuri: "{{ hostvars[inventory_hostname]['bmc_address'] }}" + username: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + rescue: + - name: Set Dell OneTimeBoot VirtualCD (Cd) + community.general.redfish_command: + category: Systems + command: SetOneTimeBoot + bootdevice: Cd + baseuri: "{{ hostvars[inventory_hostname]['bmc_address'] }}" + username: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + +- block: + - name: DELL Eject Virtual Media (if any) + community.general.redfish_command: + category: Manager + command: VirtualMediaEject + baseuri: "{{ hostvars[inventory_hostname]['bmc_address'] }}" + username: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + virtual_media: + image_url: "{{ boot_iso_url }}" + resource_id: iDRAC.Embedded.1 + rescue: + - name: Get Virtual Media information + community.general.redfish_info: + category: Manager + command: GetVirtualMedia + baseuri: "{{ hostvars[inventory_hostname]['bmc_address'] }}" + username: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + register: result + + - name: Get blocking virtual_media + set_fact: + blocking_virtual_media: "{{ result.redfish_facts.virtual_media.entries + | flatten(levels=2) + | selectattr('ConnectedVia', 'defined') | list + | json_query('[?( + ConnectedVia == `URI` + && Image != null + && ( + contains(MediaTypes, `CD`) + || contains(MediaTypes, `DVD`) + || contains(MediaTypes, `VCD-DVD`) + ) + )]' + ) | from_yaml + }}" + + - debug: + var: blocking_virtual_media + when: debug | bool == True + + - name: Attempting to eject blocking media + community.general.redfish_command: + category: Manager + command: VirtualMediaEject + baseuri: "{{ hostvars[inventory_hostname]['bmc_address'] }}" + username: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + virtual_media: + image_url: "{{ item.Image }}" + resource_id: iDRAC.Embedded.1 + loop: "{{ blocking_virtual_media }}" + +- name: DELL Insert Virtual Media + community.general.redfish_command: + category: Manager + command: VirtualMediaInsert + baseuri: "{{ hostvars[inventory_hostname]['bmc_address'] }}" + username: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + virtual_media: + image_url: '{{ boot_iso_url }}' + media_types: + - CD + - DVD + resource_id: iDRAC.Embedded.1 + +- name: DELL Restart system power gracefully + community.general.redfish_command: + category: Systems + command: PowerGracefulRestart + baseuri: "{{ hostvars[inventory_hostname]['bmc_address'] }}" + username: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" diff --git a/roles/boot_iso/tasks/hpe.yml b/roles/boot_iso/tasks/hpe.yml new file mode 100644 index 00000000..350a49a5 --- /dev/null +++ b/roles/boot_iso/tasks/hpe.yml @@ -0,0 +1,24 @@ +--- +- name: Mount Live ISO, Boot into Live ISO (HPE servers) + block: + - name: HPE poweroff system + hpilo_boot: + host: "{{ hostvars[inventory_hostname]['bmc_address'] }}" + login: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + state: "poweroff" + + - name: HPE disconnect existing Virtual Media + hpilo_boot: + host: "{{ hostvars[inventory_hostname]['bmc_address'] }}" + login: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + state: "disconnect" + + - name: HPE task to boot a system using an ISO + hpilo_boot: + host: "{{ hostvars[inventory_hostname]['bmc_address'] }}" + login: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + image: "{{ boot_iso_url }}" + media: cdrom diff --git a/roles/boot_iso/tasks/kvm.yml b/roles/boot_iso/tasks/kvm.yml new file mode 100644 index 00000000..a8bed725 --- /dev/null +++ b/roles/boot_iso/tasks/kvm.yml @@ -0,0 +1,173 @@ +--- +# +# Virtual Redfish BMC +# https://docs.openstack.org/sushy-tools/latest/user/dynamic-emulator.html#uefi-boot +# +- name: set base url + set_fact: + base_bmc_address: "http://{{ hostvars[inventory_hostname]['bmc_address'] }}" + +- name: Identify System Manager + uri: + url: "{{ base_bmc_address }}/redfish/v1/Systems/{{ inventory_hostname }}" + user: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + method: GET + status_code: [200, 201] + validate_certs: no + return_content: yes + register: http_reply + +- debug: + msg: "{{ base_bmc_address }}/redfish/v1/Systems/{{ inventory_hostname }}" + when: debug | bool == true + +- debug: + var: http_reply + when: debug | bool == True + +- name: KVM Set System UUID + set_fact: + system_uuid: "{{ http_reply.json['@odata.id'] }}" + system_manager: "{{ http_reply.json.Links.ManagedBy[0]['@odata.id'] }}" + +- name: KVM Force Power Off System {{ inventory_hostname }} + uri: + url: "{{ base_bmc_address }}{{ system_uuid }}/Actions/ComputerSystem.Reset" + user: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + method: POST + body_format: json + body: { "ResetType": "ForceOff" } + status_code: [200, 204] + force_basic_auth: no + validate_certs: no + return_content: yes + register: redfish_poweroff + ignore_errors: yes + +- name: KVM Eject Virtual Media (if any) {{ inventory_hostname }} + uri: + url: "{{ base_bmc_address }}{{ system_manager }}/VirtualMedia/Cd/Actions/VirtualMedia.EjectMedia" + user: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + method: POST + body_format: json + body: {} + status_code: [200, 204] + validate_certs: no + return_content: yes + register: redfish_reply + ignore_errors: yes + +- debug: + var: redfish_reply + when: debug | bool == True + +- name: KVM Insert Virtual Media {{ inventory_hostname }} + uri: + url: "{{ base_bmc_address }}{{ system_manager }}/VirtualMedia/Cd/Actions/VirtualMedia.InsertMedia" + user: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + method: POST + body_format: json + body: { "Image": "{{ boot_iso_url }}", "Inserted": true } + status_code: [200, 204] + force_basic_auth: no + validate_certs: no + return_content: yes + register: redfish_reply + until: "redfish_reply.status == 204" + retries: 20 + delay: 30 + +- debug: + var: redfish_reply + when: debug | bool == True + +- name: KVM Verify Virtual Media {{ inventory_hostname }} + uri: + url: "{{ base_bmc_address }}{{ system_manager }}/VirtualMedia/Cd" + user: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + method: GET + status_code: [200, 201] + validate_certs: no + return_content: yes + register: redfish_reply + when: debug | bool == True + +- debug: + var: redfish_reply + when: debug | bool == True + +- name: KVM Set OneTimeBoot Virtual Media {{ inventory_hostname }} + uri: + url: "{{ base_bmc_address }}{{ system_uuid }}" + user: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + method: PATCH + body_format: json + body: + { + "Boot": + { + "BootSourceOverrideTarget": "Cd", + "BootSourceOverrideMode": "UEFI", + "BootSourceOverrideEnabled": "Continuous", + }, + } + status_code: [200, 204] + force_basic_auth: no + validate_certs: no + return_content: yes + register: redfish_reply + +- debug: + var: redfish_reply + when: debug | bool == True + +- name: KVM Verify System Power State {{ inventory_hostname }} + uri: + url: "{{ base_bmc_address }}{{ system_uuid }}" + user: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + method: GET + status_code: [200, 201] + validate_certs: no + return_content: yes + register: redfish_reply + +- debug: + var: redfish_reply + when: debug | bool == True + +- name: KVM Force Restart System {{ inventory_hostname }} + uri: + url: "{{ base_bmc_address }}{{ system_uuid }}/Actions/ComputerSystem.Reset" + user: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + method: POST + body_format: json + body: { "ResetType": "ForceRestart" } + status_code: [200, 204] + force_basic_auth: no + validate_certs: no + return_content: yes + register: redfish_restart + when: redfish_reply.json.PowerState == "On" + +- name: KVM Force Power On System {{ inventory_hostname }} + uri: + url: "{{ base_bmc_address }}{{ system_uuid }}/Actions/ComputerSystem.Reset" + user: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + method: POST + body_format: json + body: { "ResetType": "ForceOn" } + status_code: [200, 204] + force_basic_auth: no + validate_certs: no + return_content: yes + register: redfish_poweron + when: redfish_reply.json.PowerState == "Off" diff --git a/roles/boot_iso/tasks/lenovo.yml b/roles/boot_iso/tasks/lenovo.yml new file mode 100644 index 00000000..48353d71 --- /dev/null +++ b/roles/boot_iso/tasks/lenovo.yml @@ -0,0 +1,125 @@ +--- +- name: Mount Live ISO, Boot into Live ISO (Lenovo servers) + block: + + - name: Lenovo Power On the System {{ inventory_hostname }} + uri: + url: "https://{{ hostvars[inventory_hostname]['bmc_address'] }}/redfish/v1/Systems/1/Actions/ComputerSystem.Reset" + user: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + method: POST + body_format: json + body: {"ResetType": "On"} + status_code: [200, 204] + force_basic_auth: yes + validate_certs: no + return_content: yes + register: redfish_reply + + - debug: + var: redfish_reply + when: debug | bool == True + + - name: Lenovo Eject Virtual Media {{ inventory_hostname }} + uri: + url: "https://{{ hostvars[inventory_hostname]['bmc_address'] }}/redfish/v1/Managers/1/VirtualMedia/EXT1" + user: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + method: PATCH + body_format: json + body: {"Image": null, "Inserted": false} + status_code: [200, 204] + force_basic_auth: yes + validate_certs: no + return_content: yes + register: redfish_reply + + - debug: + var: redfish_reply + when: debug | bool == True + + - name: Lenovo Insert Virtual Media {{ inventory_hostname }} + uri: + url: "https://{{ hostvars[inventory_hostname]['bmc_address'] }}/redfish/v1/Managers/1/VirtualMedia/EXT1" + user: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + method: PATCH + body_format: json + body: {"Image":"{{ boot_iso_url }}", "Inserted": true} + status_code: [200, 204] + force_basic_auth: yes + validate_certs: no + return_content: yes + register: redfish_reply + + - debug: + var: redfish_reply + when: debug | bool == True + + - name: Lenovo Set Boot from Hard Disk {{ inventory_hostname }} + uri: + url: "https://{{ hostvars[inventory_hostname]['bmc_address'] }}/redfish/v1/Systems/1" + user: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + method: PATCH + body_format: json + body: { + "Boot": { + "BootSourceOverrideEnabled": "Disabled", + "BootSourceOverrideMode": "UEFI", + "BootSourceOverrideTarget": "Hdd", + "UefiTargetBootSourceOverride": null + } + } + status_code: [200, 204] + force_basic_auth: yes + validate_certs: no + return_content: yes + register: redfish_reply + + - debug: + var: redfish_reply + when: debug | bool == True + + - name: Lenovo Set Boot Once from Virtual Media {{ inventory_hostname }} + uri: + url: "https://{{ hostvars[inventory_hostname]['bmc_address'] }}/redfish/v1/Systems/1" + user: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + method: PATCH + body_format: json + body: { + "Boot": { + "BootSourceOverrideEnabled": "Once", + "BootSourceOverrideMode": "UEFI", + "BootSourceOverrideTarget": "UefiTarget", + "UefiTargetBootSourceOverride": "EXT1" + } + } + status_code: [200, 204] + force_basic_auth: yes + validate_certs: no + return_content: yes + register: redfish_reply + + - debug: + var: redfish_reply + when: debug | bool == True + + - name: Lenovo Restart the System {{ inventory_hostname }} + uri: + url: "https://{{ hostvars[inventory_hostname]['bmc_address'] }}/redfish/v1/Systems/1/Actions/ComputerSystem.Reset" + user: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + method: POST + body_format: json + body: {"ResetType": "ForceRestart"} + status_code: [200, 204] + force_basic_auth: yes + validate_certs: no + return_content: yes + register: redfish_reply + + - debug: + var: redfish_reply + when: debug | bool == True diff --git a/roles/boot_iso/tasks/main.yml b/roles/boot_iso/tasks/main.yml new file mode 100644 index 00000000..c3350bd2 --- /dev/null +++ b/roles/boot_iso/tasks/main.yml @@ -0,0 +1,38 @@ +--- +# tasks file for boot_iso + +- name: "Fail playbook without boot_iso_url" + fail: + msg="Missing argument: this playbook requires 'boot_iso' to be defined with the URL of the ISO to boot the systems" + when: boot_iso_url is not defined + delegate_to: bastion + +- include_tasks: dell.yml + when: hostvars[inventory_hostname]['vendor'] == 'Dell' + args: + apply: + delegate_to: bastion + +- include_tasks: hpe.yml + when: hostvars[inventory_hostname]['vendor'] == 'HPE' + args: + apply: + delegate_to: bastion + +- include_tasks: supermicro.yml + when: hostvars[inventory_hostname]['vendor'] == 'SuperMicro' + args: + apply: + delegate_to: bastion + +- include_tasks: lenovo.yml + when: hostvars[inventory_hostname]['vendor'] == 'Lenovo' + args: + apply: + delegate_to: bastion + +- include_tasks: kvm.yml + when: hostvars[inventory_hostname]['vendor'] == 'KVM' + args: + apply: + delegate_to: bastion diff --git a/roles/boot_iso/tasks/supermicro.yml b/roles/boot_iso/tasks/supermicro.yml new file mode 100644 index 00000000..4f02fefb --- /dev/null +++ b/roles/boot_iso/tasks/supermicro.yml @@ -0,0 +1,89 @@ +--- +- name: Mount Live ISO, Boot into Live ISO (SuperMicro servers) + block: + - name: Check boot iso is not in webroot + assert: + that: + - boot_iso_url is match("https?://.+/.+/.+") + fail_msg: "Boot iso should not be in webroot" + + - name: SuperMicro Power ON + community.general.redfish_command: + category: Systems + command: PowerOn + baseuri: "{{ hostvars[inventory_hostname]['bmc_address'] }}" + username: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + + - name: Check if there is a ISO mounted on the SuperMicro + uri: + url: "https://{{ hostvars[inventory_hostname]['bmc_address'] }}/redfish/v1/Managers/1/VirtualMedia/CD1" + user: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + method: GET + validate_certs: no + force_basic_auth: yes + return_content: yes + register: cd1_contents + + - name: Unmount SuperMicro ISO + uri: + url: "https://{{ hostvars[inventory_hostname]['bmc_address'] }}/redfish/v1/Managers/1/VirtualMedia/CD1/Actions/VirtualMedia.EjectMedia" + user: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + method: POST + headers: + content-type: application/json + Accept: application/json + body: {} + body_format: json + validate_certs: no + force_basic_auth: yes + return_content: yes + when: cd1_contents.json.Inserted | bool == True + + - name: Mount SuperMicro ISO + uri: + url: "https://{{ hostvars[inventory_hostname]['bmc_address'] }}/redfish/v1/Managers/1/VirtualMedia/CD1/Actions/VirtualMedia.InsertMedia" + user: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + method: POST + headers: + content-type: application/json + Accept: application/json + body: {"Image": "{{ boot_iso_url }}"} + body_format: json + validate_certs: no + force_basic_auth: yes + return_content: yes + status_code: 202 + + - name: Set Boot for the SuperMicro + uri: + url: "https://{{ hostvars[inventory_hostname]['bmc_address'] }}/redfish/v1/Systems/1" + user: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + method: PATCH + headers: + content-type: application/json + Accept: application/json + body: '{"Boot":{"BootSourceOverrideEnabled":"Once","BootSourceOverrideTarget":"UsbCd"}}' + body_format: json + force_basic_auth: yes + validate_certs: no + return_content: yes + + - name: Restart the SuperMicro + uri: + url: "https://{{ hostvars[inventory_hostname]['bmc_address'] }}/redfish/v1/Systems/1/Actions/ComputerSystem.Reset" + user: "{{ hostvars[inventory_hostname]['bmc_user'] }}" + password: "{{ hostvars[inventory_hostname]['bmc_password'] }}" + method: POST + headers: + content-type: application/json + Accept: application/json + body: '{"ResetType": "ForceRestart"}' + body_format: json + force_basic_auth: yes + validate_certs: no + return_content: yes diff --git a/roles/boot_iso/templates/vitual_media.json.j2 b/roles/boot_iso/templates/vitual_media.json.j2 new file mode 100644 index 00000000..89801aa2 --- /dev/null +++ b/roles/boot_iso/templates/vitual_media.json.j2 @@ -0,0 +1,4 @@ +{ + "Image": "{{ boot_iso_url }}", + "Inserted": true +} diff --git a/roles/boot_iso/tests/inventory b/roles/boot_iso/tests/inventory new file mode 100644 index 00000000..2fbb50c4 --- /dev/null +++ b/roles/boot_iso/tests/inventory @@ -0,0 +1 @@ +localhost diff --git a/roles/boot_iso/tests/test.yml b/roles/boot_iso/tests/test.yml new file mode 100644 index 00000000..a7807e23 --- /dev/null +++ b/roles/boot_iso/tests/test.yml @@ -0,0 +1,5 @@ +--- +- hosts: localhost + remote_user: root + roles: + - boot_iso diff --git a/roles/boot_iso/vars/main.yml b/roles/boot_iso/vars/main.yml new file mode 100644 index 00000000..941283ce --- /dev/null +++ b/roles/boot_iso/vars/main.yml @@ -0,0 +1,2 @@ +--- +# vars file for boot_iso diff --git a/roles/create_cluster/defaults/main.yml b/roles/create_cluster/defaults/main.yml new file mode 100644 index 00000000..b012171b --- /dev/null +++ b/roles/create_cluster/defaults/main.yml @@ -0,0 +1,26 @@ +--- +# defaults file for create_cluster + +secure: False +create: True +debug: False +disconnected: False +manifests: True +fetched_dest: ./fetched + +mirror_registry: "{{ hostvars['registry_host']['registry_fqdn'] }}:{{ hostvars['registry_host']['registry_port'] }}" + +ASSISTED_INSTALLER_HOST: "{{ ansible_default_ipv4.address|default(ansible_all_ipv4_addresses[0]) }}" +ASSISTED_INSTALLER_PORT: 8090 +ASSISTED_INSTALLER_BASE_URL: "{{ secure | ternary('https', 'http') }}://{{ ASSISTED_INSTALLER_HOST }}:{{ ASSISTED_INSTALLER_PORT }}/api/assisted-install/v1" +URL_ASSISTED_INSTALLER_CLUSTERS: "{{ ASSISTED_INSTALLER_BASE_URL }}/clusters" + +HTTP_PROXY: "" +HTTPS_PROXY: "" +NO_PROXY: "" + +manifest_templates: + - 50-worker-nm-fix-ipv6.yml + - 50-worker-remove-ipi-leftovers.yml + - 02-fix-ingress-config.yml + - 01-master-node-scheduler.yml diff --git a/roles/create_cluster/meta/main.yml b/roles/create_cluster/meta/main.yml new file mode 100644 index 00000000..085384d7 --- /dev/null +++ b/roles/create_cluster/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - role: validate_inventory diff --git a/roles/create_cluster/tasks/main.yml b/roles/create_cluster/tasks/main.yml new file mode 100644 index 00000000..06633b3b --- /dev/null +++ b/roles/create_cluster/tasks/main.yml @@ -0,0 +1,161 @@ +--- +# tasks file for create_cluster + +# TODO: use the variable for cluster_network_host_prefix +- name: Create cluster + uri: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTERS }}" + method: POST + url_username: "{{ HTTP_AUTH_USERNAME }}" + url_password: "{{ HTTP_AUTH_PASSWORD }}" + body_format: json + status_code: [201] + return_content: True + body: + { + "name": "{{ CLUSTER_NAME }}", + "openshift_version": "{{ OPENSHIFT_VERSION }}", + "base_dns_domain": "{{ BASE_DNS_DOMAIN }}", + "cluster_network_cidr": "{{ CLUSTER_NETWORK_CIDR }}", + "cluster_network_host_prefix": 23, + "service_network_cidr": "{{ SERVICE_NETWORK_CIDR }}", + "ingress_vip": "{{ INGRESS_VIP }}", + "pull_secret": "{{ PULL_SECRET | to_json }}", + "ssh_public_key": "{{ SSH_PUBLIC_KEY }}", + "vip_dhcp_allocation": "{{ VIP_DHCP_ALLOCATION | lower | bool }}", + "api_vip": "{{ API_VIP }}", + "http_proxy": "{{ HTTP_PROXY }}", + "https_proxy": "{{ HTTPS_PROXY }}", + "no_proxy": "{{ NO_PROXY }}", + "additional_ntp_source": "{{ NTP_SERVER }}", + } + when: create | bool == True + register: http_reply + +- debug: + var: http_reply.json + when: debug and create | bool == True + +- name: Set the cluster ID + set_fact: + cluster_id: "{{ http_reply.json.id }}" + when: create | bool == True + +- name: "Save cluster_id" + copy: + content: "{{ cluster_id }}" + dest: "{{ fetched_dest }}/cluster.txt" + delegate_to: localhost + become: no + when: create | bool == True + +#### patch discovery ignition on restricted network environments ### + +- name: Load patch for search registries + set_fact: + search_registries: "{{ lookup('template', 'patch-search-registries.j2') }}" + when: disconnected | bool == True + +- debug: + var: search_registries + when: debug and disconnected | bool == True + +- name: Load patch for discovery ignition + set_fact: + patch_discovery_ignition: "{{ lookup('template', 'patch-discovery-ignition.j2') }}" + when: disconnected | bool == True + +- debug: + var: patch_discovery_ignition + when: debug and disconnected | bool == True + +- name: Patch discovery ignition + uri: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTERS }}/{{ cluster_id }}/discovery-ignition" + method: PATCH + status_code: [201] + return_content: True + body_format: json + body: + { + "config": "{{ patch_discovery_ignition | to_json(ensure_ascii=False) | string }}", + } + when: disconnected | bool == True + register: http_reply + +- debug: + var: http_reply + when: debug and disconnected | bool == True + +#### patch cluster install config on restricted network environments ### + +- name: Get install-config file + uri: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTERS }}/{{ cluster_id }}/install-config" + method: GET + status_code: [200] + return_content: True + register: install_config + +- debug: + var: install_config.json + when: debug | bool == True + +- name: "Copy install_config" + copy: + content: "{{ install_config.json }}" + dest: "{{ fetched_dest }}/install-config.txt" + delegate_to: localhost + become: no + +- name: Load patch for install config + set_fact: + patch_install_config: "{{ lookup('template', 'patch-install-config.j2') | from_yaml }}" + when: disconnected | bool == True + +- name: Add network_type to patch_install_config + set_fact: + patch_install_config: "{{ lookup('template', 'patch-network-type.j2') | from_yaml | combine(patch_install_config | default({})) }}" + when: network_type is defined + +- debug: + var: patch_install_config + when: debug and patch_install_config is defined + +- name: Patch install config + uri: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTERS }}/{{ cluster_id }}/install-config" + method: PATCH + status_code: [201] + return_content: True + body_format: json + body: "{{ patch_install_config | to_json(ensure_ascii=False) | string | to_json(ensure_ascii=False) | string }}" + when: patch_install_config is defined + register: http_reply + +- debug: + var: http_reply + when: debug and patch_install_config is defined +- name: Get install-config file + uri: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTERS }}/{{ cluster_id }}/install-config" + method: GET + status_code: [200] + return_content: True + register: install_config + +- debug: + var: install_config.json + when: debug | bool == True + +- name: "Copy install_config-json to patched-config.txt" + copy: + content: "{{ install_config.json }}" + dest: "{{ fetched_dest }}/patched-config.txt" + delegate_to: localhost + become: no + +- name: Apply manifests before cluster installation + include_tasks: manifest.yml + loop: "{{ manifest_templates }}" + when: manifests | bool == True diff --git a/roles/create_cluster/tasks/manifest.yml b/roles/create_cluster/tasks/manifest.yml new file mode 100644 index 00000000..19178bb7 --- /dev/null +++ b/roles/create_cluster/tasks/manifest.yml @@ -0,0 +1,31 @@ +--- +# tasks file for manifests + +- name: Load manifest + set_fact: + manifest: "{{ lookup('template', '{{ item }}.j2' ) }}" + when: manifests | bool == True + +- debug: + var: manifest + when: debug and manifests | bool == True + +- name: Apply manifest + uri: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTERS }}/{{ cluster_id }}/manifests" + method: POST + status_code: [201] + return_content: True + body_format: json + body: + { + "folder": "manifests", + "file_name": "{{ item }}", + "content": "{{ manifest | string | b64encode }}", + } + when: manifests | bool == True + register: http_reply + +- debug: + var: http_reply + when: debug and manifests | bool == True diff --git a/roles/create_cluster/templates/01-master-node-scheduler.yml.j2 b/roles/create_cluster/templates/01-master-node-scheduler.yml.j2 new file mode 100644 index 00000000..312bd8e8 --- /dev/null +++ b/roles/create_cluster/templates/01-master-node-scheduler.yml.j2 @@ -0,0 +1,8 @@ +--- +apiVersion: config.openshift.io/v1 +kind: Scheduler +metadata: + creationTimestamp: null + name: cluster +spec: + mastersSchedulable: true diff --git a/roles/create_cluster/templates/02-fix-ingress-config.yml.j2 b/roles/create_cluster/templates/02-fix-ingress-config.yml.j2 new file mode 100644 index 00000000..83775688 --- /dev/null +++ b/roles/create_cluster/templates/02-fix-ingress-config.yml.j2 @@ -0,0 +1,12 @@ +--- +apiVersion: operator.openshift.io/v1 +kind: IngressController +metadata: + name: default + namespace: openshift-ingress-operator +spec: + nodePlacement: + nodeSelector: + matchLabels: + node-role.kubernetes.io/master: "" + replicas: 3 diff --git a/roles/create_cluster/templates/50-worker-nm-fix-ipv6.yml.j2 b/roles/create_cluster/templates/50-worker-nm-fix-ipv6.yml.j2 new file mode 100644 index 00000000..30156aa8 --- /dev/null +++ b/roles/create_cluster/templates/50-worker-nm-fix-ipv6.yml.j2 @@ -0,0 +1,18 @@ +apiVersion: machineconfiguration.openshift.io/v1 +kind: MachineConfig +metadata: + name: 50-worker-nm-fix-duid + labels: + machineconfiguration.openshift.io/role: worker +spec: + config: + ignition: + version: 3.1.0 + storage: + files: + - contents: + source: data:text/plain;charset=utf-8;base64,W21haW5dCnJjLW1hbmFnZXI9ZmlsZQpbY29ubmVjdGlvbl0KaXB2Ni5kaGNwLWR1aWQ9bGwKaXB2Ni5kaGNwLWlhaWQ9bWFjCg== + verification: {} + filesystem: root + mode: 420 + path: /etc/NetworkManager/conf.d/99-upi-duid.conf diff --git a/roles/create_cluster/templates/50-worker-remove-ipi-leftovers.yml.j2 b/roles/create_cluster/templates/50-worker-remove-ipi-leftovers.yml.j2 new file mode 100644 index 00000000..004cc940 --- /dev/null +++ b/roles/create_cluster/templates/50-worker-remove-ipi-leftovers.yml.j2 @@ -0,0 +1,30 @@ +apiVersion: machineconfiguration.openshift.io/v1 +kind: MachineConfig +metadata: + name: 50-worker-remove-ipi-leftovers + labels: + machineconfiguration.openshift.io/role: worker +spec: + config: + ignition: + version: 3.1.0 + storage: + files: + - contents: + source: data:, + verification: {} + filesystem: root + mode: 420 + path: /etc/kubernetes/manifests/coredns.yaml + - contents: + source: data:, + verification: {} + filesystem: root + mode: 420 + path: /etc/kubernetes/manifests/keepalived.yaml + - contents: + source: data:, + verification: {} + filesystem: root + mode: 420 + path: /etc/kubernetes/manifests/mdns-publisher.yaml diff --git a/roles/create_cluster/templates/patch-discovery-ignition.j2 b/roles/create_cluster/templates/patch-discovery-ignition.j2 new file mode 100644 index 00000000..802da817 --- /dev/null +++ b/roles/create_cluster/templates/patch-discovery-ignition.j2 @@ -0,0 +1,31 @@ +{ + "ignition": { + "version": "3.1.0" + }, + "storage": { + "files": [ + { + "path": "/etc/containers/registries.conf", + "mode": 420, + "overwrite": true, + "user": { + "name": "root" + }, + "contents": { + "source": "data:text/plain;base64,{{ search_registries | string | b64encode }}" + } + }, + { + "path": "/etc/pki/ca-trust/source/anchors/domain.crt", + "mode": 420, + "overwrite": true, + "user": { + "name": "root" + }, + "contents": { + "source": "data:text/plain;base64,{{ mirror_certificate | string | b64encode }}" + } + } + ] + } +} diff --git a/roles/create_cluster/templates/patch-install-config.j2 b/roles/create_cluster/templates/patch-install-config.j2 new file mode 100644 index 00000000..8dd9405f --- /dev/null +++ b/roles/create_cluster/templates/patch-install-config.j2 @@ -0,0 +1,13 @@ +imageContentSources: +- mirrors: + - {{ mirror_registry }}/ocp4/openshift4 + source: quay.io/openshift-release-dev/ocp-release +- mirrors: + - {{ mirror_registry }}/ocp4/openshift4 + source: quay.io/openshift-release-dev/ocp-v4.0-art-dev +- mirrors: + - {{ mirror_registry }}/ocpmetal + source: quay.io/ocpmetal +additionalTrustBundle: | +{{ mirror_certificate.split("\n") | map("regex_replace", "^(?!\s{4})", " ") | list | join("\n") }} +sshKey: {{ ssh_public_key }} diff --git a/roles/create_cluster/templates/patch-network-type.j2 b/roles/create_cluster/templates/patch-network-type.j2 new file mode 100644 index 00000000..c18944ff --- /dev/null +++ b/roles/create_cluster/templates/patch-network-type.j2 @@ -0,0 +1,2 @@ +networking: + networkType: {{ network_type }} diff --git a/roles/create_cluster/templates/patch-search-registries.j2 b/roles/create_cluster/templates/patch-search-registries.j2 new file mode 100644 index 00000000..744d5cfb --- /dev/null +++ b/roles/create_cluster/templates/patch-search-registries.j2 @@ -0,0 +1,19 @@ +unqualified-search-registries = ["registry.access.redhat.com", "docker.io"] +[[registry]] + prefix = "" + location = "quay.io/ocpmetal" + mirror-by-digest-only = false + [[registry.mirror]] + location = "{{ mirror_registry }}/ocpmetal" +[[registry]] + prefix = "" + location = "quay.io/openshift-release-dev/ocp-release" + mirror-by-digest-only = true + [[registry.mirror]] + location = "{{ mirror_registry }}/ocp4/openshift4" +[[registry]] + prefix = "" + location = "quay.io/openshift-release-dev/ocp-v4.0-art-dev" + mirror-by-digest-only = true + [[registry.mirror]] + location = "{{ mirror_registry }}/ocp4/openshift4" diff --git a/roles/create_cluster/tests/inventory b/roles/create_cluster/tests/inventory new file mode 100644 index 00000000..2fbb50c4 --- /dev/null +++ b/roles/create_cluster/tests/inventory @@ -0,0 +1 @@ +localhost diff --git a/roles/create_cluster/tests/test.yml b/roles/create_cluster/tests/test.yml new file mode 100644 index 00000000..a6b82935 --- /dev/null +++ b/roles/create_cluster/tests/test.yml @@ -0,0 +1,5 @@ +--- +- hosts: localhost + remote_user: root + roles: + - create_cluster diff --git a/roles/create_cluster/vars/main.yml b/roles/create_cluster/vars/main.yml new file mode 100644 index 00000000..408eef04 --- /dev/null +++ b/roles/create_cluster/vars/main.yml @@ -0,0 +1,5 @@ +--- +# vars file for create_cluster + +ASSISTED_INSTALLER_HOST: "{{ hostvars['assisted_installer']['host'] }}" +ASSISTED_INSTALLER_PORT: "{{ hostvars['assisted_installer']['port'] }}" diff --git a/roles/create_day2_cluster/defaults/main.yml b/roles/create_day2_cluster/defaults/main.yml new file mode 100644 index 00000000..a5addfce --- /dev/null +++ b/roles/create_day2_cluster/defaults/main.yml @@ -0,0 +1,17 @@ +--- +# defaults file for create_day2_cluster + +secure: false +debug: False + +ASSISTED_INSTALLER_HOST: "{{ hostvars['assisted_installer']['host'] }}" +ASSISTED_INSTALLER_PORT: "{{ hostvars['assisted_installer']['port'] }}" +ASSISTED_INSTALLER_BASE_URL: "{{ secure | ternary('https', 'http') }}://{{ ASSISTED_INSTALLER_HOST }}:{{ ASSISTED_INSTALLER_PORT }}/api/assisted-install/v1" + +HTTP_PROXY: "" +HTTPS_PROXY: "" +NO_PROXY: "" + +# HTTP Basic Authentication +HTTP_AUTH_USERNAME: "none" +HTTP_AUTH_PASSWORD: "none" diff --git a/roles/create_day2_cluster/tasks/main.yml b/roles/create_day2_cluster/tasks/main.yml new file mode 100644 index 00000000..5ba122a4 --- /dev/null +++ b/roles/create_day2_cluster/tasks/main.yml @@ -0,0 +1,50 @@ +- name: Create day 2 cluster + uri: + url: "{{ ASSISTED_INSTALLER_BASE_URL }}/add_hosts_clusters" + method: POST + url_username: "{{ HTTP_AUTH_USERNAME }}" + url_password: "{{ HTTP_AUTH_PASSWORD }}" + body_format: json + status_code: [201] + return_content: True + body: >- + { + "id": "{{ lookup('password', '/dev/null') | to_uuid }}", + "name": "{{ cluster_name + '-day2' }}", + "api_vip_dnsname": "{{ 'api.' + cluster_name + '.' + base_dns_domain }}", + "openshift_version": "{{ openshift_full_version }}" + } + register: http_reply + +- debug: + var: http_reply.json.id + when: debug + +- set_fact: + BASE_CLUSTER_ID: "{{ cluster_id }}" + CLUSTER_ID: "{{ http_reply.json.id }}" + ADD_HOST_CLUSTER_ID: "{{ http_reply.json.id }}" + +- debug: + msg: "{{ pull_secret | to_json }}" + when: debug + +- name: Patch day 2 install config + uri: + url: "{{ ASSISTED_INSTALLER_BASE_URL }}/clusters/{{ ADD_HOST_CLUSTER_ID }}" + method: PATCH + status_code: [201] + return_content: True + body_format: json + body: + { + "pull_secret": "{{ pull_secret | to_json }}", + "ssh_public_key": "{{ ssh_public_key }}", + } + +- name: Distribute ADD_HOST_CLUSTER_ID value to day2_worker hosts + set_fact: + ADD_HOST_CLUSTER_ID: "{{ ADD_HOST_CLUSTER_ID }}" + delegate_to: "{{ item }}" + delegate_facts: yes + loop: "{{ groups['day2_workers'] }}" diff --git a/roles/create_vms/defaults/main.yml b/roles/create_vms/defaults/main.yml new file mode 100644 index 00000000..4715cfdc --- /dev/null +++ b/roles/create_vms/defaults/main.yml @@ -0,0 +1,39 @@ +# number of extra VMs to create but not deploy, used as OSP computes + +vm_spec_master_memory: 16384 +vm_spec_master_vcpu: 6 +vm_spec_master_disk: 30 + +vm_spec_worker_memory: 30000 +vm_spec_worker_vcpu: 8 +vm_spec_worker_disk: 50 + +vm_params: + master: + memory: "{{ vm_spec_master_memory }}" + vcpu: "{{ vm_spec_master_vcpu }}" + disk_size: "{{ vm_spec_master_disk }}" + worker: + memory: "{{ vm_spec_worker_memory }}" + vcpu: "{{ vm_spec_worker_vcpu }}" + disk_size: "{{ vm_spec_worker_disk }}" + +sushy_tools_port: 8082 +vm_bridge_ip: "{{ machine_network_cidr | ipaddr('next_usable') }}" +vm_bridge_prefix: "{{ machine_network_cidr | ipaddr('prefix') }}" +bridge_name: "{{ cluster_name }}-br" +network_name: "net-{{ cluster_name }}" +vm_host_ip_keyword: "{{ host_ip_keyword | default('ansible_host') }}" +vm_create_scripts: /home/redhat/vm_create_scripts/ + +virt_packages: + - python3 + - libvirt + - virt-install + - qemu-kvm + - virt-manager + - python3-pip + - python3-lxml + - python3-libvirt + +images_dir: /var/lib/libvirt/images/ diff --git a/roles/create_vms/meta/main.yml b/roles/create_vms/meta/main.yml new file mode 100644 index 00000000..085384d7 --- /dev/null +++ b/roles/create_vms/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - role: validate_inventory diff --git a/roles/create_vms/tasks/main.yml b/roles/create_vms/tasks/main.yml new file mode 100644 index 00000000..8ef55804 --- /dev/null +++ b/roles/create_vms/tasks/main.yml @@ -0,0 +1,43 @@ +--- + +- name: Set kvm_nodes + set_fact: + kvm_nodes: "{{ kvm_nodes | default([]) + [{ + 'name': item, + 'mac': hostvars[item]['mac'], + 'ip': hostvars[item][vm_host_ip_keyword], + 'memory': hostvars[item]['vm_spec']['ram_mib'] | default(vm_params[hostvars[item]['role']]['memory']), + 'vcpu': hostvars[item]['vm_spec']['cpu_cores'] | default(vm_params[hostvars[item]['role']]['vcpu']), + 'disk_size': hostvars[item]['vm_spec']['disk_size_gb'] | default(vm_params[hostvars[item]['role']]['disk_size']), + }] }}" + loop: "{{ groups['masters'] + groups['workers'] | default([])}}" + when: hostvars[item]['vendor'] | lower == 'kvm' + +- name: Setup host environment + become: true + block: + - name: "Install virt-manager" + package: + name: "{{ virt_packages }}" + state: present + + - name: Start libvirtd + service: + name: libvirtd + state: started + +- name: Prepare Bridges + import_tasks: prepare_bridges.yml + when: (SETUP_VM_BRIDGE | default(true)) | bool == true + +- name: Prepare Network + import_tasks: prepare_network.yml + +- name: Prepare firewall + import_tasks: prepare_firewall.yml + +- name: Prepare Storage Pool + import_tasks: prepare_storage_pool.yml + +- name: Prepare VMS + import_tasks: provision_vms.yml diff --git a/roles/create_vms/tasks/prepare_bridges.yml b/roles/create_vms/tasks/prepare_bridges.yml new file mode 100644 index 00000000..fba5d6f9 --- /dev/null +++ b/roles/create_vms/tasks/prepare_bridges.yml @@ -0,0 +1,37 @@ +- name: Prepare bridges + become: true + block: + - name: Install needed network manager libs + package: + name: + - NetworkManager-libnm + - nm-connection-editor + state: present + + - name: Create BM bridge + community.general.nmcli: + conn_name: "{{ bridge_name }}" + type: bridge + ifname: "{{ bridge_name }}" + autoconnect: yes + never_default4: yes + stp: no + ip4: "{{ vm_bridge_ip + '/' + vm_bridge_prefix }}" + dns4: "{{ hostvars[inventory_hostname]['dns'] }}" + state: present + + - name: Create bridge slave + community.general.nmcli: + conn_name: "{{ vm_bridge_interface }}_{{ bridge_name }}" + type: bridge-slave + ifname: "{{ vm_bridge_interface }}" + hairpin: no + master: "{{ bridge_name }}" + autoconnect: yes + state: present + + - name: Bring up bridge slave + shell: /usr/bin/nmcli con up {{ bridge_name }} + + - name: Bring up bridge slave + shell: /usr/bin/nmcli con up {{ vm_bridge_interface }}_{{ bridge_name }} diff --git a/roles/create_vms/tasks/prepare_firewall.yml b/roles/create_vms/tasks/prepare_firewall.yml new file mode 100644 index 00000000..7f6640bc --- /dev/null +++ b/roles/create_vms/tasks/prepare_firewall.yml @@ -0,0 +1,9 @@ +- name: Add TCP firewall rules for BM bridge + firewalld: + port: "{{ item.0 }}/tcp" + state: enabled + zone: "{{ item.1 }}" + permanent: yes + immediate: yes + loop: "{{ [sushy_tools_port] | product(['internal', 'public']) | list }}" + become: true diff --git a/roles/create_vms/tasks/prepare_network.yml b/roles/create_vms/tasks/prepare_network.yml new file mode 100644 index 00000000..8f914519 --- /dev/null +++ b/roles/create_vms/tasks/prepare_network.yml @@ -0,0 +1,14 @@ +- name: Setup network + become: true + block: + - name: define network + community.libvirt.virt_net: + name: "{{ network_name }}" + command: define + xml: "{{ lookup('template', 'network.xml.j2') }}" + + - name: start network + community.libvirt.virt_net: + name: "{{ network_name }}" + command: start + diff --git a/roles/create_vms/tasks/prepare_storage_pool.yml b/roles/create_vms/tasks/prepare_storage_pool.yml new file mode 100644 index 00000000..4e747a50 --- /dev/null +++ b/roles/create_vms/tasks/prepare_storage_pool.yml @@ -0,0 +1,23 @@ +# Sushy-tools does not allow you to specify the libvirt storage pool, and assumes +# that default exists, so we need to make sure that it does +- name: Handle default storage pool + become: true + block: + - name: make images dir + file: + path: "{{ images_dir }}" + state: directory + recurse: yes + + - name: Create default storage pool + community.libvirt.virt_pool: + command: define + name: default + xml: '{{ lookup("template", "storage-pool.xml.j2") }}' + autostart: yes + + - name: Start default storage pool + community.libvirt.virt_pool: + name: default + state: active + diff --git a/roles/create_vms/tasks/provision_vms.yml b/roles/create_vms/tasks/provision_vms.yml new file mode 100644 index 00000000..592ff4ae --- /dev/null +++ b/roles/create_vms/tasks/provision_vms.yml @@ -0,0 +1,28 @@ +--- +- name: Provision Nodes + become: true + block: + - name: Create rng device XML file + template: + src: rng_device.xml.j2 + dest: "/tmp/{{ cluster_name }}_rng_device.xml" + mode: 0664 + + - name: Create vm create scripts dir + file: + path: "{{ vm_create_scripts }}" + state: directory + recurse: yes + + - name: Create vm creation_scripts + template: + # community.libvirt.virt doesn't define the qcow image so it was chosen to use + # virt-install. The reason we use a script is to aid with debugging on the host + src: create_vm.sh.j2 + dest: "{{ vm_create_scripts }}/{{ item.name }}_setup_vm.sh" + mode: 0774 + loop: "{{ kvm_nodes }}" + + - name: Run vm creation_scripts + shell: "/bin/bash {{ vm_create_scripts }}/{{ item.name }}_setup_vm.sh" + loop: "{{ kvm_nodes }}" diff --git a/roles/create_vms/templates/create_vm.sh.j2 b/roles/create_vms/templates/create_vm.sh.j2 new file mode 100644 index 00000000..5b3086e8 --- /dev/null +++ b/roles/create_vms/templates/create_vm.sh.j2 @@ -0,0 +1,22 @@ +#! /bin/bash + +virt-install \ + --virt-type=kvm \ + --name "{{ item.name }}" \ + --memory {{ item.memory }} \ + --vcpus={{ item.vcpu }} \ + --os-variant=rhel8.3 \ + --os-type linux \ + --network=bridge:{{ bridge_name }},mac="{{ item.mac }}" \ + --controller type=scsi,model=virtio-scsi \ + --disk path={{ images_dir }}/{{ cluster_name }}-{{ item.name }}.qcow2,size={{ item.disk_size }},bus=scsi,format=qcow2 \ + --graphics vnc \ + --noautoconsole \ + --wait=-1 \ + --boot uefi \ + --events on_reboot=restart \ + --print-xml > /tmp/{{ cluster_name }}-{{item.name}}.xml + +virsh define --file /tmp/{{ cluster_name }}-{{item.name}}.xml + +virsh detach-device {{ item.name }} /tmp/{{ cluster_name }}_rng_device.xml --config diff --git a/roles/create_vms/templates/network.xml.j2 b/roles/create_vms/templates/network.xml.j2 new file mode 100644 index 00000000..c39e87c7 --- /dev/null +++ b/roles/create_vms/templates/network.xml.j2 @@ -0,0 +1,6 @@ + + {{ network_name }} + 4414ebcb-8bdd-4cfc-a467-4376f3b709e9 + + + diff --git a/roles/create_vms/templates/rng_device.xml.j2 b/roles/create_vms/templates/rng_device.xml.j2 new file mode 100644 index 00000000..c296717c --- /dev/null +++ b/roles/create_vms/templates/rng_device.xml.j2 @@ -0,0 +1,3 @@ + + /dev/urandom + diff --git a/roles/create_vms/templates/storage-pool.xml.j2 b/roles/create_vms/templates/storage-pool.xml.j2 new file mode 100644 index 00000000..117d1677 --- /dev/null +++ b/roles/create_vms/templates/storage-pool.xml.j2 @@ -0,0 +1,14 @@ + + default + + + + {{ images_dir }} + + 0711 + 0 + 0 + + + + diff --git a/roles/destroy_vms/defaults/main.yml b/roles/destroy_vms/defaults/main.yml new file mode 100644 index 00000000..ffbf952b --- /dev/null +++ b/roles/destroy_vms/defaults/main.yml @@ -0,0 +1,15 @@ +virt_packages: + - python3 + - libvirt + - virt-install + - qemu-kvm + - virt-manager + - python3-pip + - python3-lxml + - python3-libvirt + +images_dir: /var/lib/libvirt/images/ +vm_create_scripts: /home/redhat/vm_create_scripts/ + +bridge_name: "{{ cluster_name }}-br" +network_name: "net-{{ cluster_name }}" diff --git a/roles/destroy_vms/meta/main.yml b/roles/destroy_vms/meta/main.yml new file mode 100644 index 00000000..085384d7 --- /dev/null +++ b/roles/destroy_vms/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - role: validate_inventory diff --git a/roles/destroy_vms/tasks/destroy_pools.yml b/roles/destroy_vms/tasks/destroy_pools.yml new file mode 100644 index 00000000..d67095d5 --- /dev/null +++ b/roles/destroy_vms/tasks/destroy_pools.yml @@ -0,0 +1,39 @@ + +- name: List vm storage pools + community.libvirt.virt_pool: + command: list_pools + register: pools + +- name: Get pool info + community.libvirt.virt_pool: + command: info + name: "{{ item }}" + loop: "{{ pools.list_pools }}" + register: pool_info + +- name: Get pools to Remove + set_fact: + pools_to_remove: "{{ pools_to_remove | default({}) | combine({item.name: item.data}) }}" + loop: "{{ + pool_info.results | + map(attribute='pools') | list | combine | + dict2items(key_name='name', value_name='data') + }}" + when: item.data.path == images_dir + +- name: Find images + find: + path: "{{ images_dir }}" + register: images_to_remove + +- name: Remove file in image dir + file: + path: "{{ item }}" + state: absent + loop: "{{ images_to_remove.files | map(attribute='path') | list }}" + +- name: Remove pools + community.libvirt.virt_pool: + state: deleted + name: "{{ item }}" + loop: "{{ pools_to_remove.keys() | list }}" diff --git a/roles/destroy_vms/tasks/destroy_vms.yml b/roles/destroy_vms/tasks/destroy_vms.yml new file mode 100644 index 00000000..c6452fad --- /dev/null +++ b/roles/destroy_vms/tasks/destroy_vms.yml @@ -0,0 +1,32 @@ +- name: Find vm creation scripts + find: + path: "{{ vm_create_scripts }}" + register: creation_to_remove + +- name: Remove file in {{vm_create_scripts }} dir + file: + path: "{{ item }}" + state: absent + loop: "{{ creation_to_remove.files | map(attribute='path') | list }}" + +- name: List vms + community.libvirt.virt: + command: list_vms + register: vms_list + +- name: Get filtered vm list + set_fact: + vms_to_remove: "{{ [item] + vms_to_remove | default([]) }}" + loop: "{{ vms_list.list_vms }}" + when: item in hostvars and hostvars[item]['vendor'] | lower == 'kvm' + +- name: Destroy VM + community.libvirt.virt: + name: "{{ item }}" + state: destroyed + loop: "{{ vms_to_remove | default([]) }}" + +- name: Undefine VM + shell: + cmd: "virsh undefine --nvram {{ item }}" # community.libvirt.virt undefine doesn't have the ability to specify --nvram + loop: "{{ vms_to_remove | default([]) }}" diff --git a/roles/destroy_vms/tasks/main.yml b/roles/destroy_vms/tasks/main.yml new file mode 100644 index 00000000..4bfaf745 --- /dev/null +++ b/roles/destroy_vms/tasks/main.yml @@ -0,0 +1,35 @@ +- name: Remove network + become: true + ignore_errors: yes + block: + - name: destroy network + community.libvirt.virt_net: + name: "{{ network_name }}" + command: destroy + + - name: undefine network + community.libvirt.virt_net: + name: "{{ network_name }}" + command: undefine + +- name: Delete existing bridges (if any) + community.general.nmcli: + conn_name: "{{ item }}" + type: bridge + state: absent + loop: + - "{{ bridge_name }}" + - "{{ vm_bridge_interface }}_{{ bridge_name }}" + ignore_errors: yes + become: true + when: (SETUP_VM_BRIDGE | default(true)) | bool == true + +- name: Destroy pool + import_tasks: destroy_pools.yml + ignore_errors: yes + become: true + +- name: Destroy vms + import_tasks: destroy_vms.yml + ignore_errors: yes + become: true diff --git a/roles/generate_discovery_iso/defaults/main.yml b/roles/generate_discovery_iso/defaults/main.yml new file mode 100644 index 00000000..a41c7e72 --- /dev/null +++ b/roles/generate_discovery_iso/defaults/main.yml @@ -0,0 +1,25 @@ +--- +# defaults file for generate_iso + +secure: False +generate: True +download: True +upload: False +debug: False + +ASSISTED_INSTALLER_HOST: "{{ ansible_default_ipv4.address|default(ansible_all_ipv4_addresses[0]) }}" +ASSISTED_INSTALLER_PORT: 8090 +ASSISTED_INSTALLER_BASE_URL: "{{ secure | ternary('https', 'http') }}://{{ ASSISTED_INSTALLER_HOST }}:{{ ASSISTED_INSTALLER_PORT }}/api/assisted-install/v1" +URL_ASSISTED_INSTALLER_CLUSTERS_DOWNLOAD_IMAGE: "{{ ASSISTED_INSTALLER_BASE_URL }}/clusters/{{ CLUSTER_ID }}/downloads/image" + +# HTTP Basic Authentication +HTTP_AUTH_USERNAME: "test" +HTTP_AUTH_PASSWORD: "test" + +SSH_PUBLIC_KEY: "" +DOWNLOAD_DEST_PATH: "/tmp" +DOWNLOAD_DEST_FILE: "discovery.iso" + +mac_interface_default_mapping: "interfaces[?(name != null && mac != null)].{logical_nic_name: name, mac_address: mac}" +ai_version: "{{ hostvars.assisted_installer.ai_version | default('v1.0.24.2') }}" +ai_version_number: "{{ ai_version | regex_replace('v(\\d+\\.\\d+.\\d+\\.\\d+)', '\\1') }}" diff --git a/roles/generate_discovery_iso/handlers/main.yml b/roles/generate_discovery_iso/handlers/main.yml new file mode 100644 index 00000000..c87fe799 --- /dev/null +++ b/roles/generate_discovery_iso/handlers/main.yml @@ -0,0 +1,2 @@ +--- +# handlers file for generate_iso diff --git a/roles/generate_discovery_iso/meta/main.yml b/roles/generate_discovery_iso/meta/main.yml new file mode 100644 index 00000000..90bbb455 --- /dev/null +++ b/roles/generate_discovery_iso/meta/main.yml @@ -0,0 +1,53 @@ +galaxy_info: + author: Son of Spike + description: generate discovery iso + company: Red Hat + + # If the issue tracker for your role is not on github, uncomment the + # next line and provide a value + # issue_tracker_url: http://example.com/issue/tracker + + # Choose a valid license ID from https://spdx.org - some suggested licenses: + # - BSD-3-Clause (default) + # - MIT + # - GPL-2.0-or-later + # - GPL-3.0-only + # - Apache-2.0 + # - CC-BY-4.0 + license: Apache-2.0 + + min_ansible_version: 2.1 + + # If this a Container Enabled role, provide the minimum Ansible Container version. + # min_ansible_container_version: + + # + # Provide a list of supported platforms, and for each platform a list of versions. + # If you don't wish to enumerate all versions for a particular platform, use 'all'. + # To view available platforms and versions (or releases), visit: + # https://galaxy.ansible.com/api/v1/platforms/ + # + # platforms: + # - name: Fedora + # versions: + # - all + # - 25 + # - name: SomePlatform + # versions: + # - all + # - 1.0 + # - 7 + # - 99.99 + + galaxy_tags: [] + # List tags for your role here, one per line. A tag is a keyword that describes + # and categorizes the role. Users find roles by searching for tags. Be sure to + # remove the '[]' above, if you add tags to this list. + # + # NOTE: A tag is limited to a single word comprised of alphanumeric characters. + # Maximum 20 tags per role. + +dependencies: + # List your role dependencies here, one per line. Be sure to remove the '[]' above, + # if you add dependencies to this list. + - role: validate_inventory diff --git a/roles/generate_discovery_iso/tasks/main.yml b/roles/generate_discovery_iso/tasks/main.yml new file mode 100644 index 00000000..8a6b9180 --- /dev/null +++ b/roles/generate_discovery_iso/tasks/main.yml @@ -0,0 +1,77 @@ +--- +# tasks file for generate_discovery_iso + +- name: Set discovery iso body + set_fact: + request_body: + ssh_public_key: "{{ ssh_public_key }}" + image_type: "full-iso" + +- name: static IP addresses + include_tasks: static.yml + when: hostvars[item].network_config is defined + loop: "{{ groups['masters'] + ( groups['workers'] | default([]) ) }}" + +- name: IP config + set_fact: + request_body: "{{ request_body | combine({'static_network_config': static_network_config_items}) }}" + when: static_network_config_items | default([]) | length > 0 + +- name: Iso body + debug: + msg: "{{ request_body | to_json }}" + when: debug | bool == True + +- name: Generate a new discovery ISO + uri: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTERS_DOWNLOAD_IMAGE }}" + method: POST + url_username: "{{ HTTP_AUTH_USERNAME }}" + url_password: "{{ HTTP_AUTH_PASSWORD }}" + body_format: json + status_code: [201] + return_content: True + body: "{{ request_body }}" + when: generate | bool == True + register: http_reply + +- debug: + var: http_reply.json + when: debug and generate | bool == True + +- name: Put discovery iso in http store + block: + - name: Create discovery directory + file: + path: "{{ DOWNLOAD_DEST_PATH + '/' + DOWNLOAD_DEST_FILE | dirname }}" + recurse: yes + state: directory + become: true + + - name: Download discovery ISO + get_url: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTERS_DOWNLOAD_IMAGE }}" + dest: "{{ DOWNLOAD_DEST_PATH }}/{{ DOWNLOAD_DEST_FILE }}" + when: download | bool == True + register: http_reply + become: true + + - debug: + var: http_reply.status_code + when: debug and download | bool == True + + - name: Upload discovery ISO + uri: + url: "{{ URL_HTTP_STORE_UPLOAD_IMAGE }}/{{ CLUSTER_ID }}.iso" + method: PUT + url_username: "{{ HTTP_STORE_AUTH_USERNAME }}" + url_password: "{{ HTTP_STORE_AUTH_PASSWORD }}" + src: "{{ DOWNLOAD_DEST_PATH }}/{{ DOWNLOAD_DEST_FILE }}" + status_code: [200] + when: upload | bool == True + register: http_reply + + - debug: + var: http_reply.status_code + when: debug and upload | bool == True + delegate_to: http_store diff --git a/roles/generate_discovery_iso/tasks/static.yml b/roles/generate_discovery_iso/tasks/static.yml new file mode 100644 index 00000000..0fc454f9 --- /dev/null +++ b/roles/generate_discovery_iso/tasks/static.yml @@ -0,0 +1,35 @@ +- name: "Set network config for {{ item }}" + set_fact: + network_config: "{{ hostvars[item].network_config }}" + +- name: "Set default value of mac_interface_map for {{ item }}" + set_fact: + mac_interface_map: "{{ hostvars[item].mac_interface_map | default([]) }}" + +- name: "Set mac_interface_map for {{ item }} using query" + set_fact: + mac_interface_map: "{{ network_config | json_query( network_config.mapping_query | default(mac_interface_default_mapping) ) }}" + when: hostvars[item].mac_interface_map is not defined + +- name: "Set default value of network_yaml for {{ item }}" + set_fact: + network_yaml: "{{ network_config.raw | default({}) }}" + +- name: "Set network_yaml for {{ item }}" + set_fact: + network_yaml: "{{ lookup('template', network_config.template | default('nmstate.yml.j2')) }}" + when: network_config.raw is not defined + +- debug: + msg: "{{ network_yaml }}" + when: debug | bool == true + +- name: "Set static network config for {{ item }}" + set_fact: + static_network_config_entry: + network_yaml: "{{ network_yaml }}" + mac_interface_map: "{{ mac_interface_map }}" + +- name: Update static_network_config_items + set_fact: + static_network_config_items: "{{ static_network_config_items | default([]) + [static_network_config_entry] }}" diff --git a/roles/generate_discovery_iso/templates/nmstate.yml.j2 b/roles/generate_discovery_iso/templates/nmstate.yml.j2 new file mode 100644 index 00000000..3270acbd --- /dev/null +++ b/roles/generate_discovery_iso/templates/nmstate.yml.j2 @@ -0,0 +1,52 @@ +#jinja2:trim_blocks: True, lstrip_blocks: True +{% if network_config.dns_server_ip is defined %} +dns-resolver: + config: + server: + - {{ network_config.dns_server_ip }} +{% endif %} +interfaces: +{% for interface in network_config.interfaces %} + - name: {{ interface.name }} + state: {{ interface.state | default('up') }} + type: {{ interface.type | default('ethernet') }} + {% if interface.addresses.ipv4 is defined %} + ipv4: + address: + {% for address in interface.addresses.ipv4 %} + - ip: {{ address.ip }} + prefix-length: {{ address.prefix }} + {% endfor %} + dhcp: false + enabled: true + {% endif %} + {% if interface.addresses.ipv6 is defined %} + ipv6: + address: + {% for address in interface.addresses.ipv6 %} + - ip: {{ address.ip }} + prefix-length: {{ address.prefix }} + {% endfor %} + dhcp: false + enabled: true + {% endif %} + {% if interface.link_aggregation is defined %} + link-aggregation: + mode: {{ interface.link_aggregation.mode | default('active-backup') }} + options: {{ interface.link_aggregation.options | default([]) | to_json }} + slaves: + {% for slave in interface.link_aggregation.slaves %} + - {{ slave }} + {% endfor %} + {% endif %} +{% endfor %} +{% if network_config.routes is defined %} +routes: + config: + {% for route in network_config.routes %} + - destination: {{ route.destination | default('0.0.0.0/0') }} + next-hop-address: {{ route.address }} + next-hop-interface: {{ route.interface }} + table-id: {{ route.table_id | default('254') }} + {% endfor %} +{% endif %} diff --git a/roles/generate_discovery_iso/tests/inventory b/roles/generate_discovery_iso/tests/inventory new file mode 100644 index 00000000..2fbb50c4 --- /dev/null +++ b/roles/generate_discovery_iso/tests/inventory @@ -0,0 +1 @@ +localhost diff --git a/roles/generate_discovery_iso/tests/test.yml b/roles/generate_discovery_iso/tests/test.yml new file mode 100644 index 00000000..24ae94d3 --- /dev/null +++ b/roles/generate_discovery_iso/tests/test.yml @@ -0,0 +1,5 @@ +--- +- hosts: localhost + remote_user: root + roles: + - generate_iso diff --git a/roles/generate_discovery_iso/vars/main.yml b/roles/generate_discovery_iso/vars/main.yml new file mode 100644 index 00000000..507916bb --- /dev/null +++ b/roles/generate_discovery_iso/vars/main.yml @@ -0,0 +1,5 @@ +--- +# vars file for generate_iso + +ASSISTED_INSTALLER_HOST: "{{ hostvars['assisted_installer']['host'] }}" +ASSISTED_INSTALLER_PORT: "{{ hostvars['assisted_installer']['port'] }}" diff --git a/roles/generate_ssh_key_pair/defaults/main.yml b/roles/generate_ssh_key_pair/defaults/main.yml new file mode 100644 index 00000000..b423acea --- /dev/null +++ b/roles/generate_ssh_key_pair/defaults/main.yml @@ -0,0 +1,6 @@ +key_pair_dir: /tmp/ssh_key_pair +private_key_name: "{{ cluster_name }}" +public_key_name: "{{ cluster_name }}.pub" +ssh_key_dest_base_dir: /home/redhat +ssh_key_dest_dir: "{{ ssh_key_dest_base_dir }}/ssh_keys/" +fetched_dest: ./fetched diff --git a/roles/generate_ssh_key_pair/meta/main.yml b/roles/generate_ssh_key_pair/meta/main.yml new file mode 100644 index 00000000..085384d7 --- /dev/null +++ b/roles/generate_ssh_key_pair/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - role: validate_inventory diff --git a/roles/generate_ssh_key_pair/tasks/main.yml b/roles/generate_ssh_key_pair/tasks/main.yml new file mode 100644 index 00000000..a13d64ed --- /dev/null +++ b/roles/generate_ssh_key_pair/tasks/main.yml @@ -0,0 +1,45 @@ +--- +- name: Make key dir + file: + path: "{{ key_pair_dir }}" + mode: 0775 + state: directory + +- name: Generate an OpenSSH rsa keypair + community.crypto.openssh_keypair: + path: "{{ key_pair_dir }}/{{ private_key_name }}" + args: "{{ openssh_keypair_args | default({}) }}" + +- name: Fetch SSH Key + fetch: + src: "{{ item }}" + dest: "{{ fetched_dest }}/ssh_keys/" + flat: yes + loop: + - "{{ key_pair_dir }}/{{ private_key_name }}" + - "{{ key_pair_dir }}/{{ public_key_name }}" + +- name: Copy SSH Key to bastion + block: + - name: Make SSH Key folder + file: + path: "{{ ssh_key_dest_dir }}" + mode: 0775 + state: directory + + - name: Copy SSH Key files to bastion + copy: + src: "{{ fetched_dest }}/ssh_keys/{{ item }}" + dest: "{{ ssh_key_dest_dir }}/{{ item }}" + loop: + - "{{ private_key_name }}" + - "{{ public_key_name }}" + + delegate_to: bastion + +- name: Distribute public key to all hosts + set_fact: + ssh_public_key: "{{ lookup('file', fetched_dest + '/ssh_keys/' + public_key_name) }}" + delegate_to: "{{ item }}" + delegate_facts: true + loop: "{{ groups['all'] }}" diff --git a/roles/get_image_hash/README.md b/roles/get_image_hash/README.md new file mode 100644 index 00000000..77f06651 --- /dev/null +++ b/roles/get_image_hash/README.md @@ -0,0 +1,26 @@ +# Get image hash role + +Uses `skopeo` to produce a dictionary of image digests for images defined in `images_to_get_hash_for`. + +## Requirements + +- skopeo +- jq + +## Role Variables + +- `openshift_full_version`: used to set the tag for `ocp-release` which is one of the default images to fetch. +- `destination_hosts`: the hosts to put the `image_hashes`. + +## Example Playbook + +```yaml +- name: Play to populate image_hashes for relevant images + hosts: localhost + vars: + destination_hosts: + - registry_host + openshift_full_version: 4.6.18 + roles: + - get_image_hash +``` diff --git a/roles/get_image_hash/defaults/main.yml b/roles/get_image_hash/defaults/main.yml new file mode 100644 index 00000000..46e296cb --- /dev/null +++ b/roles/get_image_hash/defaults/main.yml @@ -0,0 +1,22 @@ +ai_version: v1.0.24.2 +controller_tag: "{{ ai_version }}" +installer_agent_tag: "{{ ai_version }}" +installer_tag: "{{ ai_version }}" + +images_to_get_hash_for: + release: + image: quay.io/openshift-release-dev/ocp-release + tag: "{{ release_tag | default(openshift_full_version + '-x86_64') }}" + controller: + image: quay.io/ocpmetal/assisted-installer-controller + tag: "{{ controller_tag }}" + installer_agent: + image: quay.io/ocpmetal/assisted-installer-agent + tag: "{{ installer_agent_tag }}" + installer: + image: quay.io/ocpmetal/assisted-installer + tag: "{{ installer_tag }}" + +destination_hosts: + - registry_host + - assisted_installer diff --git a/roles/get_image_hash/meta/main.yml b/roles/get_image_hash/meta/main.yml new file mode 100644 index 00000000..085384d7 --- /dev/null +++ b/roles/get_image_hash/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - role: validate_inventory diff --git a/roles/get_image_hash/tasks/get_image_hash.yml b/roles/get_image_hash/tasks/get_image_hash.yml new file mode 100644 index 00000000..d7e72411 --- /dev/null +++ b/roles/get_image_hash/tasks/get_image_hash.yml @@ -0,0 +1,7 @@ +- name: "Get {{ item.key }} image hash" + shell: "skopeo --authfile {{ local_pull_secret_path }} inspect docker://{{ item.value.image }}:{{ item.value.tag }}" + register: result + +- name: Update hashes + set_fact: + image_hashes: "{{ image_hashes | default({}) | combine({item.key: (result.stdout | from_json | json_query('Digest'))}) }}" diff --git a/roles/get_image_hash/tasks/main.yml b/roles/get_image_hash/tasks/main.yml new file mode 100644 index 00000000..7bdc515d --- /dev/null +++ b/roles/get_image_hash/tasks/main.yml @@ -0,0 +1,17 @@ +- name: check skopeo is installed + shell: /usr/bin/skopeo --version + +- name: Find hash for images + include_tasks: + file: get_image_hash.yml + apply: + tags: + - install + loop: "{{ images_to_get_hash_for | dict2items }}" + +- name: "Set image hashes in {{ item }}" + set_fact: + image_hashes: "{{ image_hashes }}" + delegate_to: "{{ item }}" + delegate_facts: true + loop: "{{ destination_hosts }}" diff --git a/roles/insert_dns_records/README.md b/roles/insert_dns_records/README.md new file mode 100644 index 00000000..b74f1586 --- /dev/null +++ b/roles/insert_dns_records/README.md @@ -0,0 +1,47 @@ +# Insert DNS Records roles + +Setups `dnsmasq` (either directly or via `NetworkManager`) inserting the DNS A records required for Openshift install. + +## Role Variables + +| Variable | Required | Default | Options | Comments | +| --------------------- | -------- | -------------- | ----------------------- | ----------------------------------------------------------- | +| domain | yes | | | base for the dns entries | +| dns_entries_file_name | no | domains.dns | | | +| dns_service_name | no | NetworkManager | NetworkManager, dnsmasq | the name of the service you want to manage your dns records | +| node_dns_records | no | | | dns records for the nodes of the Openshift cluster | +| extra_dns_records | no | | | used to defined dns records which are excess of the | + +The structure of `node_dns_records` and `extra_dns_records` is the same and as follows: + +```yaml +node_dns_records: + master-0: + address: "" + ip: "" +extra_dns_records: + place-0: + address: "

" + ip: "" +``` + +## Example Playbook + +```yaml +- name: Setup DNS Records + hosts: dns_host + roles: + - insert_dns_records + vars: + domain: "cluster.example.com" + node_dns_records: + master-0: + address: "master-0.cluster.example.com" + ip: "111.111.111.111" + master-1: + address: "master-1.cluster.example.com" + ip: "111.111.111.112" + master-2: + address: "master-2.cluster.example.com" + ip: "111.111.111.113" +``` diff --git a/roles/insert_dns_records/defaults/main.yml b/roles/insert_dns_records/defaults/main.yml new file mode 100644 index 00000000..1bf440ae --- /dev/null +++ b/roles/insert_dns_records/defaults/main.yml @@ -0,0 +1,21 @@ +write_dnsmasq_config: true +domain: "{{ cluster_name + '.' + base_dns_domain }}" +host_ip_keyword: "ansible_host" +dns_entries_file_name: "{{ 'dnsmasq.' + cluster_name + '.conf' }}" +dns_service_name: NetworkManager +dns_records: + apps: + address: "*.apps.{{ domain }}" + ip: "{{ ingress_vip }}" + api: + address: "api.{{ domain }}" + ip: "{{ api_vip }}" + api_int: + address: "api-int.{{ domain }}" + ip: "{{ api_vip }}" + +node_dns_records: {} +extra_dns_records: {} + +use_dhcp: false +dhcp_lease_time: 24h diff --git a/roles/insert_dns_records/files/nm-dnsmasq.conf b/roles/insert_dns_records/files/nm-dnsmasq.conf new file mode 100644 index 00000000..53a8b172 --- /dev/null +++ b/roles/insert_dns_records/files/nm-dnsmasq.conf @@ -0,0 +1,2 @@ +[main] +dns=dnsmasq diff --git a/roles/insert_dns_records/handlers/main.yml b/roles/insert_dns_records/handlers/main.yml new file mode 100644 index 00000000..796f35b8 --- /dev/null +++ b/roles/insert_dns_records/handlers/main.yml @@ -0,0 +1,8 @@ +- name: "Restart {{ dns_service_name }}" + ansible.builtin.service: + name: "{{ dns_service_name }}" + state: restarted + async: 45 + poll: 5 + listen: restart_service + become: true diff --git a/roles/insert_dns_records/meta/main.yml b/roles/insert_dns_records/meta/main.yml new file mode 100644 index 00000000..085384d7 --- /dev/null +++ b/roles/insert_dns_records/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - role: validate_inventory diff --git a/roles/insert_dns_records/tasks/configure_firewall.yml b/roles/insert_dns_records/tasks/configure_firewall.yml new file mode 100644 index 00000000..786ac229 --- /dev/null +++ b/roles/insert_dns_records/tasks/configure_firewall.yml @@ -0,0 +1,22 @@ +- name: Open port in firewall for DNS + ansible.posix.firewalld: + port: "53/udp" + permanent: yes + immediate: yes + state: enabled + zone: "{{ item }}" + loop: + - internal + - public + +- name: Open port in firewall for DHCP + ansible.posix.firewalld: + port: "67/udp" + permanent: yes + immediate: yes + state: enabled + zone: "{{ item }}" + loop: + - internal + - public + when: use_dhcp == true diff --git a/roles/insert_dns_records/tasks/dnsmasq.yml b/roles/insert_dns_records/tasks/dnsmasq.yml new file mode 100644 index 00000000..4bf8b124 --- /dev/null +++ b/roles/insert_dns_records/tasks/dnsmasq.yml @@ -0,0 +1,18 @@ +--- +- name: Install dnsmasq + become: true + ansible.builtin.package: + name: dnsmasq + state: present + +- name: Create dns file + ansible.builtin.template: + src: openshift-cluster.conf.j2 + dest: "/etc/dnsmasq.d/{{ dns_entries_file_name }}" + notify: restart_service + +- name: Start dnsmasq + ansible.builtin.service: + name: dnsmasq + state: started + enabled: true diff --git a/roles/insert_dns_records/tasks/main.yml b/roles/insert_dns_records/tasks/main.yml new file mode 100644 index 00000000..65047b44 --- /dev/null +++ b/roles/insert_dns_records/tasks/main.yml @@ -0,0 +1,41 @@ +--- +- name: Get node_records for masters and workers + set_fact: + node_dns_records: "{{ node_dns_records | default({}) | combine( + { + item: { + 'name': item, + 'address': item + '.' + cluster_name + '.' + base_dns_domain, + 'ip': hostvars[item][host_ip_keyword], + 'mac': hostvars[item]['mac'] | default(False), + 'use_dhcp': hostvars[item]['ip'] | default('dhcp') == 'dhcp', + } + } + ) + }}" + loop: "{{ groups['masters'] + groups['workers'] | default([]) }}" + when: hostvars[item][host_ip_keyword] is defined + +- name: Configure firewall + become: true + import_tasks: configure_firewall.yml + +- name: Configure dnsmasq via NetworkManager + become: true + import_tasks: + file: network-manager.yml + when: dns_service_name == "NetworkManager" + +- name: Configure dnsmasq via dnsmasq + become: true + import_tasks: + file: dnsmasq.yml + when: dns_service_name == "dnsmasq" + +- name: "Restart {{ dns_service_name }}" + become: true + ansible.builtin.service: + name: "{{ dns_service_name }}" + state: restarted + async: 45 + poll: 5 diff --git a/roles/insert_dns_records/tasks/network-manager.yml b/roles/insert_dns_records/tasks/network-manager.yml new file mode 100644 index 00000000..03aaf7d1 --- /dev/null +++ b/roles/insert_dns_records/tasks/network-manager.yml @@ -0,0 +1,17 @@ +--- +- name: Setup network manager to run dnsmasq + ansible.builtin.copy: + src: nm-dnsmasq.conf + dest: /etc/NetworkManager/conf.d/dnsmasq.conf + +- name: Create dnsmasq openshift-cluster config file + ansible.builtin.template: + src: openshift-cluster.conf.j2 + dest: "/etc/NetworkManager/dnsmasq.d/{{dns_entries_file_name}}" + notify: restart_service + +- name: Start NetworkManager + ansible.builtin.service: + name: NetworkManager + state: started + enabled: true diff --git a/roles/insert_dns_records/templates/nm-dnsmasq.conf.j2 b/roles/insert_dns_records/templates/nm-dnsmasq.conf.j2 new file mode 100644 index 00000000..53a8b172 --- /dev/null +++ b/roles/insert_dns_records/templates/nm-dnsmasq.conf.j2 @@ -0,0 +1,2 @@ +[main] +dns=dnsmasq diff --git a/roles/insert_dns_records/templates/openshift-cluster.conf.j2 b/roles/insert_dns_records/templates/openshift-cluster.conf.j2 new file mode 100644 index 00000000..10d24c51 --- /dev/null +++ b/roles/insert_dns_records/templates/openshift-cluster.conf.j2 @@ -0,0 +1,45 @@ +domain={{ domain }} +{% if write_dnsmasq_config %} +domain-needed +bogus-priv +listen-address=127.0.0.1,{{ ansible_default_ipv4.address }} +expand-hosts +{% if upstream_dns | default(False) %} +server={{ upstream_dns }} +{% endif %} +{% endif %} + +{% if use_dhcp %} +dhcp-range= tag:{{ cluster_name }},{{ dhcp_range_first }},{{ dhcp_range_last }} +dhcp-option= tag:{{ cluster_name }},option:netmask,{{ (gateway + '/' + prefix | string) | ipaddr('netmask') }} +dhcp-option= tag:{{ cluster_name }},option:router,{{ gateway }} +dhcp-option= tag:{{ cluster_name }},option:dns-server,{{ ansible_default_ipv4.address }} +dhcp-option= tag:{{ cluster_name }},option:domain-search,{{ domain }} +dhcp-option= tag:{{ cluster_name }},option:ntp-server,{{ ntp_server }} +{% endif %} + +#Wildcard for apps and other api domains +{% for item in dns_records.values() %} +address=/{{ item.address }}/{{ item.ip }} +{% endfor %} + +#additional IPs +{% for item in node_dns_records.values() %} +# {{ item.name }} +{% if item.use_dhcp %} +dhcp-host={{item.mac}},{{ item.ip }},{{ item.address }}, set:{{ cluster_name }} +{% endif %} +address=/{{ item.address }}/{{ item.ip }} +ptr-record={{ item.ip.split('.')[::-1] | join('.') }}.in-addr.arpa,{{ item.address }} + +{% endfor %} + +{% for item in extra_dns_records.values() %} +# {{ item.name }} +{% if item.use_dhcp %} +dhcp-host={{item.mac}},{{ item.ip }},{{ item.address }}, set:{{ cluster_name }} +{% endif %} +address=/{{ item.address }}/{{ item.ip }} +ptr-record={{ item.ip.split('.')[::-1] | join('.') }}.in-addr.arpa,{{ item.address }} + +{% endfor %} diff --git a/roles/install_cluster/defaults/main.yml b/roles/install_cluster/defaults/main.yml new file mode 100644 index 00000000..63f38c34 --- /dev/null +++ b/roles/install_cluster/defaults/main.yml @@ -0,0 +1,17 @@ +--- +# defaults file for install_cluster + +install: True +secure: False +debug: False + +ASSISTED_INSTALLER_HOST: "{{ ansible_default_ipv4.address|default(ansible_all_ipv4_addresses[0]) }}" +ASSISTED_INSTALLER_PORT: 8090 +ASSISTED_INSTALLER_BASE_URL: "{{ secure | ternary('https', 'http') }}://{{ ASSISTED_INSTALLER_HOST }}:{{ ASSISTED_INSTALLER_PORT }}/api/assisted-install/v1" +URL_ASSISTED_INSTALLER_CLUSTER: "{{ ASSISTED_INSTALLER_BASE_URL }}/clusters/{{ CLUSTER_ID }}" + +# HTTP Basic Authentication +HTTP_AUTH_USERNAME: "none" +HTTP_AUTH_PASSWORD: "none" + +fetched_dest: ./fetched diff --git a/roles/install_cluster/meta/main.yml b/roles/install_cluster/meta/main.yml new file mode 100644 index 00000000..085384d7 --- /dev/null +++ b/roles/install_cluster/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - role: validate_inventory diff --git a/roles/install_cluster/tasks/hosts_discovery.yml b/roles/install_cluster/tasks/hosts_discovery.yml new file mode 100644 index 00000000..e96a0134 --- /dev/null +++ b/roles/install_cluster/tasks/hosts_discovery.yml @@ -0,0 +1,95 @@ +--- +# tasks file for install_cluster/hosts_discovery + +- name: "Wait for up to 10 minutes for node discovery - {{ discovered_host.id }}" + uri: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTER }}/hosts/{{ discovered_host.id }}" + method: GET + status_code: [200, 201] + return_content: True + register: host + until: host.json.inventory is defined + retries: 10 + delay: 60 + when: discovered_host.inventory is not defined + +- name: Identify the discovered host {{ discovered_host.id }} + set_fact: + host: "{{ host.json }}" + when: discovered_host.inventory is not defined + +- name: Identify the discovered host {{ discovered_host.id }} + set_fact: + host: "{{ discovered_host }}" + when: discovered_host.inventory is defined + +- name: Identify the host {{ host.id }} properties + set_fact: + host_inventory: "{{ host.inventory }}" + host_id: "{{ host.id }}" + host_name: "{{ host.requested_hostname | default('node' + lookup('password', '/dev/null chars=ascii_lowercase,digits length=8')) }}" + host_role: "auto-assign" + +- name: Identify the host {{ host.id }} interfaces + set_fact: + host_interfaces: "{{ host_inventory.interfaces }}" + +- name : Set host name and role for {{ host.id }} + set_fact: + host_name: "{{ item.0 }}" + host_role: "{{ hostvars[item.0]['role'] }}" + when: hostvars[item.0]['mac'] is defined and ( hostvars[item.0]['mac'] | upper ) == ( item.1.mac_address | upper ) + loop: "{{ inventory_nodes | product(host_interfaces) | list }}" + no_log: True + +- name : Prepare hosts name and role for {{ host_name }} + set_fact: + host: + id: "{{ host_id }}" + hostname: "{{ host_name }}" + role: "{{ host_role }}" + when: host_name is defined or host_role is defined + +- name: Set host name and role for {{ host_name }} + uri: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTER }}" + method: PATCH + url_username: "{{ HTTP_AUTH_USERNAME }}" + url_password: "{{ HTTP_AUTH_PASSWORD }}" + body_format: json + status_code: [201] + return_content: True + body: { + "hosts_names": [ "{{ host }}" ], + "hosts_roles": [ "{{ host }}" ] + } + register: http_reply + +- name: Set the installation disk path + when: hostvars[host_name]['installation_disk_path'] is defined + block: + - name: Fetch the installation disk path from host vars + set_fact: + installation_disk_path: "{{ hostvars[host_name]['installation_disk_path'] }}" + + - name: "Set the installation disk to {{ installation_disk_path }} for {{ host_name }}" + uri: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTER }}" + method: PATCH + status_code: [201] + return_content: true + body_format: json + body: { + "disks_selected_config": [ + { + "id": "{{ host_id }}", + "disks_config": [ + { + "id": "{{ installation_disk_path }}", + "role": "install" + } + ] + } + ] + } + register: http_reply diff --git a/roles/install_cluster/tasks/main.yml b/roles/install_cluster/tasks/main.yml new file mode 100644 index 00000000..4bfd7c3f --- /dev/null +++ b/roles/install_cluster/tasks/main.yml @@ -0,0 +1,104 @@ +--- +# tasks file for install_cluster + +- name: Get discovery ignition file + uri: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTER }}/discovery-ignition" + method: GET + status_code: [200] + return_content: True + register: discovery_ignition + +- debug: + var: discovery_ignition.json + when: debug | bool == True + +- name: "Copy discovery_ignition.json" + copy: + content: "{{ discovery_ignition.json }}" + dest: "{{ fetched_dest }}/discovery-ignition.txt" + delegate_to: localhost + become: no + +# TODO: Validate cluster settings +- name: Count the hosts to be discovered + set_fact: + inventory_hosts: "{{ groups['masters'] | length + groups['workers'] | default([]) | length }}" + +- name: Join list for workers and masters + set_fact: + nodes: "{{ groups['masters'] + groups['workers'] | default([]) }}" + inventory_nodes: "{{ groups['masters'] + groups['workers'] | default([]) }}" + +# Monitor hosts discovery +- name: Allow up to 20 minutes for all hosts to be discovered + uri: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTER }}" + method: GET + status_code: [200, 201] + return_content: True + register: cluster + until: ( cluster.json.hosts | length == inventory_nodes | length ) and ( cluster.json.status == "pending-for-input") + retries: 20 + delay: 60 + when: install | bool == True + +- name: Patch discovered hosts + include_tasks: hosts_discovery.yml + with_items: + - "{{ cluster.json.hosts }}" + loop_control: + loop_var: discovered_host + no_log: True + +# Patch the cluster with the API Virtual IP +- name: Patch cluster with API Virtual IP + uri: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTER }}" + method: PATCH + status_code: [201] + return_content: True + body_format: json + body: { + "vip_dhcp_allocation": "{{ VIP_DHCP_ALLOCATION | lower | bool }}", + "ingress_vip": "{{ INGRESS_VIP }}", + "api_vip": "{{ API_VIP }}" + } + when: install | bool == True + register: http_reply + +- debug: + var: http_reply.json + when: debug | bool == True + +# Monitor cluster discovery +- name: Wait up to 20 minutes for the cluster to report as ready + uri: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTER }}" + method: GET + status_code: [200, 201] + return_content: True + register: cluster + until: "cluster.json.status == 'ready'" + retries: 20 + delay: 60 + when: install | bool == True + +# Install cluster +- name: Install cluster + uri: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTER }}/actions/install" + method: POST + status_code: [202] + return_content: True + body_format: json + body: { } + when: install | bool == True + register: http_reply + +- name: Debug http_reply + debug: + var: http_reply.json + when: debug | bool == True and install | bool == True + + diff --git a/roles/install_cluster/tests/inventory b/roles/install_cluster/tests/inventory new file mode 100644 index 00000000..2fbb50c4 --- /dev/null +++ b/roles/install_cluster/tests/inventory @@ -0,0 +1 @@ +localhost diff --git a/roles/install_cluster/tests/test.yml b/roles/install_cluster/tests/test.yml new file mode 100644 index 00000000..1575ff8e --- /dev/null +++ b/roles/install_cluster/tests/test.yml @@ -0,0 +1,5 @@ +--- +- hosts: localhost + remote_user: root + roles: + - install_cluster diff --git a/roles/monitor_cluster/defaults/main.yml b/roles/monitor_cluster/defaults/main.yml new file mode 100644 index 00000000..e7ccfbcb --- /dev/null +++ b/roles/monitor_cluster/defaults/main.yml @@ -0,0 +1,15 @@ +--- +# defaults file for monitor_cluster + +monitor: True +secure: False +debug: False + +ASSISTED_INSTALLER_HOST: "{{ ansible_default_ipv4.address|default(ansible_all_ipv4_addresses[0]) }}" +ASSISTED_INSTALLER_PORT: 8090 +ASSISTED_INSTALLER_BASE_URL: "{{ secure | ternary('https', 'http') }}://{{ ASSISTED_INSTALLER_HOST }}:{{ ASSISTED_INSTALLER_PORT }}/api/assisted-install/v1" +URL_ASSISTED_INSTALLER_CLUSTER: "{{ ASSISTED_INSTALLER_BASE_URL }}/clusters/{{ CLUSTER_ID }}" + +# HTTP Basic Authentication +HTTP_AUTH_USERNAME: "none" +HTTP_AUTH_PASSWORD: "none" diff --git a/roles/monitor_cluster/meta/main.yml b/roles/monitor_cluster/meta/main.yml new file mode 100644 index 00000000..085384d7 --- /dev/null +++ b/roles/monitor_cluster/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - role: validate_inventory diff --git a/roles/monitor_cluster/tasks/main.yml b/roles/monitor_cluster/tasks/main.yml new file mode 100644 index 00000000..4f0d0d61 --- /dev/null +++ b/roles/monitor_cluster/tasks/main.yml @@ -0,0 +1,27 @@ +--- +# tasks file for monitor_cluster + +# Monitor cluster installation +- name: Wait for up to 60 minutes for the cluster to report as installed + uri: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTER }}" + method: GET + status_code: [200, 201] + return_content: True + register: cluster + until: cluster.json.status in ['installed', 'error', 'cancelled'] + retries: 60 + delay: 60 + delegate_to: bastion + +- name: "Fail installation because user cancelled (ERROR)" + fail: + msg: "Cluster installation failed - Reset the installation process to return to the configuration and try again" + when: cluster.json.status == 'error' + delegate_to: bastion + +- name: "Fail installation because user cancelled (CANCELLED)" + fail: + msg: "Installation was canceled by user - Reset the installation process to return to the configuration and try again" + when: cluster.json.status == 'cancelled' + delegate_to: bastion diff --git a/roles/monitor_host/defaults/main.yml b/roles/monitor_host/defaults/main.yml new file mode 100644 index 00000000..e7ccfbcb --- /dev/null +++ b/roles/monitor_host/defaults/main.yml @@ -0,0 +1,15 @@ +--- +# defaults file for monitor_cluster + +monitor: True +secure: False +debug: False + +ASSISTED_INSTALLER_HOST: "{{ ansible_default_ipv4.address|default(ansible_all_ipv4_addresses[0]) }}" +ASSISTED_INSTALLER_PORT: 8090 +ASSISTED_INSTALLER_BASE_URL: "{{ secure | ternary('https', 'http') }}://{{ ASSISTED_INSTALLER_HOST }}:{{ ASSISTED_INSTALLER_PORT }}/api/assisted-install/v1" +URL_ASSISTED_INSTALLER_CLUSTER: "{{ ASSISTED_INSTALLER_BASE_URL }}/clusters/{{ CLUSTER_ID }}" + +# HTTP Basic Authentication +HTTP_AUTH_USERNAME: "none" +HTTP_AUTH_PASSWORD: "none" diff --git a/roles/monitor_host/meta/main.yml b/roles/monitor_host/meta/main.yml new file mode 100644 index 00000000..085384d7 --- /dev/null +++ b/roles/monitor_host/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - role: validate_inventory diff --git a/roles/monitor_host/tasks/hosts_monitoring.yml b/roles/monitor_host/tasks/hosts_monitoring.yml new file mode 100644 index 00000000..83ad2c88 --- /dev/null +++ b/roles/monitor_host/tasks/hosts_monitoring.yml @@ -0,0 +1,23 @@ +--- +# tasks file for hosts_monitoring + +- name: "Wait for up to 60 minutes for node {{ host_name }} to reboot" + uri: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTER }}/hosts/{{ host_id }}" + method: GET + status_code: [200, 201] + return_content: True + register: host + until: (host.json.progress.current_stage == 'Rebooting' and host.json.status == 'installing-pending-user-action') + or + host.json.progress.current_stage in ['Configuring', 'Done'] + retries: 60 + delay: 60 + +- name: Force rebooting on disk + include_role: + name: boot_disk + vars: + hosts: + - "{{ host.json.requested_hostname }}" + when: host.json.progress.current_stage == 'Rebooting' and host.json.status == 'installing-pending-user-action' diff --git a/roles/monitor_host/tasks/main.yml b/roles/monitor_host/tasks/main.yml new file mode 100644 index 00000000..4b4eb5bf --- /dev/null +++ b/roles/monitor_host/tasks/main.yml @@ -0,0 +1,28 @@ +--- +# tasks file for monitor_cluster + +- name : Get cluster status during installation + uri: + url: "{{ URL_ASSISTED_INSTALLER_CLUSTER }}" + method: GET + status_code: [200, 201] + return_content: True + register: cluster + delegate_to: bastion + +- name: Identify the host + set_fact: + current_host: "{{ item }}" + loop: "{{ cluster.json.hosts }}" + when: item.requested_hostname == inventory_hostname + no_log: True + +- name: Start host monitoring + include_tasks: hosts_monitoring.yml + args: + apply: + delegate_to: bastion + vars: + host_id: "{{ current_host.id }}" + host_name: "{{ current_host.requested_hostname }}" + no_log: True diff --git a/roles/populate_mirror_registry/defaults/main.yml b/roles/populate_mirror_registry/defaults/main.yml new file mode 100644 index 00000000..3a1e4f5c --- /dev/null +++ b/roles/populate_mirror_registry/defaults/main.yml @@ -0,0 +1,54 @@ +--- +# defaults file for disconnected_registry +force: "no" # force downloads for oc and opm + +openshift_version: "{{ openshift_full_version.split('.')[:2] | join('.') }}" +downloads_path: "{{ ansible_env.HOME }}" + +# Name of the pod running as the registry. +image_name_registry: ocpdiscon-registry + +# oc and opm install +release_url: "https://mirror.openshift.com/pub/openshift-v{{ openshift_version_parts[0] }}/clients/ocp" +oc_tar: openshift-client-linux.tar.gz +opm_tar: opm-linux.tar.gz + +# The information for the locally created registry +registry_fqdn: "{{ ansible_fqdn }}" +local_registry: "{{ registry_fqdn }}:{{ registry_port }}" +local_repo: ocp4/openshift4 +local_registry_index_tag: "olm-index/redhat-operator-index:v{{ openshift_version_parts[:2] | join('.') }}" +local_registry_image_tag: olm +remote_registry: registry.redhat.io +remote_registry_index: "{{ remote_registry }}/redhat/redhat-operator-index:v{{ openshift_version_parts[:2] | join('.') }}" +registry_port: 5000 + +installer_agent_image: + remote: "quay.io/ocpmetal/assisted-installer-agent@{{ image_hashes.installer_agent }}" + local: "{{ local_registry }}/ocpmetal/assisted-installer-agent" + +installer_image: + remote: "quay.io/ocpmetal/assisted-installer@{{ image_hashes.installer }}" + local: "{{ local_registry }}/ocpmetal/assisted-installer" + +installer_controller_image: + remote: "quay.io/ocpmetal/assisted-installer-controller@{{ image_hashes.controller }}" + local: "{{ local_registry }}/ocpmetal/assisted-installer-controller" + +ocpmetal_images: + - "{{ installer_agent_image }}" + - "{{ installer_image }}" + - "{{ installer_controller_image }}" + +mirror_packages: + - advanced-cluster-management + - cluster-logging + - kubevirt-hyperconverged + - local-storage-operator + - ocs-operator + - performance-addon-operator + - ptp-operator + - sriov-network-operator + +file_owner: "{{ ansible_env.USER }}" +file_group: "{{ file_owner }}" diff --git a/roles/populate_mirror_registry/meta/main.yml b/roles/populate_mirror_registry/meta/main.yml new file mode 100644 index 00000000..085384d7 --- /dev/null +++ b/roles/populate_mirror_registry/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - role: validate_inventory diff --git a/roles/populate_mirror_registry/tasks/main.yml b/roles/populate_mirror_registry/tasks/main.yml new file mode 100644 index 00000000..50f37c8c --- /dev/null +++ b/roles/populate_mirror_registry/tasks/main.yml @@ -0,0 +1,13 @@ +--- +# tasks file for populate_mirror_registry +- import_tasks: var_check.yml + tags: + - populate_registry + +- import_tasks: prerequisites.yml + tags: + - populate_registry + +- import_tasks: populate_registry.yml + tags: + - populate_registry diff --git a/roles/populate_mirror_registry/tasks/populate_registry.yml b/roles/populate_mirror_registry/tasks/populate_registry.yml new file mode 100644 index 00000000..37de9583 --- /dev/null +++ b/roles/populate_mirror_registry/tasks/populate_registry.yml @@ -0,0 +1,53 @@ +--- +- name: Populate Mirror + block: + - name: Create podman auth dir + file: + path: "{{ config_file_path }}/containers/" + state: directory + + - name: Copy pull_secrets file. + copy: + src: "{{ config_file_path }}/{{ pull_secret_file_name }}" + dest: "{{ config_file_path }}/containers/auth.json" + remote_src: yes + + - name: Podman login to remote registry + containers.podman.podman_login_info: + registry: "{{ remote_registry }}" + register: login + environment: + XDG_RUNTIME_DIR: "{{ config_file_path }}" + + - name: Mirror remote ocpmetal image registry to local + command: > + /usr/bin/oc image mirror + -a "{{ config_file_path }}/{{ pull_secret_file_name }}" + {{ item.remote }} + {{ item.local }}:latest + loop: "{{ ocpmetal_images }}" + + - name: Mirror remote registry to local + command: > + /usr/bin/oc adm release mirror + -a "{{ config_file_path }}/{{ pull_secret_file_name }}" + --from="{{ release_image | quote }}" + --to-release-image="{{ local_registry | quote }}/{{ local_repo | quote }}:{{ openshift_full_version }}-x86_64" + --to="{{ local_registry | quote }}/{{ local_repo | quote }}" + + - name: Build pruned OLM index + command: > + opm index prune --from-index "{{ remote_registry_index }}" + --packages "{{ mirror_packages | join(',') }}" + --tag "{{ local_registry }}/{{ local_registry_index_tag }}" + environment: + XDG_RUNTIME_DIR: "{{ config_file_path }}" + + - name: Push pruned index to local registry + command: > + podman push --tls-verify=false + {{ local_registry }}/{{ local_registry_index_tag }} + --authfile "{{ config_file_path }}/{{ pull_secret_file_name }}" + become: true + tags: + - mirror_images diff --git a/roles/populate_mirror_registry/tasks/prerequisites.yml b/roles/populate_mirror_registry/tasks/prerequisites.yml new file mode 100644 index 00000000..ed1afebb --- /dev/null +++ b/roles/populate_mirror_registry/tasks/prerequisites.yml @@ -0,0 +1,127 @@ +--- +- name: Make sure needed packages are installed + package: + name: tar + state: present + become: true + +- name: Create download dir + ansible.builtin.file: + path: "{{ downloads_path }}" + owner: "{{ file_owner }}" + group: "{{ file_group }}" + mode: 0755 + state: directory + +- name: Setup Version/Image facts + block: + - name: Get Release.txt File + uri: + url: "{{ release_url }}/{{ openshift_full_version }}/release.txt" + return_content: yes + register: result + until: result.status == 200 + retries: 6 # 1 minute (10 * 6) + delay: 10 # Every 10 seconds + failed_when: result.content|length == 0 or result.status >= 400 + - name: Set Fact for Release Image + set_fact: + release_version: "{{ result.content | regex_search('Version:.*') | regex_replace('Version:\\s*(.*)', '\\1') }}" + release_image: "{{ result.content | regex_search('Pull From:.*') | regex_replace('Pull From:\\s*(.*)', '\\1') }}" + +- name: Install oc + block: + - name: Download oc tarball + get_url: + url: "{{ release_url }}/{{ release_version }}/{{ oc_tar }}" + dest: "{{ downloads_path }}/" + force: "{{force}}" + backup: yes + + - name: Make directory to extract oc binary + file: + path: "{{ downloads_path }}/openshift-client-linux" + owner: "{{ file_owner }}" + group: "{{ file_group }}" + mode: 0775 + state: directory + + - name: Extract oc binary + unarchive: + src: "{{ downloads_path }}/{{ oc_tar }}" + dest: "{{ downloads_path }}/openshift-client-linux" + owner: "{{ file_owner }}" + group: "{{ file_group }}" + remote_src: yes + become: true + + - name: Move oc to /usr/bin + copy: + src: "{{ downloads_path }}/openshift-client-linux/oc" + dest: /usr/bin/oc + owner: "{{ file_owner }}" + group: "{{ file_group }}" + mode: 0775 + remote_src: yes + become: true + + - name: Move kubectl to /usr/bin + copy: + src: "{{ downloads_path }}/openshift-client-linux/kubectl" + dest: /usr/bin/kubectl + owner: "{{ file_owner }}" + group: "{{ file_group }}" + mode: 0775 + remote_src: yes + become: true + + - name: Check kubectl installed + command: /usr/bin/kubectl version + + - name: Check oc installed + command: /usr/bin/oc version + + tags: + - create_registry + - fetch_oc + +- name: Install opm + block: + - name: Download opm tarball + get_url: + url: "{{ release_url }}/stable-{{ openshift_version }}/{{ opm_tar }}" + dest: "{{ downloads_path }}/" + force: "{{force}}" + backup: yes + + - name: Make directory to extract binaries + file: + path: "{{ downloads_path }}/opm-linux" + owner: "{{ file_owner }}" + group: "{{ file_group }}" + mode: 0775 + state: directory + + - name: Extract binary + unarchive: + src: "{{ downloads_path }}/{{ opm_tar }}" + dest: "{{ downloads_path }}/opm-linux" + owner: "{{ file_owner }}" + group: "{{ file_group }}" + remote_src: yes + + - name: Move opm to /usr/bin + copy: + src: "{{ downloads_path }}/opm-linux/opm" + dest: /usr/bin/opm + owner: "{{ file_owner }}" + group: "{{ file_group }}" + mode: 0775 + remote_src: yes + become: true + + - name: Check opm installed + command: opm version + tags: + - create_registry + - fetch_opm diff --git a/roles/populate_mirror_registry/tasks/var_check.yml b/roles/populate_mirror_registry/tasks/var_check.yml new file mode 100644 index 00000000..28f7713d --- /dev/null +++ b/roles/populate_mirror_registry/tasks/var_check.yml @@ -0,0 +1,15 @@ +--- +- name: Check openshift_full_version is set + fail: + msg: openshift_full_version must be set and not empty + when: (openshift_full_version is not defined) or (openshift_full_version == "") + +- name: Check openshift_full_version is has at last two parts + block: + - name: Split openshift_full_version + set_fact: + openshift_version_parts: "{{ openshift_full_version.split('.') }}" + - name: + fail: + msg: openshift_full_version does not have at least two parts + when: openshift_version_parts | length < 2 diff --git a/roles/prereq_facts_check/README.md b/roles/prereq_facts_check/README.md new file mode 100644 index 00000000..9feb5948 --- /dev/null +++ b/roles/prereq_facts_check/README.md @@ -0,0 +1,18 @@ +# Prereqs facts check + +Checks that required facts are set correctly + +## Role Variables + +- `pull_secret_check`: Wether to check `pull_secret` fact is valid +- `ssh_public_check`: Wether to check `ssh_public` fact is valid +- `mirror_certificate_check`: Wether to check `mirror_certificate` fact is valid + +## Example Playbook + +```yaml +- name: Check facts + hosts: localhost + roles: + - prereq_facts_check +``` diff --git a/roles/prereq_facts_check/defaults/main.yml b/roles/prereq_facts_check/defaults/main.yml new file mode 100644 index 00000000..86546917 --- /dev/null +++ b/roles/prereq_facts_check/defaults/main.yml @@ -0,0 +1,3 @@ +pull_secret_check: true +ssh_public_check: true +mirror_certificate_check: true diff --git a/roles/prereq_facts_check/meta/main.yml b/roles/prereq_facts_check/meta/main.yml new file mode 100644 index 00000000..085384d7 --- /dev/null +++ b/roles/prereq_facts_check/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - role: validate_inventory diff --git a/roles/prereq_facts_check/tasks/main.yml b/roles/prereq_facts_check/tasks/main.yml new file mode 100644 index 00000000..d30d8f45 --- /dev/null +++ b/roles/prereq_facts_check/tasks/main.yml @@ -0,0 +1,30 @@ +--- +- name: Check for pull_secret + assert: + that: + - pull_secret is defined + - pull_secret.auths is defined + - pull_secret | trim != '' + quiet: true + msg: "The required 'pull_secret' is not defined or is not valid" + when: pull_secret_check | bool == True + +- name: Check for ssh_public_key + assert: + that: + - ssh_public_key is defined + - ssh_public_key is string + - ssh_public_key | trim != '' + quiet: true + msg: "The required 'ssh_public_key' is not defined or is not valid" + when: ssh_public_check | bool == True + +- name: Check for mirror_certificate + assert: + that: + - mirror_certificate is defined + - mirror_certificate is string + - mirror_certificate | trim != '' + quiet: true + msg: "The required 'mirror_certificate' is not defined or is not valid" + when: mirror_certificate_check | bool == True diff --git a/roles/setup_assisted_installer/defaults/main.yml b/roles/setup_assisted_installer/defaults/main.yml new file mode 100644 index 00000000..3138e44f --- /dev/null +++ b/roles/setup_assisted_installer/defaults/main.yml @@ -0,0 +1,86 @@ +ai_version: v1.0.24.2 +assisted_service_tag: "{{ ai_version }}" +assisted_service_gui_tag: "{{ ai_version }}" + +coreos_installer_tag: v0.7.0 + +coreos_installer_image: "quay.io/coreos/coreos-installer:{{ coreos_installer_tag }}" +assisted_service_image: "quay.io/ocpmetal/assisted-service:{{ assisted_service_tag }}" +assisted_service_gui_image: "quay.io/ocpmetal/ocp-metal-ui:{{ assisted_service_gui_tag }}" +assisted_postgres_image: quay.io/ocpmetal/postgresql-12-centos7 + +assisted_service_openshift_versions: + "4.6": + display_name: 4.6.16 + release_image: quay.io/openshift-release-dev/ocp-release:4.6.16-x86_64 + release_version: 4.6.16 + rhcos_image: https://mirror.openshift.com/pub/openshift-v4/dependencies/rhcos/4.6/4.6.8/rhcos-4.6.8-x86_64-live.x86_64.iso + rhcos_rootfs: https://mirror.openshift.com/pub/openshift-v4/dependencies/rhcos/4.6/4.6.8/rhcos-live-rootfs.x86_64.img + rhcos_version: 46.82.202012051820-0 + support_level: production + "4.7": + display_name: 4.7.28 + release_image: quay.io/openshift-release-dev/ocp-release:4.7.28-x86_64 + release_version: 4.7.28 + rhcos_image: https://mirror.openshift.com/pub/openshift-v4/dependencies/rhcos/4.7/4.7.13/rhcos-4.7.13-x86_64-live.x86_64.iso + rhcos_rootfs: https://mirror.openshift.com/pub/openshift-v4/dependencies/rhcos/4.7/4.7.13/rhcos-live-rootfs.x86_64.img + rhcos_version: 47.83.202105220305-0 + support_level: production + "4.8": + default: true + display_name: 4.8.10 + release_image: quay.io/openshift-release-dev/ocp-release:4.8.10-x86_64 + release_version: 4.8.10 + rhcos_image: https://mirror.openshift.com/pub/openshift-v4/dependencies/rhcos/4.8/4.8.2/rhcos-4.8.2-x86_64-live.x86_64.iso + rhcos_rootfs: https://mirror.openshift.com/pub/openshift-v4/dependencies/rhcos/4.8/4.8.2/rhcos-live-rootfs.x86_64.img + rhcos_version: 48.84.202107202156-0 + support_level: production + "4.9": + display_name: 4.9.0-rc.0 + release_image: quay.io/openshift-release-dev/ocp-release:4.9.0-rc.0-x86_64 + release_version: 4.9.0-rc.0 + rhcos_image: https://mirror.openshift.com/pub/openshift-v4/dependencies/rhcos/pre-release/4.9.0-rc.0/rhcos-4.9.0-rc.0-x86_64-live.x86_64.iso + rhcos_rootfs: https://mirror.openshift.com/pub/openshift-v4/dependencies/rhcos/pre-release/4.9.0-rc.0/rhcos-live-rootfs.x86_64.img + rhcos_version: 49.84.202109041651-0 + support_level: beta + +assisted_installer_images: + release: + image: "quay.io/openshift-release-dev/ocp-release@{{ image_hashes.release }}" + controller: + image: "quay.io/ocpmetal/assisted-installer-controller@{{ image_hashes.controller }}" + installer_agent: + image: "quay.io/ocpmetal/assisted-installer-agent@{{ image_hashes.installer_agent }}" + installer: + image: "quay.io/ocpmetal/assisted-installer@{{ image_hashes.installer }}" + +rhcos_image: "{{ assisted_service_openshift_versions[openshift_version]['rhcos_image'] }}" +assisted_installer_image: "{{ assisted_installer_images.installer.image }}" +assisted_installer_agent_image: "{{ assisted_installer_images.installer_agent.image }}" +assisted_installer_controller_image: "{{ assisted_installer_images.controller.image }}" + +assisted_installer_dir: /opt/assisted-installer + +ai_ports: + - 8000:8000 + - 8090:8090 + - 8080:8080 + +# Format has changed and likely will continue to change for HW validation (Link: https://github.com/openshift/assisted-service/blob/master/onprem-environment#L19) +assisted_installer_hardware_validation: + - version: default + master: + cpu_cores: 4 + ram_mib: 6144 + disk_size_gb: 20 + installation_disk_speed_threshold_ms: 10 + worker: + cpu_cores: 4 + ram_mib: 4096 + disk_size_gb: 20 + installation_disk_speed_threshold_ms: 10 + sno: + cpu_cores: 8 + ram_mib: 32768 + disk_size_gb: 120 + installation_disk_speed_threshold_ms: 10 diff --git a/roles/setup_assisted_installer/meta/main.yml b/roles/setup_assisted_installer/meta/main.yml new file mode 100644 index 00000000..085384d7 --- /dev/null +++ b/roles/setup_assisted_installer/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - role: validate_inventory diff --git a/roles/setup_assisted_installer/tasks/main.yml b/roles/setup_assisted_installer/tasks/main.yml new file mode 100644 index 00000000..38c245ed --- /dev/null +++ b/roles/setup_assisted_installer/tasks/main.yml @@ -0,0 +1,142 @@ +--- +- name: Setup AI + become: True + block: + - name: Open ports zone internal and public, for firewalld + firewalld: + port: "{{ item.1 }}/tcp" + permanent: yes + immediate: yes + state: enabled + zone: "{{ item.0 }}" + loop: "{{ ['internal', 'public'] | product(['8000', '8090', '8080']) | list }}" + + - name: Create directories for assisted-installer + file: + path: "{{ item }}" + mode: 0775 + state: directory + with_items: + - "{{ assisted_installer_dir }}" + - "{{ assisted_installer_dir }}/data" + - "{{ assisted_installer_dir }}/coreos_installer_data" + + - name: Create directory for assisted-installer database + file: + path: "{{ assisted_installer_dir }}/data/postgresql" + mode: 0775 + state: directory + recurse: true + + - name: Download the rhcos image + get_url: + url: "{{ rhcos_image }}" + dest: "{{ assisted_installer_dir }}/{{ rhcos_image | basename }}" + + - name: Get coreos-installer via podman + containers.podman.podman_container: + name: coreos-installer + rm: true + detach: false + volume: + - "{{ assisted_installer_dir }}/coreos_installer_data/:/data:z" + workdir: /data + entrypoint: /bin/bash + image: "{{ coreos_installer_image }}" + command: ["-c", "cp /usr/sbin/coreos-installer /data/coreos-installer"] + state: started + + - name: Move coreos-installer + copy: + src: "{{ assisted_installer_dir }}/coreos_installer_data/coreos-installer" + dest: "{{ assisted_installer_dir }}/coreos-installer" + mode: 0555 + remote_src: true + + - name: Template out assisted-installer files + template: + src: "{{ item.src }}" + dest: "{{ item.dest }}" + with_items: + - src: onprem-environment.j2 + dest: "{{ assisted_installer_dir }}/onprem-environment" + - src: nginx-ui.conf + dest: "{{ assisted_installer_dir }}/nginx-ui.conf" + + - name: Create assisted-service pod + containers.podman.podman_pod: + name: assisted-service + state: started + network: "{{ podman_network | default(omit) }}" + ports: "{{ ai_ports }}" + + - name: Create database container in assisted-service pod + containers.podman.podman_container: + pod: assisted-service + env_file: "{{ assisted_installer_dir }}/onprem-environment" + volume: + - "{{ assisted_installer_dir }}/data/postgresql:/var/lib/pgsql:z" + name: db + image: "{{ assisted_postgres_image }}" + state: started + + - name: Create assisted installer GUI container in assisted-service pod + containers.podman.podman_container: + pod: assisted-service + env_file: "{{ assisted_installer_dir }}/onprem-environment" + volume: + - "{{ assisted_installer_dir }}/nginx-ui.conf:/opt/bitnami/nginx/conf/server_blocks/nginx.conf:z" + name: gui + image: "{{ assisted_service_gui_image }}" + state: started + + - name: Create assisted installer service container in assisted-service pod + containers.podman.podman_container: + pod: assisted-service + env_file: "{{ assisted_installer_dir }}/onprem-environment" + volume: + - "{{ assisted_installer_dir }}/{{ rhcos_image | basename }}:/data/livecd.iso:z" + - "{{ assisted_installer_dir }}/coreos-installer:/data/coreos-installer:z" + name: installer + image: "{{ assisted_service_image }}" + env: + PULL_SECRET: "{{ PULL_SECRET }}" + DUMMY_IGNITION: False + state: started + register: assisted_service_container_info + +- name: Setup assisted_installer service + become: true + block: + - name: Copy the systemd service file + copy: + content: | + [Unit] + Description=Podman assisted_installer.service + [Service] + Restart=on-failure + ExecStart=/usr/bin/podman pod start assisted-service + ExecStop=/usr/bin/podman pod stop -t 10 assisted-service + KillMode=none + Type=forking + PIDFile={{ assisted_service_container_info.container.ConmonPidFile }} + [Install] + WantedBy=default.target + dest: "/etc/systemd/system/assisted_installer.service" + + - name: Reload systemd service + systemd: + daemon_reexec: yes + scope: system + + - name: Enable assisted_installer.service + systemd: + name: assisted_installer + enabled: yes + scope: system + + - name: Start assisted_installer.service + systemd: + name: assisted_installer + state: started + scope: system diff --git a/roles/setup_assisted_installer/templates/nginx-ui.conf b/roles/setup_assisted_installer/templates/nginx-ui.conf new file mode 100644 index 00000000..7d9d1a44 --- /dev/null +++ b/roles/setup_assisted_installer/templates/nginx-ui.conf @@ -0,0 +1,20 @@ +######################################################################## +# file: /opt/assisted-service/nginx-ui.conf +######################################################################## +server { + listen 0.0.0.0:8080; + server_name _; + root /app; + index index.html; + location /api { + proxy_pass http://localhost:8090; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } + location / { + try_files $uri /index.html; + } +} diff --git a/roles/setup_assisted_installer/templates/onprem-environment.j2 b/roles/setup_assisted_installer/templates/onprem-environment.j2 new file mode 100644 index 00000000..e540a918 --- /dev/null +++ b/roles/setup_assisted_installer/templates/onprem-environment.j2 @@ -0,0 +1,31 @@ +POSTGRESQL_DATABASE=installer +POSTGRESQL_PASSWORD=admin +POSTGRESQL_USER=admin +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_USER=admin +DB_PASS=admin +DB_NAME=installer +SERVICE_BASE_URL=http://{{ host | default("ansible_fqdn") }}:8090 +DEPLOY_TARGET=onprem +STORAGE=filesystem +DUMMY_IGNITION=false + +OPENSHIFT_VERSIONS={{ assisted_service_openshift_versions | to_json }} +ENABLE_SINGLE_NODE_DNSMASQ=true +PUBLIC_CONTAINER_REGISTRIES=quay.io +NTP_DEFAULT_SERVER= +IPV6_SUPPORT=true +AUTH_TYPE=none + +SKIP_CERT_VERIFICATION=true + +SELF_VERSION={{ assisted_service_image }} +INSTALLER_IMAGE={{ assisted_installer_image }} +CONTROLLER_IMAGE={{ assisted_installer_controller_image }} +AGENT_DOCKER_IMAGE={{ assisted_installer_agent_image }} + +HW_VALIDATOR_MIN_DISK_SIZE_GIB=20 + +# Format has changed and likely will continue to change for HW validation (Link: https://github.com/openshift/assisted-service/blob/master/onprem-environment#L19) +HW_VALIDATOR_REQUIREMENTS={{ assisted_installer_hardware_validation | to_json }} diff --git a/roles/setup_http_store/README.md b/roles/setup_http_store/README.md new file mode 100644 index 00000000..8cdf9c34 --- /dev/null +++ b/roles/setup_http_store/README.md @@ -0,0 +1,28 @@ +# Setup HTTP Store + +Sets up a web host which can be used to distribute iso's for `boot_iso` role + +## Role Variables + +| Variable | Required | Default | Comments | +| ------------------------- | -------- | -------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | +| http_store_container_name | no | http_store | | +| http_store_pod_name | no | http_store_pod | | +| http_dir | no | /opt/http_store | | +| http_data_dir | no | "{{ http_dir }}/data" | | +| container_image | no | registry.centos.org/centos/httpd-24-centos7:latest | If you change this to anything other than the same image on a different host you may need to change then enviroment vars in the task | + +## Dependencies + +- containers.podman + +## Example Playbook + +``` +- name: Install and http_store service + hosts: http_store + roles: + - setup_http_store + vars: + http_store_container_name: "iso store" +``` diff --git a/roles/setup_http_store/defaults/main.yml b/roles/setup_http_store/defaults/main.yml new file mode 100644 index 00000000..166245d3 --- /dev/null +++ b/roles/setup_http_store/defaults/main.yml @@ -0,0 +1,8 @@ +http_store_container_name: http_store +http_store_pod_name: http_store_pod +http_dir: /opt/http_store +http_data_dir: "{{ http_dir }}/data" +# Note if you change this you might have to change the env vars and volumes for podman task +container_image: registry.centos.org/centos/httpd-24-centos7:latest +file_owner: "{{ ansible_env.USER }}" +file_group: "{{ file_owner }}" diff --git a/roles/setup_http_store/meta/main.yml b/roles/setup_http_store/meta/main.yml new file mode 100644 index 00000000..085384d7 --- /dev/null +++ b/roles/setup_http_store/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - role: validate_inventory diff --git a/roles/setup_http_store/tasks/main.yml b/roles/setup_http_store/tasks/main.yml new file mode 100644 index 00000000..b2c612e4 --- /dev/null +++ b/roles/setup_http_store/tasks/main.yml @@ -0,0 +1,89 @@ +--- +- name: Open http port, zone internal and public, for firewalld + firewalld: + port: "80/tcp" + permanent: yes + immediate: yes + state: enabled + zone: "{{ item }}" + become: yes + with_items: + - internal + - public + +- name: Create directory to hold the registry files + file: + path: "{{ item }}" + state: directory + owner: "{{ file_owner }}" + group: "{{ file_group }}" + mode: 0775 + recurse: yes + loop: + - "{{ http_dir }}" + - "{{ http_data_dir }}" + become: true + +- name: Setup pod + block: + - name: Create httpd container + containers.podman.podman_pod: + name: "{{ http_store_pod_name }}" + publish: + - 80:8080 + register: pod_info + + - debug: + var: pod_info + + - name: Create httpd container + containers.podman.podman_container: + name: "{{ http_store_container_name }}" + image: "{{ container_image }}" + pod: "{{ http_store_pod_name }}" + state: stopped + volumes: + - "{{ http_data_dir }}:/var/www/html:z" + register: container_info + become: true + +- name: Setting facts about container + set_fact: + http_store_name: "{{ container_info.container.Name }}" + http_store_pidfile: "{{ container_info.container.ConmonPidFile }}" + +- name: Setup http_store service + block: + - name: Copy the systemd service file + copy: + content: | + [Unit] + Description=Podman http_store.service + [Service] + Restart=on-failure + ExecStart=/usr/bin/podman pod start {{ http_store_pod_name }} + ExecStop=/usr/bin/podman pod stop -t 10 {{ http_store_pod_name }} + KillMode=none + Type=forking + PIDFile={{ http_store_pidfile }} + [Install] + WantedBy=default.target + dest: "/etc/systemd/system/http_store.service" + + - name: Reload systemd service + systemd: + daemon_reexec: yes + scope: system + + - name: Enable http_store.service + systemd: + name: http_store + enabled: yes + scope: system + + - name: Start http_store.service + systemd: + name: http_store + state: started + scope: system + become: true diff --git a/roles/setup_mirror_registry/defaults/main.yml b/roles/setup_mirror_registry/defaults/main.yml new file mode 100644 index 00000000..ffbfde30 --- /dev/null +++ b/roles/setup_mirror_registry/defaults/main.yml @@ -0,0 +1,42 @@ +--- +# defaults file for disconnected_registry +force: "no" # force downloads for oc and opm +pull_secret_file_name: pull-secret.txt +localhost_pull_secret_file: "./{{ pull_secret_file_name }}" +registry_container_image: docker.io/library/registry:2 + +# Paths +config_file_path: "{{ ansible_env.HOME }}" # Where config and secret files should be stored +fetched_dest: ./fetched + +# vars file for disconnected_registry +# packages needed for the disconnected registry tasks +required_packages: + - podman + - httpd + - httpd-tools + +openshift_version: "{{ openshift_full_version.split('.')[:2] | join('.') }}" + +# Name of the pod running as the registry. +image_name_registry: ocpdiscon-registry + +# Registry directories to be created +registry_dir: /opt/registry +registry_dir_auth: "{{ registry_dir }}/auth" +registry_dir_cert: "{{ registry_dir }}/certs" +registry_dir_data: "{{ registry_dir }}/data" + +# The information for the locally created registry +registry_host: "{{ ansible_fqdn }}" +local_registry: "{{ registry_host }}:{{ registry_port }}" +registry_port: 5000 + +install_config_appends_file: install-config-appends.yml +registry_auth_file: registry-auths.json + +registry_email_user: "{{ ansible_env.USER }}" +registry_email: "{{ registry_email_user }}@{{ registry_fqdn }}" + +file_owner: "{{ ansible_env.USER }}" +file_group: "{{ file_owner }}" diff --git a/roles/setup_mirror_registry/meta/main.yml b/roles/setup_mirror_registry/meta/main.yml new file mode 100644 index 00000000..085384d7 --- /dev/null +++ b/roles/setup_mirror_registry/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - role: validate_inventory diff --git a/roles/setup_mirror_registry/tasks/main.yml b/roles/setup_mirror_registry/tasks/main.yml new file mode 100644 index 00000000..8fd5c7aa --- /dev/null +++ b/roles/setup_mirror_registry/tasks/main.yml @@ -0,0 +1,15 @@ +--- +# tasks file for disconnected_registry +- import_tasks: var_check.yml + tags: create_registry + +- import_tasks: prerequisites.yml + tags: + - create_registry + +- import_tasks: setup_registry.yml + tags: create_registry + +- import_tasks: retrieve_config.yml + tags: + - copy_config diff --git a/roles/setup_mirror_registry/tasks/prerequisites.yml b/roles/setup_mirror_registry/tasks/prerequisites.yml new file mode 100644 index 00000000..5066a8e7 --- /dev/null +++ b/roles/setup_mirror_registry/tasks/prerequisites.yml @@ -0,0 +1,31 @@ +--- +- name: Make sure needed packages are installed + package: + name: "{{ required_packages }}" + state: present + become: true + tags: + - create_registry + +- name: Check cert exists + block: + - name: Get cert stat + stat: + path: "{{ registry_dir_cert}}/domain.crt" + register: cert_file + - fail: + msg: "Cert file {{ registry_dir_cert }}/domain.crt missing" + when: cert_file.stat.exists | bool == False + +- name: Create config_file_path dir + file: + path: "{{ config_file_path }}" + owner: "{{ file_owner }}" + group: "{{ file_group }}" + mode: 0775 + state: directory + +- name: Copy pull_secret + copy: + src: "{{ localhost_pull_secret_file }}" + dest: "{{ config_file_path }}/{{ pull_secret_file_name }}" diff --git a/roles/setup_mirror_registry/tasks/retrieve_config.yml b/roles/setup_mirror_registry/tasks/retrieve_config.yml new file mode 100644 index 00000000..d980f966 --- /dev/null +++ b/roles/setup_mirror_registry/tasks/retrieve_config.yml @@ -0,0 +1,29 @@ +--- +- name: Write auth for disconnected to localhost + fetch: + src: "{{ config_file_path }}/{{ pull_secret_file_name }}" + dest: "{{ fetched_dest }}/" + flat: yes + tags: + - copy_config + +- name: Read in the contents of domain.crt + slurp: + src: "{{ registry_dir_cert }}/domain.crt" + register: domain_cert_b64 + tags: + - copy_config + +- name: Set trustbundle fact to contents of domain.crt + set_fact: + trustbundle: "{{ domain_cert_b64.content | string | b64decode }}" + tags: + - copy_config + +- name: Information + debug: + msg: | + To reuse this disconnected registry for other deployments, you must do the following: + Add the authentication from either + {{ config_file_path }}/{{ registry_auth_file }} on {{ inventory_hostname }} + or {{ config_file_path }}/{{ registry_auth_file }} on this server to your pull secret. diff --git a/roles/setup_mirror_registry/tasks/setup_registry.yml b/roles/setup_mirror_registry/tasks/setup_registry.yml new file mode 100644 index 00000000..94970546 --- /dev/null +++ b/roles/setup_mirror_registry/tasks/setup_registry.yml @@ -0,0 +1,129 @@ +--- +- name: Open registry port, zone internal and public, for firewalld + firewalld: + port: "{{ registry_port }}/tcp" + permanent: yes + immediate: yes + state: enabled + zone: "{{ item }}" + become: yes + with_items: + - internal + - public + +- name: Create directory to hold the registry files + file: + path: "{{ item }}" + owner: "{{ file_owner }}" + group: "{{ file_group }}" + mode: 0775 + state: directory + + recurse: yes + loop: + - "{{ registry_dir_auth }}" + - "{{ registry_dir_cert }}" + - "{{ registry_dir_data }}" + become: true + +- name: Generate htpasswd entry + command: htpasswd -bBn {{ disconnected_registry_user }} {{ disconnected_registry_password }} + register: htpass_entry + +- name: Write htpasswd file + copy: + content: '{{ htpass_entry.stdout }}' + dest: "{{ registry_dir_auth }}/htpasswd" + owner: "{{ file_owner }}" + group: "{{ file_group }}" + mode: 0660 + backup: yes + force: yes + become: True + +- name: Set disconnected_auth + set_fact: + disconnected_registry_up: "{{ disconnected_registry_user }}:{{ disconnected_registry_password }}" + +- name: Update pull_secret variable + set_fact: + pull_secret: "{{ pull_secret | combine( + {'auths': pull_secret['auths'] | combine( + { + local_registry: { + 'auth': disconnected_registry_up | b64encode, + 'email': registry_email + } + } + )} + ) }}" + +- name: Write updated pull_secret + copy: + content: "{{ pull_secret | to_json }}" + dest: "{{ config_file_path }}/{{ pull_secret_file_name }}" + + +- name: Create container to serve the registry + containers.podman.podman_container: + name: "{{ image_name_registry }}" + image: "{{ registry_container_image }}" + state: stopped + expose: "{{ registry_port }}" + network: host + volumes: + - "{{ registry_dir_data }}:/var/lib/registry:z" + - "{{ registry_dir_auth }}:/auth:z" + - "{{ registry_dir_cert }}:/certs:z" + env: + REGISTRY_AUTH: htpasswd + REGISTRY_AUTH_HTPASSWD_REALM: Registry + REGISTRY_HTTP_SECRET: "{{ REGISTRY_HTTP_SECRET }} " + REGISTRY_AUTH_HTPASSWD_PATH: auth/htpasswd + REGISTRY_HTTP_TLS_CERTIFICATE: certs/domain.crt + REGISTRY_HTTP_TLS_KEY: certs/domain.key + become: True + register: registry_container_info + + +- name: Setting facts about container + set_fact: + container_registry_name: "{{ registry_container_info.container.Name }}" + container_registry_pidfile: "{{ registry_container_info.container.ConmonPidFile }}" + +- name: Setup registry service + block: + - name: Copy the systemd service file + copy: + content: | + [Unit] + Description=Podman container-registry.service + [Service] + Restart=on-failure + ExecStart=/usr/bin/podman start {{ container_registry_name }} + ExecStop=/usr/bin/podman stop -t 10 {{ container_registry_name }} + KillMode=none + Type=forking + PIDFile={{ container_registry_pidfile }} + [Install] + WantedBy=default.target + dest: "/etc/systemd/system/container-registry.service" + + + - name: Reload demon to pick up changes in container-registry.service + ansible.builtin.systemd: + name: container-registry + daemon_reload: yes + scope: system + + - name: Start container-registry.service + systemd: + name: container-registry + enabled: yes + scope: system + + - name: Start container-registry.service + systemd: + name: container-registry + state: started + become: true diff --git a/roles/setup_mirror_registry/tasks/var_check.yml b/roles/setup_mirror_registry/tasks/var_check.yml new file mode 100644 index 00000000..c9840445 --- /dev/null +++ b/roles/setup_mirror_registry/tasks/var_check.yml @@ -0,0 +1,30 @@ +--- +- name: Check REGISTRY_HTTP_SECRET is set + fail: + msg: REGISTRY_HTTP_SECRET must be set and not empty + when: (REGISTRY_HTTP_SECRET is not defined) or (REGISTRY_HTTP_SECRET == "") + +- name: Check disconnected_registry_user is set + fail: + msg: disconnected_registry_user must be set and not empty + when: (disconnected_registry_user is not defined) or (disconnected_registry_user == "") + +- name: Check disconnected_registry_password is set + fail: + msg: disconnected_registry_password must be set and not empty + when: (disconnected_registry_password is not defined) or (disconnected_registry_password == "") + +- name: Check openshift_full_version is set + fail: + msg: openshift_full_version must be set and not empty + when: (openshift_full_version is not defined) or (openshift_full_version == "") + +- name: Check openshift_full_version is has at last two parts + block: + - name: Split openshift_full_version + set_fact: + openshift_version_parts: "{{ openshift_full_version.split('.') }}" + - name: + fail: + msg: openshift_full_version does not have at least two parts + when: openshift_version_parts | length < 2 diff --git a/roles/setup_ntp/defaults/main.yml b/roles/setup_ntp/defaults/main.yml new file mode 100644 index 00000000..99253777 --- /dev/null +++ b/roles/setup_ntp/defaults/main.yml @@ -0,0 +1,6 @@ +--- +ntp_pool_servers: + - 0.us.pool.ntp.org + - 1.us.pool.ntp.org + - 2.us.pool.ntp.org + - 3.us.pool.ntp.org diff --git a/roles/setup_ntp/handlers/main.yml b/roles/setup_ntp/handlers/main.yml new file mode 100644 index 00000000..93d02cb6 --- /dev/null +++ b/roles/setup_ntp/handlers/main.yml @@ -0,0 +1,14 @@ +--- +- name: Restart chronyd + ansible.builtin.service: + name: chronyd + state: restarted + become: true + +- name: Start chronyd + ansible.builtin.service: + name: chronyd + state: started + enabled: true + become: true + diff --git a/roles/setup_ntp/meta/main.yml b/roles/setup_ntp/meta/main.yml new file mode 100644 index 00000000..085384d7 --- /dev/null +++ b/roles/setup_ntp/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - role: validate_inventory diff --git a/roles/setup_ntp/tasks/main.yml b/roles/setup_ntp/tasks/main.yml new file mode 100644 index 00000000..f473ecb7 --- /dev/null +++ b/roles/setup_ntp/tasks/main.yml @@ -0,0 +1,31 @@ +--- +- name: Setup Chrony + block: + - name: Install Chrony + ansible.builtin.package: + name: chrony + state: latest + + - name: Configure chrony + ansible.builtin.template: + src: chrony.conf.j2 + dest: /etc/chrony.conf + owner: root + group: root + mode: 0644 + notify: Restart chronyd + + - name: Start chrony + ansible.builtin.service: + name: chronyd + state: started + enabled: true + + - name: Allow incoming ntp traffic + ansible.posix.firewalld: + zone: public + service: ntp + permanent: yes + state: enabled + immediate: yes + become: true diff --git a/roles/setup_ntp/templates/chrony.conf.j2 b/roles/setup_ntp/templates/chrony.conf.j2 new file mode 100644 index 00000000..681d7cf7 --- /dev/null +++ b/roles/setup_ntp/templates/chrony.conf.j2 @@ -0,0 +1,20 @@ +# {{ ansible_managed }} +stratumweight 0 +driftfile /var/lib/chrony/drift +rtcsync +makestep 10 3 +bindcmdaddress 127.0.0.1 +bindcmdaddress ::1 +keyfile /etc/chrony.keys +noclientlog +logchange 0.5 +logdir /var/log/chrony +manual +local stratum 10 +{% if ntp_server_allow %} +allow {{ ntp_server_allow }} +{% endif %} +{% for item in ntp_pool_servers %} +Server {{ item }} +{% endfor %} + diff --git a/roles/setup_selfsigned_cert/defaults/main.yml b/roles/setup_selfsigned_cert/defaults/main.yml new file mode 100644 index 00000000..297260ed --- /dev/null +++ b/roles/setup_selfsigned_cert/defaults/main.yml @@ -0,0 +1,17 @@ +host_var_key: "registry_host" +fetched_dest: ./fetched + +# Directory to be created +registry_dir: /opt/registry +cert_dir: "{{ registry_dir }}/certs" + +# Cert vars +registry_fqdn: "{{ ansible_fqdn }}" +cert_common_name: "{{ registry_fqdn }}" +cert_country: "{{ hostvars[host_var_key]['cert_country'] }}" +cert_state: "{{ hostvars[host_var_key]['cert_state'] }}" +cert_locality: "{{ hostvars[host_var_key]['cert_locality'] }}" +cert_organization: "{{ hostvars[host_var_key]['cert_organization'] }}" + +file_owner: "{{ ansible_env.USER }}" +file_group: "{{ file_owner }}" diff --git a/roles/setup_selfsigned_cert/meta/main.yml b/roles/setup_selfsigned_cert/meta/main.yml new file mode 100644 index 00000000..085384d7 --- /dev/null +++ b/roles/setup_selfsigned_cert/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - role: validate_inventory diff --git a/roles/setup_selfsigned_cert/tasks/main.yml b/roles/setup_selfsigned_cert/tasks/main.yml new file mode 100644 index 00000000..01100944 --- /dev/null +++ b/roles/setup_selfsigned_cert/tasks/main.yml @@ -0,0 +1,100 @@ +--- +- name: Verify the certificate variables are set + assert: + that: + - item is defined + - item is string + - item | trim != '' + quiet: true + loop: + - "{{ cert_common_name }}" + - "{{ cert_country }}" + - "{{ cert_state }}" + - "{{ cert_locality }}" + - "{{ cert_organization }}" + - "{{ cert_organizational_unit }}" + +- name: Install python3-cryptography + package: + name: python3-cryptography + state: present + become: True + +- name: Create directory to hold the cert files + file: + path: "{{ registry_dir_cert }}" + owner: "{{ file_owner }}" + group: "{{ file_group }}" + mode: 0770 + state: directory + recurse: yes + become: true + +- name: Generate an OpenSSL private key + openssl_privatekey: + path: "{{ registry_dir_cert }}/domain.key" + +- name: Generate an OpenSSL CSR + openssl_csr: + path: "{{ registry_dir_cert }}/domain.csr" + privatekey_path: "{{ registry_dir_cert }}/domain.key" + common_name: "{{ cert_common_name }}" + country_name: "{{ cert_country }}" + state_or_province_name: "{{ cert_state }}" + locality_name: "{{ cert_locality }}" + organization_name: "{{ cert_organization }}" + organizational_unit_name: "{{ cert_organizational_unit }}" + basic_constraints_critical: yes + create_subject_key_identifier: yes + basic_constraints: ["CA:TRUE"] + +- name: Generate a selfsigned OpenSSL CA Certificate + openssl_certificate: + path: "{{ registry_dir_cert }}/domainCA.crt" + privatekey_path: "{{ registry_dir_cert }}/domain.key" + csr_path: "{{ registry_dir_cert }}/domain.csr" + provider: selfsigned + +- name: Generate an ownca OpenSSL Certificate + openssl_certificate: + path: "{{ registry_dir_cert }}/domain.crt" + ownca_privatekey_path: "{{ registry_dir_cert }}/domain.key" + csr_path: "{{ registry_dir_cert }}/domain.csr" + ownca_path: "{{ registry_dir_cert }}/domainCA.crt" + ownca_create_authority_key_identifier: yes + provider: ownca + +- name: Set cert in CA trust + block: + - name: Copy cert to pki directory + copy: + src: "{{ registry_dir_cert }}/domain.crt" + dest: /etc/pki/ca-trust/source/anchors/domain.crt + remote_src: yes + owner: "{{ file_owner }}" + group: "{{ file_group }}" + mode: 0660 + force: yes + backup: yes + + - name: Update the CA trust files + command: update-ca-trust extract + become: true + +- name: Fetch the domain cert from the registry host + fetch: + src: "{{ registry_dir_cert }}/domain.crt" + dest: "{{ fetched_dest }}/domain.crt" + flat: yes + tags: + - copy_config + +- name: Get cert contents + set_fact: + mirror_certificate: "{{ lookup('file', fetched_dest + '/domain.crt') }}" + +- name: Populate mirror_certificate in bastion + set_fact: + mirror_certificate: "{{ mirror_certificate }}" + delegate_to: bastion + delegate_facts: true diff --git a/roles/setup_sushy_tools/defaults/main.yml b/roles/setup_sushy_tools/defaults/main.yml new file mode 100644 index 00000000..bcb45508 --- /dev/null +++ b/roles/setup_sushy_tools/defaults/main.yml @@ -0,0 +1 @@ +sushy_tools_port: 8082 diff --git a/roles/setup_sushy_tools/tasks/main.yml b/roles/setup_sushy_tools/tasks/main.yml new file mode 100644 index 00000000..89f6c26a --- /dev/null +++ b/roles/setup_sushy_tools/tasks/main.yml @@ -0,0 +1,35 @@ +### SUSHY-TOOLS +- name: Install sushy-tools + become: true + block: + - name: Install sushy-tools via pip3 + pip: + name: "sushy-tools" + + - name: Create sushy-tools conf directory + file: + path: /opt/sushy-tools + state: directory + mode: 0755 + + - name: Create sushy-tools conf + template: + src: sushy-emulator.conf.j2 + dest: /opt/sushy-tools/sushy-emulator.conf + mode: 0664 + + - name: Create sushy-tools service + template: + src: sushy-tools.service.j2 + dest: /etc/systemd/system/sushy-tools.service + mode: 0664 + + - name: Reload systemd service + systemd: + daemon_reexec: yes + + - name: Start sushy-tools service + service: + name: sushy-tools + state: restarted + enabled: yes diff --git a/roles/setup_sushy_tools/templates/sushy-emulator.conf.j2 b/roles/setup_sushy_tools/templates/sushy-emulator.conf.j2 new file mode 100644 index 00000000..cb669583 --- /dev/null +++ b/roles/setup_sushy_tools/templates/sushy-emulator.conf.j2 @@ -0,0 +1,161 @@ +#/etc/sushy-emulator.conf +# sushy emulator configuration file build on top of Flask application +# configuration infrastructure: http://flask.pocoo.org/docs/config/ + +# Listen on all local IP interfaces +SUSHY_EMULATOR_LISTEN_IP = u'0.0.0.0' + +# Bind to TCP port {{ sushy_tools_port | default('8082', true) }} +SUSHY_EMULATOR_LISTEN_PORT = {{ sushy_tools_port | default('8082', true) }} + +# Serve this SSL certificate to the clients +SUSHY_EMULATOR_SSL_CERT = None + +# If SSL certificate is being served, this is its RSA private key +SUSHY_EMULATOR_SSL_KEY = None + +# The OpenStack cloud ID to use. This option enables OpenStack driver. +SUSHY_EMULATOR_OS_CLOUD = None + +# The libvirt URI to use. This option enables libvirt driver. +SUSHY_EMULATOR_LIBVIRT_URI = u'qemu:///system' + +# Ignore boot device, otherwise it will keep booting to the discovery iso +# Instruct the libvirt driver to ignore any instructions to +# set the boot device. Allowing the UEFI firmware to instead +# rely on the EFI Boot Manager +SUSHY_EMULATOR_IGNORE_BOOT_DEVICE = True + +# The map of firmware loaders dependant on the boot mode and +# system architecture +SUSHY_EMULATOR_BOOT_LOADER_MAP = { + u'UEFI': { +{% if ansible_distribution_version|float >= 8.3 %} + u'x86_64': u'/usr/share/edk2/ovmf/OVMF_CODE.secboot.fd', +{% else %} + u'x86_64': u'/usr/share/OVMF/OVMF_CODE.secboot.fd', +{% endif %} + u'aarch64': u'/usr/share/AAVMF/AAVMF_CODE.fd' + }, + u'Legacy': { + u'x86_64': None, + u'aarch64': None + } +} + +# This map contains statically configured Redfish Chassis linked +# up with the Systems and Managers enclosed into this Chassis. +# +# The first chassis in the list will contain all other resources. +# +# If this map is not present in the configuration, a single default +# Chassis is configured automatically to enclose all available Systems +# and Managers. +SUSHY_EMULATOR_CHASSIS = [ + { + u'Id': u'Chassis', + u'Name': u'Chassis', + u'UUID': u'48295861-2522-3561-6729-621118518810' + } +] + +# This map contains statically configured Redfish IndicatorLED +# resource state ('Lit', 'Off', 'Blinking') keyed by UUIDs of +# System and Chassis resources. +# +# If this map is not present in the configuration, each +# System and Chassis will have their IndicatorLED `Lit` by default. +# +# Redfish client can change IndicatorLED state. The new state +# is volatile, i.e. it's maintained in process memory. +SUSHY_EMULATOR_INDICATOR_LEDS = { +# u'48295861-2522-3561-6729-621118518810': u'Blinking' +} + +# This map contains statically configured virtual media resources. +# These devices ('Cd', 'Floppy', 'USBStick') will be exposed by the +# Manager(s) and possibly used by the System(s) if system emulation +# backend supports boot image configuration. +# +# If this map is not present in the configuration, the following configuration +# is used: +SUSHY_EMULATOR_VMEDIA_DEVICES = { + u'Cd': { + u'Name': 'Virtual CD', + u'MediaTypes': [ + u'CD', + u'DVD' + ] + } +} + +# This map contains statically configured Redfish Storage resource linked +# up with the Systems resource, keyed by the UUIDs of the Systems. +SUSHY_EMULATOR_STORAGE = { + "da69abcc-dae0-4913-9a7b-d344043097c0": [ + { + "Id": "1", + "Name": "Local Storage Controller", + "StorageControllers": [ + { + "MemberId": "0", + "Name": "Contoso Integrated RAID", + "SpeedGbps": 12 + } + ], + "Drives": [ + "32ADF365C6C1B7BD" + ] + } + ] +} + +# This map contains statically configured Redfish Drives resource. The Drive +# objects are keyed in a composite fashion using a tuple of the form +# (System_UUID, Storage_ID) referring to the UUID of the System and Id of the +# Storage resource, respectively, to which the drive belongs. +SUSHY_EMULATOR_DRIVES = { + ("da69abcc-dae0-4913-9a7b-d344043097c0", "1"): [ + { + "Id": "32ADF365C6C1B7BD", + "Name": "Drive Sample", + "CapacityBytes": 899527000000, + "Protocol": "SAS" + } + ] +} + +# This map contains dynamically configured Redfish Volume resource backed +# by the libvirt virtualization backend of the dynamic Redfish emulator. +# The Volume objects are keyed in a composite fashion using a tuple of the +# form (System_UUID, Storage_ID) referring to the UUID of the System and ID +# of the Storage resource, respectively, to which the Volume belongs. +# +# Only the volumes specified in the map or created via a POST request are +# allowed to be emulated upon by the emulator. Volumes other than these can +# neither be listed nor deleted. +# +# The Volumes from map missing in the libvirt backend will be created +# dynamically in the pool name specified (provided the pool exists in the +# backend). If the pool name is not specified, the volume will be created +# automatically in pool named 'default'. +SUSHY_EMULATOR_VOLUMES = { + ('da69abcc-dae0-4913-9a7b-d344043097c0', '1'): [ + { + "libvirtPoolName": "sushyPool", + "libvirtVolName": "testVol", + "Id": "1", + "Name": "Sample Volume 1", + "VolumeType": "Mirrored", + "CapacityBytes": 23748 + }, + { + "libvirtPoolName": "sushyPool", + "libvirtVolName": "testVol1", + "Id": "2", + "Name": "Sample Volume 2", + "VolumeType": "StripedWithParity", + "CapacityBytes": 48395 + } + ] +} diff --git a/roles/setup_sushy_tools/templates/sushy-tools.service.j2 b/roles/setup_sushy_tools/templates/sushy-tools.service.j2 new file mode 100644 index 00000000..a3237349 --- /dev/null +++ b/roles/setup_sushy_tools/templates/sushy-tools.service.j2 @@ -0,0 +1,13 @@ +[Unit] +Description=Sushy Tools (Redfish Emulator for Libvirt) +After=network.target syslog.target + +[Service] +Type=simple +TimeoutStartSec=5m +WorkingDirectory=/opt/sushy-tools +ExecStart=/usr/local/bin/sushy-emulator --config /opt/sushy-tools/sushy-emulator.conf +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/roles/validate_dns_records/defaults/main.yml b/roles/validate_dns_records/defaults/main.yml new file mode 100644 index 00000000..a8a3cf30 --- /dev/null +++ b/roles/validate_dns_records/defaults/main.yml @@ -0,0 +1,6 @@ +required_domains: + - "{{ 'api.' + domain }}" + - "{{ 'api-int.' + domain }}" + - "{{ '*.apps.' + domain }}" +required_binary: dig +required_binary_provided_in_package: bind-utils diff --git a/roles/validate_dns_records/meta/main.yml b/roles/validate_dns_records/meta/main.yml new file mode 100644 index 00000000..085384d7 --- /dev/null +++ b/roles/validate_dns_records/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - role: validate_inventory diff --git a/roles/validate_dns_records/tasks/main.yml b/roles/validate_dns_records/tasks/main.yml new file mode 100644 index 00000000..8fb93c91 --- /dev/null +++ b/roles/validate_dns_records/tasks/main.yml @@ -0,0 +1,19 @@ +- name: Check if the required binary for testing exists + ansible.builtin.shell: + cmd: "which {{ required_binary }}" + register: required_binary_check + ignore_errors: True + +- name: (if binary is missing) Install the package providing the required binary + ansible.builtin.package: + name: "{{ required_binary_provided_in_package }}" + state: present + become: True + when: required_binary_check.rc != 0 + +- name: Check required domain {item} exists + ansible.builtin.shell: + cmd: "{{ required_binary }} {{ item }} +short" + register: res + failed_when: res.stdout | ipaddr == false + loop: "{{ required_domains }}" diff --git a/roles/validate_http_store/defaults/main.yml b/roles/validate_http_store/defaults/main.yml new file mode 100644 index 00000000..4da90815 --- /dev/null +++ b/roles/validate_http_store/defaults/main.yml @@ -0,0 +1,3 @@ +http_store_dir : "{{ iso_download_dest_path | default('/opt/http_store/data') }}" +http_host: "{{ discovery_iso_server | default('http://' + hostvars['http_store']['ansible_host']) }}" +test_file_name: http_test diff --git a/roles/validate_http_store/meta/main.yml b/roles/validate_http_store/meta/main.yml new file mode 100644 index 00000000..085384d7 --- /dev/null +++ b/roles/validate_http_store/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - role: validate_inventory diff --git a/roles/validate_http_store/tasks/main.yml b/roles/validate_http_store/tasks/main.yml new file mode 100644 index 00000000..223ddaba --- /dev/null +++ b/roles/validate_http_store/tasks/main.yml @@ -0,0 +1,30 @@ +- name: Set test file contents + set_fact: + contents: "{{ lookup('template', 'test_file.j2') }}" + +- name: Copy file to http_store + copy: + content: "{{ contents }}" + dest: "{{ http_store_dir }}/{{ test_file_name }}" + become: true + delegate_to: http_store + +- name: Retrieve file from http_store + uri: + url: "{{ http_host }}/{{ test_file_name }}" + return_content: true + register: response + delegate_to: bastion + +- name: Check content matches + assert: + that: + response.content == contents + quiet: true + +- name: Remove file on http_store + file: + path: "{{ http_store_dir }}/{{ test_file_name }}" + state: absent + become: true + delegate_to: http_store \ No newline at end of file diff --git a/roles/validate_http_store/templates/test_file.j2 b/roles/validate_http_store/templates/test_file.j2 new file mode 100644 index 00000000..68e95a88 --- /dev/null +++ b/roles/validate_http_store/templates/test_file.j2 @@ -0,0 +1 @@ +{{ 99999999 | random | to_uuid }} diff --git a/roles/validate_inventory/defaults/main.yml b/roles/validate_inventory/defaults/main.yml new file mode 100644 index 00000000..4a5ffb4e --- /dev/null +++ b/roles/validate_inventory/defaults/main.yml @@ -0,0 +1,20 @@ +node_required_vars: + - bmc_address + - bmc_password + - bmc_user + - vendor + - role + - mac + +supported_role_values: + - worker + - master + +supported_vendor_values: + - Dell + - Lenovo + - KVM + - SuperMicro + +ai_version: "{{ hostvars.assisted_installer.ai_version | default('v1.0.24.2') }}" +ai_version_number: "{{ ai_version | regex_replace('v(\\d+\\.\\d+.\\d+\\.\\d+)', '\\1') }}" diff --git a/roles/validate_inventory/tasks/ai.yml b/roles/validate_inventory/tasks/ai.yml new file mode 100644 index 00000000..29306749 --- /dev/null +++ b/roles/validate_inventory/tasks/ai.yml @@ -0,0 +1,30 @@ +--- +- name: Assert ai_version is valid + assert: + that: + - ai_version_number is version('1.0.18.0', '>=') + fail_msg: "ai_version must be >= v1.0.18.1 and must be of the form 'v\\d+.\\d+.\\d+.\\d+'" + +- name: Assert that Openshift version is supported + assert: + that: + - openshift_full_version is version('4.6', '>=') + fail_msg: "openshift_full_version must be >= 4.6." + +- name: Assert VIPs are within the machine network + assert: + that: + - hostvars['assisted_installer'][item] | ipaddr(hostvars['assisted_installer']['machine_network_cidr']) | ipaddr('bool') + fail_msg: "{{ item }} is not within the machine network!" + when: vip_dhcp_allocation == false + loop: + - api_vip + - ingress_vip + +- name: Assert nodes are within the machine network + assert: + that: + - hostvars[item]['ansible_host'] | ipaddr(hostvars['assisted_installer']['machine_network_cidr']) | ipaddr('bool') + fail_msg: "{{ item }} is not within the machine network!" + when: vip_dhcp_allocation == false + loop: "{{ groups['nodes'] }}" diff --git a/roles/validate_inventory/tasks/cluster.yml b/roles/validate_inventory/tasks/cluster.yml new file mode 100644 index 00000000..caebc8f7 --- /dev/null +++ b/roles/validate_inventory/tasks/cluster.yml @@ -0,0 +1,65 @@ +--- +- name: Assert valid master configuration + assert: + that: + - groups['masters'] | length >= 3 + fail_msg: "There must be at least three masters defined." + +- name: Assert valid worker configuration + assert: + that: + - (groups['workers'] | length == 0) or (groups['workers'] | length >= 2) + fail_msg: "There must be either zero, or more than one, workers defined." + when: groups['workers'] is defined + +- name: Assert all nodes have all required vars + assert: + that: + - hostvars[item.0][item.1] is defined + - hostvars[item.0][item.1] | trim != '' + quiet: true + fail_msg: "Node {{ item.0 }} is missing required var {{ item.1 }}" + loop: "{{ groups['nodes'] | product(node_required_vars) | list }}" + +- name: Assert required vars are correctly typed + assert: + that: + - hostvars[item]['bmc_address'] is string + - (hostvars[item]['mac'] | hwaddr('bool') ) == true + - hostvars[item]['bmc_password'] is string + - hostvars[item]['bmc_user'] is string + - hostvars[item]['vendor'] is string + quiet: true + fail_msg: "Node {{ item }} has an incorrectly formatted var" + loop: "{{ groups['nodes'] }}" + +- name: Assert that all values of 'vendor' are supported + assert: + that: + - hostvars[item]['vendor'] is in supported_vendor_values + quiet: true + fail_msg: "Node {{ item }} does not have a supported value for 'vendor'" + loop: "{{ groups['nodes'] }}" + +- name: Assert that all values of 'role' are supported + assert: + that: + - hostvars[item]['role'] is in supported_role_values + quiet: true + fail_msg: "Node {{ item }} does not have a supported value for 'role'" + loop: "{{ groups['nodes'] }}" + +- name: Get all KVM Nodes + vars: + kvm_node_names: [] + set_fact: + kvm_node_names: "{{ kvm_node_names }} + {{ [item] }}" + when: hostvars[item]['vendor'] == 'KVM' + loop: "{{ groups['nodes'] }}" + +- name: Assert that a 'vm_host' is defined if needed + assert: + that: + - hostvars['vm_host'] is defined + quiet: true + when: (kvm_node_names is defined) and (kvm_node_names | length > 0) diff --git a/roles/validate_inventory/tasks/day2.yml b/roles/validate_inventory/tasks/day2.yml new file mode 100644 index 00000000..db9960c6 --- /dev/null +++ b/roles/validate_inventory/tasks/day2.yml @@ -0,0 +1,5 @@ +- name: Check for day2_discovery_iso_name if required + assert: + that: + - day2_discovery_iso_name is defined + when: (groups['day2_workers'] | default([])) | length > 0 diff --git a/roles/validate_inventory/tasks/dns.yml b/roles/validate_inventory/tasks/dns.yml new file mode 100644 index 00000000..8d0f9cbe --- /dev/null +++ b/roles/validate_inventory/tasks/dns.yml @@ -0,0 +1,21 @@ +--- +- name: Assert 'dhcp_range_first' and 'dhcp_range_last' are defined if needed + assert: + that: + - hostvars['dns_host'][item] is defined + - hostvars['dns_host'][item] | ipaddr('bool') == True + quiet: true + when: hostvars['dns_host']['use_dhcp'] | default(false) + loop: + - dhcp_range_first + - dhcp_range_last + +- name: if DNS DHCP setup is enabled, ntp_server MUST be an IP for DNS config + assert: + that: + - hostvars['dns_host']['ntp_server'] is defined + - hostvars['dns_host']['ntp_server'] | ipaddr('bool') == True + when: hostvars['dns_host']['setup_dns_service'] | default(false) and hostvars['dns_host']['use_dhcp'] | default(false) + +# All other DNS config is excluded for brevity at this time. +# It is taken from the cluster and/or AI configuration and is not DNS-specific so much is checked elsewhere. diff --git a/roles/validate_inventory/tasks/main.yml b/roles/validate_inventory/tasks/main.yml new file mode 100644 index 00000000..fe0ababd --- /dev/null +++ b/roles/validate_inventory/tasks/main.yml @@ -0,0 +1,41 @@ +--- +- name: Validate Inventory + block: + - include_tasks: + file: cluster.yml + apply: + tags: validate_cluster + tags: validate_cluster + + - include_tasks: + file: secrets.yml + apply: + tags: validate_secrets + tags: validate_secrets + + - include_tasks: + file: prereqs.yml + apply: + tags: validate_prereqs + tags: validate_prereqs + + - include_tasks: + file: network.yml + apply: + tags: validate_network + tags: validate_network + + - include_tasks: + file: day2.yml + apply: + tags: validate_day2 + tags: validate_day2 + + when: not (inventory_validated | default(False) | bool) + +- name: Record successful validation on all hosts + set_fact: + inventory_validated: True + delegate_to: "{{ item }}" + delegate_facts: True + loop: "{{ groups['all'] + ['localhost'] }}" diff --git a/roles/validate_inventory/tasks/network.yml b/roles/validate_inventory/tasks/network.yml new file mode 100644 index 00000000..f7703669 --- /dev/null +++ b/roles/validate_inventory/tasks/network.yml @@ -0,0 +1,21 @@ +--- +# Node `ansible_host`s are not pinged. They are not required to be running at this stage. +# KVM node BMCs are not checked, the vm_host will be pinged later. +- name: Ensure baremetal node BMCs are reachable + shell: # noqa 305 + cmd: "ping -c 1 -W 2 {{ hostvars[item]['bmc_address'] }}" + changed_when: False + when: hostvars[item]['vendor'] != 'KVM' + loop: "{{ groups['nodes'] }}" + +- name: Ensure service hosts are reachable + shell: # noqa 305 + cmd: "ping -c 1 -W 2 {{ hostvars[item]['ansible_host'] }}" + changed_when: False + loop: "{{ groups['services'] + groups['bastions'] }}" + +- name: Ensure NTP server is available if not being set up + shell: # noqa 305 + cmd: "ping -c 1 -W 2 {{ ntp_server }}" + changed_when: False + when: (setup_ntp_service | default(True)) != True diff --git a/roles/validate_inventory/tasks/prereqs.yml b/roles/validate_inventory/tasks/prereqs.yml new file mode 100644 index 00000000..5a29bbfa --- /dev/null +++ b/roles/validate_inventory/tasks/prereqs.yml @@ -0,0 +1,6 @@ +--- +- import_tasks: + file: ai.yml + +- import_tasks: + file: dns.yml diff --git a/roles/validate_inventory/tasks/secrets.yml b/roles/validate_inventory/tasks/secrets.yml new file mode 100644 index 00000000..e00abf74 --- /dev/null +++ b/roles/validate_inventory/tasks/secrets.yml @@ -0,0 +1,47 @@ +--- +- name: Assert that {{ secret_var_name }} is set for the registry host + assert: + that: + - hostvars['registry_host'][secret_var_name] is defined + - hostvars['registry_host'][secret_var_name] is string + - hostvars['registry_host'][secret_var_name] | trim != '' + fail_msg: > + The registry host requires a valid {{ secret_var_name }} variable to be set. + Please ensure a valid secret is set in the inventory vault file. + vars: + secret_var_name: REGISTRY_HTTP_SECRET + +- name: Assert that all credentials for the disconnected registry are set + assert: + that: + - hostvars['registry_host'][secret_var_name] is defined + - hostvars['registry_host'][secret_var_name] is string + - hostvars['registry_host'][secret_var_name] | trim != '' + fail_msg: > + The registry host requires a valid {{ secret_var_name }} variable to be set. + Please ensure a valid secret is set in the inventory vault file. + vars: + secret_vars_to_check: + - disconnected_registry_user + - disconnected_registry_password + loop: "{{ secret_vars_to_check }}" + loop_control: + loop_var: secret_var_name + # only for Restricted Network installations + when: "use_local_mirror_registry | default(setup_registry_service | default(true))" + +- name: Assert that all nodes have BMC credentials set + assert: + that: + - hostvars[item.0][item.1] is defined + - hostvars[item.0][item.1] is string + - hostvars[item.0][item.1] | trim != '' + fail_msg: > + Node {{ item.0 }} requires a valid {{ item.1 }} variable to be set. + Please ensure valid BMC credentials are set in the inventory vault file. + vars: + secret_vars_to_check: + - bmc_user + - bmc_password + nodes_and_required_secret_vars: "{{ groups['nodes'] | product(secret_vars_to_check) | list }}" + loop: "{{ nodes_and_required_secret_vars }}" diff --git a/site.yml b/site.yml new file mode 100644 index 00000000..4cf8a744 --- /dev/null +++ b/site.yml @@ -0,0 +1,8 @@ +--- +- import_playbook: deploy_prerequisites.yml + +- import_playbook: deploy_cluster.yml + +- import_playbook: post_install.yml + +- import_playbook: deploy_day2_workers.yml diff --git a/tests/run_tests.yml b/tests/run_tests.yml new file mode 100644 index 00000000..a013ab16 --- /dev/null +++ b/tests/run_tests.yml @@ -0,0 +1,2 @@ +--- +- import_playbook: validate_inventory/tests.yml diff --git a/tests/validate_inventory/roles/run_suite/tasks/main.yml b/tests/validate_inventory/roles/run_suite/tasks/main.yml new file mode 100644 index 00000000..94bf50c4 --- /dev/null +++ b/tests/validate_inventory/roles/run_suite/tasks/main.yml @@ -0,0 +1,40 @@ +--- +- name: "Load suite {{ test_suite_file }}" + include_vars: + file: "suites/{{ test_suite_file }}" + name: suite_data + +- name: Create temporary directory for rendered templates + tempfile: + state: directory + register: temp_dir + +- name: Render inventories and run tests + block: + + - name: "Render test inventories for {{ test_suite_file }}" + template: + src: "{{ item.from_template | default(suite_data.template_file) }}" + dest: "{{ temp_dir.path }}/{{ item.test_name }}.yml" + trim_blocks: true + lstrip_blocks: true + loop: "{{ suite_data.tests }}" + + - name: "Run Inventory Validation tests from {{ test_suite_file }}" + include_tasks: run_test.yml + loop: "{{ suite_data.tests }}" + + rescue: + - name: Persist the tested inventory files on failure + copy: + src: "{{ temp_dir.path }}" + dest: "{{ playbook_dir }}/failed/" + backup: true + failed_when: true # Keep non-zero exit code + + always: + - name: Remove the temporary directory + file: + path: "{{ temp_dir.path }}" + state: absent + when: temp_dir.path is defined diff --git a/tests/validate_inventory/roles/run_suite/tasks/run_test.yml b/tests/validate_inventory/roles/run_suite/tasks/run_test.yml new file mode 100644 index 00000000..df0af607 --- /dev/null +++ b/tests/validate_inventory/roles/run_suite/tasks/run_test.yml @@ -0,0 +1,13 @@ +--- +- block: + - name: "Run Testcase {{ item.test_name }}" + shell: + chdir: "{{ playbook_dir }}/../.." + cmd: "ansible-playbook playbooks/validate_inventory.yml -i {{ temp_dir.path }}/{{ item.test_name }}.yml -t {{ suite_data.tags }}" + register: res + failed_when: res.rc != item.expected + changed_when: false # Keep simple success/failure state for tests + + rescue: + - set_fact: + test_failures: "{{ test_failures | default([]) + [item.test_name] }}" diff --git a/tests/validate_inventory/suites/cluster.yml b/tests/validate_inventory/suites/cluster.yml new file mode 100644 index 00000000..c4222b03 --- /dev/null +++ b/tests/validate_inventory/suites/cluster.yml @@ -0,0 +1,59 @@ +tags: validate_cluster + +template_file: test_inv.yml.j2 + +tests: + - test_name: valid_no_kvm + expected: 0 + + - test_name: valid_with_kvm + expected: 0 + template: + vendor: KVM + include_vm_host: true + + - test_name: valid_no_workers + expected: 0 + template: + num_workers: 0 + + - test_name: valid_bmc_address_as_domain + expected: 0 + template: + bmc_address: host.name.lab:10 + + - test_name: valid_dhcp_ranges_defined + expected: 0 + template: + use_dhcp: true + dhcp_range_first: "192.168.100.101" + dhcp_range_last: "192.168.100.201" + + - test_name: invalid_missing_vm_host + expected: 2 + template: + vendor: KVM + include_vm_host: false + + - test_name: invalid_single_worker + expected: 2 + template: + num_workers: 1 + + - test_name: invalid_mac_address + expected: 2 + template: + mac: FF:FF:FF:FF:FF:XX + + - test_name: invalid_bmc_user_type + expected: 2 + template: + bmc_user: 88 + + - test_name: invalid_missing_var + from_template: invalid_missing_var.yml + expected: 2 + + - test_name: invalid_empty_var + from_template: invalid_empty_var.yml + expected: 2 diff --git a/tests/validate_inventory/suites/day2.yml b/tests/validate_inventory/suites/day2.yml new file mode 100644 index 00000000..988f96ef --- /dev/null +++ b/tests/validate_inventory/suites/day2.yml @@ -0,0 +1,15 @@ +tags: validate_day2 + +template_file: test_inv.yml.j2 + +tests: + - test_name: valid_day2_nodes + expected: 0 + template: + day2_discovery_iso_name: "day2.iso" + num_day2_workers: 1 + + - test_name: invalid_day2_nodes + expected: 2 + template: + num_day2_workers: 1 diff --git a/tests/validate_inventory/suites/network.yml b/tests/validate_inventory/suites/network.yml new file mode 100644 index 00000000..a997a522 --- /dev/null +++ b/tests/validate_inventory/suites/network.yml @@ -0,0 +1,14 @@ +tags: validate_network + +tests: + - test_name: all_reachable + from_template: all_reachable.yml + expected: 0 + + - test_name: all_unreachable + from_template: all_unreachable.yml + expected: 2 + + - test_name: ntp_unreachable + from_template: ntp_unreachable.yml + expected: 2 diff --git a/tests/validate_inventory/suites/prereqs.yml b/tests/validate_inventory/suites/prereqs.yml new file mode 100644 index 00000000..06fb4b2a --- /dev/null +++ b/tests/validate_inventory/suites/prereqs.yml @@ -0,0 +1,35 @@ +tags: validate_prereqs + +template_file: test_inv.yml.j2 + +tests: + - test_name: invalid_dhcp_first_last_missing + expected: 2 + template: + use_dhcp: true + + - test_name: invalid_dhcp_first_missing + expected: 2 + template: + use_dhcp: true + dhcp_range_last: "192.168.100.201" + + - test_name: invalid_dhcp_last_missing + expected: 2 + template: + use_dhcp: true + dhcp_range_first: "192.168.100.101" + + - test_name: invalid_dhcp_first_invalid + expected: 2 + template: + use_dhcp: true + dhcp_range_first: "invalid" + dhcp_range_last: "192.168.100.201" + + - test_name: invalid_dhcp_last_invalid + expected: 2 + template: + use_dhcp: true + dhcp_range_first: "192.168.100.101" + dhcp_range_last: "invalid" diff --git a/tests/validate_inventory/suites/secrets.yml b/tests/validate_inventory/suites/secrets.yml new file mode 100644 index 00000000..f60f971d --- /dev/null +++ b/tests/validate_inventory/suites/secrets.yml @@ -0,0 +1,60 @@ +tags: validate_secrets + +template_file: test_inv_secrets.yml.j2 + +tests: + - test_name: invalid_all_secrets_missing + expected: 2 + template: + include_var_registry_http_secret: false + include_var_disconnected_registry_user: false + include_var_disconnected_registry_password: false + include_nodes_vars_bmc_credentials: false + + - test_name: invalid_registry_http_secret_missing + expected: 2 + template: + include_var_registry_http_secret: false + + - test_name: invalid_registry_http_secret_empty + expected: 2 + template: + registry_http_secret: "" + + - test_name: invalid_registry_disconnected_registry_user_missing + expected: 2 + template: + include_var_disconnected_registry_user: false + + - test_name: invalid_registry_disconnected_registry_user_empty + expected: 2 + template: + disconnected_registry_user: "" + + - test_name: invalid_registry_disconnected_registry_password_missing + expected: 2 + template: + include_var_disconnected_registry_password: false + + - test_name: invalid_registry_disconnected_registry_password_empty + expected: 2 + template: + disconnected_registry_password: "" + + - test_name: invalid_registry_nodes_vars_bmc_credentials + expected: 2 + template: + include_nodes_vars_bmc_credentials: false + + - test_name: valid_super1_inherited_bmc_credentials + expected: 0 + template: + include_super1_custom_bmc_credentials: false + + - test_name: valid_worker1_inherited_bmc_credentials + expected: 0 + template: + include_worker1_custom_bmc_credentials: false + + - test_name: valid_all_secrets_present + expected: 0 diff --git a/tests/validate_inventory/templates/all_reachable.yml b/tests/validate_inventory/templates/all_reachable.yml new file mode 100644 index 00000000..bc9e9e8c --- /dev/null +++ b/tests/validate_inventory/templates/all_reachable.yml @@ -0,0 +1,41 @@ +all: + vars: + setup_ntp_service: false + ntp_server: localhost + + children: + bastions: + hosts: + bastion: + ansible_host: localhost + + services: + vars: + ansible_host: localhost + hosts: + assisted_installer: + registry_host: + dns_host: + http_store: + ntp_host: + vm_host: + nodes: + vars: + ansible_host: localhost + bmc_address: localhost + vendor: Dell + children: + masters: + vars: + role: master + hosts: + super1: + super2: + super3: + workers: + vars: + role: worker + vendor: KVM + hosts: + worker1: + worker2: diff --git a/tests/validate_inventory/templates/all_unreachable.yml b/tests/validate_inventory/templates/all_unreachable.yml new file mode 100644 index 00000000..c4ea782f --- /dev/null +++ b/tests/validate_inventory/templates/all_unreachable.yml @@ -0,0 +1,40 @@ +all: + vars: + setup_ntp_service: false + ntp_server: 240.0.0.0 + + children: + bastions: + hosts: + bastion: + ansible_host: 240.0.0.0 + + services: + vars: + ansible_host: 240.0.0.0 + hosts: + assisted_installer: + registry_host: + dns_host: + http_store: + ntp_host: + vm_host: + nodes: + vars: + ansible_host: 240.0.0.0 + bmc_address: 240.0.0.0 + vendor: KVM + children: + masters: + vars: + role: master + hosts: + super1: + super2: + super3: + workers: + vars: + role: worker + hosts: + worker1: + worker2: diff --git a/tests/validate_inventory/templates/invalid_empty_var.yml b/tests/validate_inventory/templates/invalid_empty_var.yml new file mode 100644 index 00000000..f775b9fd --- /dev/null +++ b/tests/validate_inventory/templates/invalid_empty_var.yml @@ -0,0 +1,25 @@ +all: + children: + nodes: + vars: + bmc_address: 172.28.10.20 + bmc_password: password + bmc_user: exists + vendor: Dell + ansible_host: 192.168.1.100 + mac: FF:40:40:40:40:AA + children: + masters: + vars: + role: master + hosts: + master1: + master2: + master3: + workers: + vars: + role: worker + hosts: + worker1: + bmc_user: "" + worker2: diff --git a/tests/validate_inventory/templates/invalid_missing_var.yml b/tests/validate_inventory/templates/invalid_missing_var.yml new file mode 100644 index 00000000..82c240b0 --- /dev/null +++ b/tests/validate_inventory/templates/invalid_missing_var.yml @@ -0,0 +1,24 @@ +all: + children: + nodes: + vars: + bmc_address: 172.28.10.20 + bmc_password: password + bmc_user: exists + # Missing vendor: + ansible_host: 192.168.1.100 + mac: FF:40:40:40:40:AA + children: + masters: + vars: + role: master + hosts: + master1: + master2: + master3: + workers: + vars: + role: worker + hosts: + worker1: + worker2: diff --git a/tests/validate_inventory/templates/ntp_unreachable.yml b/tests/validate_inventory/templates/ntp_unreachable.yml new file mode 100644 index 00000000..b36cbc64 --- /dev/null +++ b/tests/validate_inventory/templates/ntp_unreachable.yml @@ -0,0 +1,40 @@ +all: + vars: + setup_ntp_service: false + ntp_server: 240.0.0.0 + + children: + bastions: + hosts: + bastion: + ansible_host: localhost + + services: + vars: + ansible_host: localhost + hosts: + assisted_installer: + registry_host: + dns_host: + http_store: + ntp_host: + vm_host: + nodes: + vars: + ansible_host: localhost + bmc_address: localhost + vendor: KVM + children: + masters: + vars: + role: master + hosts: + super1: + super2: + super3: + workers: + vars: + role: worker + hosts: + worker1: + worker2: diff --git a/tests/validate_inventory/templates/test_inv.yml.j2 b/tests/validate_inventory/templates/test_inv.yml.j2 new file mode 100644 index 00000000..2fa740af --- /dev/null +++ b/tests/validate_inventory/templates/test_inv.yml.j2 @@ -0,0 +1,58 @@ +all: + vars: + {% if item.template.day2_discovery_iso_name is defined %} + day2_discovery_iso_name: {{ item.template.day2_discovery_iso_name }} + {% endif %} + children: + bastions: + hosts: + bastion: + ansible_host: localhost + services: + vars: + ansible_host: localhost + hosts: + assisted_installer: + dns_host: + use_dhcp: {{ item.template.use_dhcp | default(false) }} + {% if item.template.dhcp_range_first is defined %} + dhcp_range_first: {{ item.template.dhcp_range_first }} + {% endif %} + {% if item.template.dhcp_range_last is defined %} + dhcp_range_last: {{ item.template.dhcp_range_last }} + {% endif %} + {% if item.template.include_vm_host | default(false) %} + vm_host: + {% endif %} + nodes: + vars: + bmc_address: {{ item.template.bmc_address | default("localhost") }} + bmc_password: {{ item.template.bmc_password | default("password") }} + bmc_user: {{ item.template.bmc_user | default("exists") }} + vendor: {{ item.template.vendor | default("Dell") }} + ansible_host: {{ item.template.ansible_host | default("localhost") }} + mac: {{ item.template.mac | default("FF:FF:FF:FF:FF:FF") }} + children: + masters: + vars: + role: master + hosts: + {% for n in range(item.template.num_masters | default(3)) %} + master{{ n }}: + {% endfor %} + workers: + vars: + role: worker + hosts: + {% for n in range(item.template.num_workers | default(2)) %} + worker{{ n }}: + {% endfor %} + {% if item.template.num_day2_workers is defined %} + day2_workers: + vars: + role: worker + hosts: + {% for n in range(item.template.num_day2_workers) %} + day2_worker{{ n }}: + {% endfor %} + {% endif %} diff --git a/tests/validate_inventory/templates/test_inv_secrets.yml.j2 b/tests/validate_inventory/templates/test_inv_secrets.yml.j2 new file mode 100644 index 00000000..44709f88 --- /dev/null +++ b/tests/validate_inventory/templates/test_inv_secrets.yml.j2 @@ -0,0 +1,42 @@ +all: + children: + services: + hosts: + registry_host: + {% if item.template.include_var_registry_http_secret | default(true) %} + REGISTRY_HTTP_SECRET: {{ item.template.registry_http_secret | default("http_secret") }} + {% endif %} + {% if item.template.include_var_disconnected_registry_user | default(true) %} + disconnected_registry_user: {{ item.template.disconnected_registry_user | default("registry_user_secret") }} + {% endif %} + {% if item.template.include_var_disconnected_registry_password | default(true) %} + disconnected_registry_password: {{ item.template.disconnected_registry_password | default("registry_user_password") }} + {% endif %} + nodes: + vars: + {% if item.template.include_nodes_vars_bmc_credentials | default(true) %} + bmc_user: "nodes_vars_bmc_user" + bmc_password: "nodes_vars_bmc_password" + {% endif %} + children: + masters: + vars: + role: master + hosts: + super1: + {% if item.template.include_super1_custom_bmc_credentials | default(true) %} + bmc_user: "super1_bmc_user" + bmc_password: "super1_bmc_password" + {% endif %} + super2: + super3: + workers: + vars: + role: worker + hosts: + worker1: + {% if item.template.include_worker1_custom_bmc_credentials | default(true) %} + bmc_user: "worker1_bmc_user" + bmc_password: "worker1_bmc_password" + {% endif %} + worker2: diff --git a/tests/validate_inventory/tests.yml b/tests/validate_inventory/tests.yml new file mode 100644 index 00000000..98841354 --- /dev/null +++ b/tests/validate_inventory/tests.yml @@ -0,0 +1,25 @@ +--- +- name: Test Inventory Validation + hosts: localhost + gather_facts: false + vars: + test_suites: + - cluster.yml + - secrets.yml + - prereqs.yml + - network.yml + - day2.yml + tasks: + - name: Run Inventory Validation test suites + include_role: + name: run_suite + ignore_errors: true + loop: "{{ test_suites | list }}" + loop_control: + loop_var: test_suite_file + + - name: Record failures + fail: + msg: "Test failed!: {{ item }}" + when: (test_failures | default([])) | length > 0 + loop: "{{ test_failures }}"