From 5c9ae2df32c2033ba6f317d255b84dfb1a8259d7 Mon Sep 17 00:00:00 2001 From: Nicholas Kuechler Date: Wed, 11 Mar 2026 11:52:47 -0500 Subject: [PATCH] feat(images): OpenStack image upload playbook --- .gitignore | 3 + ansible/README.md | 162 ++++++++++++++++++ ansible/image-upload.yaml | 25 +++ .../defaults/main.yml | 54 ++++++ .../tasks/main.yml | 135 +++++++++++++++ 5 files changed, 379 insertions(+) create mode 100644 ansible/README.md create mode 100644 ansible/image-upload.yaml create mode 100644 ansible/roles/openstack_glance_image_upload/defaults/main.yml create mode 100644 ansible/roles/openstack_glance_image_upload/tasks/main.yml diff --git a/.gitignore b/.gitignore index 4c2710644..36f381d98 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,6 @@ docs/workflows/ # mkdocs site output /site/ + +# miscellaneous +.local/ diff --git a/ansible/README.md b/ansible/README.md new file mode 100644 index 000000000..16117d46a --- /dev/null +++ b/ansible/README.md @@ -0,0 +1,162 @@ +# Running Ansible locally + +The Ansible container is built by [`.github/workflows/containers.yaml`](../.github/workflows/containers.yaml) from [`containers/ansible/Dockerfile`](../containers/ansible/Dockerfile). That image copies this directory into `/runner/project` and the Argo workflow in [`workflows/argo-events/workflowtemplates/ansible-run.yaml`](../workflows/argo-events/workflowtemplates/ansible-run.yaml) expects inventory at `/runner/inventory/hosts.yaml`. + +## Build the container + +From the repository root: + +```bash +docker build -f containers/ansible/Dockerfile -t understack-ansible . +``` + +## Run locally with a Python virtualenv + +This uses the same Python packages and Ansible collections installed by the container build. + +From the repository root: + +```bash +python3 -m venv .venv +source .venv/bin/activate +python -m pip install --upgrade pip +pip install -r ansible/requirements.txt +ansible-galaxy collection install -r ansible/requirements.yml +``` + +Prepare inventory: + +```bash +mkdir -p .local/ansible/inventory +cp /path/to/your/inventory.yaml .local/ansible/inventory/hosts.yaml +``` + +If the playbook talks to OpenStack, put `clouds.yaml` in a standard OpenStack location: + +```bash +mkdir -p ~/.config/openstack +cp /path/to/clouds.yaml ~/.config/openstack/clouds.yaml +``` + +Run playbooks from the `ansible/` directory so the local roles resolve from `./roles`: + +```bash +cd ansible +ansible-playbook -i ../.local/ansible/inventory/hosts.yaml debug.yaml -vvv +``` + +Examples: + +```bash +cd ansible +ansible-playbook -i ../.local/ansible/inventory/hosts.yaml image-upload.yaml -vvv +ansible-playbook -i ../.local/ansible/inventory/hosts.yaml keystone-post-deploy.yaml -vvv +``` + +Notes: + +- activate the virtualenv with `source .venv/bin/activate` before running playbooks in a new shell +- `image-upload.yaml` needs a `glance` group in inventory and valid OpenStack credentials +- `nova-post-deploy.yaml` also needs flavor and device-type data; override the role paths or create local symlinks to match the defaults under `/runner/data` +- Nautobot playbooks may also need `NAUTOBOT_TOKEN` exported in your shell + +## Prepare local input files + +Create a local inventory file at the same path the workflow uses in-cluster: + +```bash +mkdir -p .local/ansible/inventory +cp /path/to/your/inventory.yaml .local/ansible/inventory/hosts.yaml +``` + +If the playbook talks to OpenStack, mount a `clouds.yaml` file into a standard OpenStack location inside the container: + +```bash +mkdir -p .local/openstack +cp ~/.config/openstack/clouds.yaml .local/openstack/clouds.yaml +``` + +If the playbook needs extra mounted data, keep the same paths it expects in-cluster: + +- `nova-post-deploy.yaml`: mount flavors at `/runner/data/flavors/` and device types at `/runner/data/device-types/` +- playbooks using Nautobot: set `NAUTOBOT_TOKEN` +- commands matching the Argo workflow: set `UNDERSTACK_ENV` + +## Run a playbook with ansible-runner + +This matches the Argo workflow shape closely. The repo copy is mounted over `/runner/project` so local edits are used without rebuilding the image. + +```bash +docker run --rm -it \ + -v "$PWD/ansible:/runner/project" \ + -v "$PWD/.local/ansible/inventory:/runner/inventory" \ + -v "$PWD/.local/openstack:/etc/openstack:ro" \ + -e UNDERSTACK_ENV=dev \ + -e NAUTOBOT_TOKEN="$NAUTOBOT_TOKEN" \ + understack-ansible \ + ansible-runner run /tmp/runner \ + --project-dir /runner/project \ + --playbook debug.yaml \ + --cmdline "-i /runner/inventory/hosts.yaml --extra-vars 'env=dev' -vvv" +``` + +Notes: + +- `ansible-runner` must be passed explicitly because the image entrypoint is `dumb-init` +- replace `debug.yaml` with the playbook you want to run +- if a playbook does not use Nautobot, omit `NAUTOBOT_TOKEN` +- if a playbook does not use OpenStack, omit the `/etc/openstack` mount + +## Example: run the image upload playbook + +[`image-upload.yaml`](./image-upload.yaml) targets the `glance` group and uses OpenStack auth, so the inventory needs a `glance` host or group and the container needs `clouds.yaml`: + +```bash +docker run --rm -it \ + -v "$PWD/ansible:/runner/project" \ + -v "$PWD/.local/ansible/inventory:/runner/inventory" \ + -v "$PWD/.local/openstack:/etc/openstack:ro" \ + understack-ansible \ + ansible-runner run /tmp/runner \ + --project-dir /runner/project \ + --playbook image-upload.yaml \ + --cmdline "-i /runner/inventory/hosts.yaml -vvv" +``` + +## Example: run the Nova flavor playbook + +[`nova-post-deploy.yaml`](./nova-post-deploy.yaml) also expects ConfigMap-style data directories. Mount local directories to the same paths: + +```bash +docker run --rm -it \ + -v "$PWD/ansible:/runner/project" \ + -v "$PWD/.local/ansible/inventory:/runner/inventory" \ + -v "$PWD/.local/openstack:/etc/openstack:ro" \ + -v "$PWD/hardware/flavors:/runner/data/flavors:ro" \ + -v "$PWD/hardware/device-types:/runner/data/device-types:ro" \ + understack-ansible \ + ansible-runner run /tmp/runner \ + --project-dir /runner/project \ + --playbook nova-post-deploy.yaml \ + --cmdline "-i /runner/inventory/hosts.yaml -vvv" +``` + +## Run with ansible-playbook instead + +If you want a simpler interactive container shell: + +```bash +docker run --rm -it \ + -v "$PWD/ansible:/runner/project" \ + -v "$PWD/.local/ansible/inventory:/runner/inventory" \ + -v "$PWD/.local/openstack:/etc/openstack:ro" \ + --workdir /runner/project \ + --entrypoint /bin/bash \ + understack-ansible +``` + +Then run: + +```bash +ansible-playbook -i /runner/inventory/hosts.yaml debug.yaml -vvv +``` diff --git a/ansible/image-upload.yaml b/ansible/image-upload.yaml new file mode 100644 index 000000000..dab1d5421 --- /dev/null +++ b/ansible/image-upload.yaml @@ -0,0 +1,25 @@ +--- +# Copyright (c) 2025 Rackspace Technology, Inc. +# +# 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. + +- name: Understack Image Uploader + hosts: glance + connection: local + + pre_tasks: + - name: Check OpenStack connectivity + ansible.builtin.import_tasks: tasks/check_openstack_auth.yml + + roles: + - role: openstack_glance_image_upload diff --git a/ansible/roles/openstack_glance_image_upload/defaults/main.yml b/ansible/roles/openstack_glance_image_upload/defaults/main.yml new file mode 100644 index 000000000..1e25e70fd --- /dev/null +++ b/ansible/roles/openstack_glance_image_upload/defaults/main.yml @@ -0,0 +1,54 @@ +--- + +# Temporary directory for downloading images +image_download_dir: "/tmp/glance_images" + +# List of images to upload to Glance +understack_images: + - uuid: 00000000-0000-0000-1111-000000000000 + name: esp.img + description: EFI system partition image from understack-images-20251016184704 + external_url: https://github.com/rackerlabs/understack/releases/download/understack-images-20251016184704/esp.img + is_public: false + is_protected: false + disk_format: raw + container_format: bare + min_disk: 0 + min_ram: 0 + properties: {} + - uuid: 00000000-0000-0000-2222-000000000000 + name: ipa-debian-bookworm.kernel + description: IPA Debian Bookworm kernel image from understack-images-20251016184704 + external_url: https://github.com/rackerlabs/understack/releases/download/understack-images-20251016184704/ipa-debian-bookworm.kernel + is_public: false + is_protected: false + disk_format: raw + container_format: bare + min_disk: 0 + min_ram: 0 + properties: {} + - uuid: 00000000-0000-0000-3333-000000000000 + name: ipa-debian-bookworm.initramfs + description: IPA Debian Bookworm initramfs image from understack-images-20251016184704 + external_url: https://github.com/rackerlabs/understack/releases/download/understack-images-20251016184704/ipa-debian-bookworm.initramfs + is_public: false + is_protected: false + disk_format: raw + container_format: bare + min_disk: 0 + min_ram: 0 + properties: {} + - uuid: 00000000-0000-0000-4444-000000000000 + name: Ubuntu 24.04 + description: Ubuntu Noble qcow2 image from understack-images-20251016184704 + external_url: https://github.com/rackerlabs/understack/releases/download/understack-images-20251016184704/ubuntu-noble.qcow2 + is_public: true + is_protected: false + disk_format: qcow2 + container_format: bare + min_disk: 0 + min_ram: 0 + properties: + os_distro: ubuntu + os_version: "24.04" + img_config_drive: mandatory diff --git a/ansible/roles/openstack_glance_image_upload/tasks/main.yml b/ansible/roles/openstack_glance_image_upload/tasks/main.yml new file mode 100644 index 000000000..6dbbb391f --- /dev/null +++ b/ansible/roles/openstack_glance_image_upload/tasks/main.yml @@ -0,0 +1,135 @@ +--- + +- name: Ensure download directory exists + ansible.builtin.file: + path: "{{ image_download_dir }}" + state: directory + mode: '0755' + +- name: Look up existing Glance images + openstack.cloud.image_info: + cloud: "{{ openstack_cloud | default(omit) }}" + image: "{{ item.uuid }}" + register: existing_image_info + loop: "{{ understack_images }}" + loop_control: + label: "{{ item.name }}" + when: understack_images | length > 0 + +- name: Reserve missing Glance image IDs before upload + openstack.cloud.image: + cloud: "{{ openstack_cloud | default(omit) }}" + name: "{{ item.item.name }}" + id: "{{ item.item.uuid }}" + disk_format: "{{ item.item.disk_format }}" + container_format: "{{ item.item.container_format | default('bare') }}" + is_public: "{{ item.item.is_public | default(true) }}" + is_protected: "{{ item.item.is_protected | default(false) }}" + min_disk: "{{ item.item.min_disk | default(0) }}" + min_ram: "{{ item.item.min_ram | default(0) }}" + properties: "{{ item.item.properties | default({}) }}" + tags: "{{ item.item.tags | default([]) }}" + state: present + wait: false + loop: "{{ existing_image_info.results }}" + loop_control: + label: "{{ item.item.name }}" + when: + - understack_images | length > 0 + - item.images | length == 0 + +- name: Download images that still need data upload + ansible.builtin.get_url: + url: "{{ item.item.external_url }}" + dest: "{{ image_download_dir }}/{{ item.item.uuid }}.{{ item.item.disk_format }}" + checksum: "{{ item.item.checksum | default(omit) }}" + mode: '0644' + loop: "{{ existing_image_info.results }}" + loop_control: + label: "{{ item.item.name }}" + when: + - understack_images | length > 0 + - item.images | length == 0 or item.images[0].status == 'queued' + +- name: Look up target Glance images after reservation + openstack.cloud.image_info: + cloud: "{{ openstack_cloud | default(omit) }}" + image: "{{ item.uuid }}" + register: target_image_info + loop: "{{ understack_images }}" + loop_control: + label: "{{ item.name }}" + when: understack_images | length > 0 + +- name: Upload image data to queued Glance images + openstack.cloud.image: + cloud: "{{ openstack_cloud | default(omit) }}" + name: "{{ item.images[0].name }}" + id: "{{ item.item.uuid }}" + filename: "{{ image_download_dir }}/{{ item.item.uuid }}.{{ item.item.disk_format }}" + disk_format: "{{ item.images[0].disk_format }}" + container_format: "{{ item.images[0].container_format }}" + state: present + wait: true + loop: "{{ target_image_info.results }}" + loop_control: + label: "{{ item.item.name }}" + when: + - understack_images | length > 0 + - item.images | length > 0 + - item.images[0].status == 'queued' + +- name: Tell Glance to import the image data (using use_import and glance-direct) + openstack.cloud.image: + cloud: "{{ openstack_cloud | default(omit) }}" + id: "{{ item.item.uuid }}" + name: "{{ item.images[0].name }}" + use_import: true + state: present + wait: true + loop: "{{ target_image_info.results }}" + loop_control: + label: "{{ item.item.name }}" + when: + - understack_images | length > 0 + - item.images | length > 0 + - item.images[0].status == 'uploading' + +- name: Wait for Glance images to become active + openstack.cloud.image_info: + cloud: "{{ openstack_cloud | default(omit) }}" + image: "{{ item.uuid }}" + register: final_image_info + loop: "{{ understack_images }}" + loop_control: + label: "{{ item.name }}" + until: + - final_image_info.images | length > 0 + - final_image_info.images[0].status == 'active' + retries: 60 + delay: 10 + when: understack_images | length > 0 + +- name: Verify all Glance images are active + ansible.builtin.assert: + that: + - item.images | length > 0 + - item.images[0].status == 'active' + fail_msg: >- + Glance image {{ item.item.name }} ({{ item.item.uuid }}) did not become active. + Current status: {{ item.images[0].status if (item.images | length > 0) else 'missing' }} + success_msg: >- + Glance image {{ item.item.name }} ({{ item.item.uuid }}) is active. + loop: "{{ final_image_info.results }}" + loop_control: + label: "{{ item.item.name }}" + when: understack_images | length > 0 + +# - name: Clean up downloaded images +# ansible.builtin.file: +# path: "{{ image_download_dir }}/{{ item.uuid }}.{{ item.disk_format }}" +# state: absent +# loop: "{{ understack_images }}" +# loop_control: +# label: "{{ item.name }}" +# when: understack_images | length > 0