Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,6 @@ docs/workflows/

# mkdocs site output
/site/

# miscellaneous
.local/
162 changes: 162 additions & 0 deletions ansible/README.md
Original file line number Diff line number Diff line change
@@ -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
```
25 changes: 25 additions & 0 deletions ansible/image-upload.yaml
Original file line number Diff line number Diff line change
@@ -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
54 changes: 54 additions & 0 deletions ansible/roles/openstack_glance_image_upload/defaults/main.yml
Original file line number Diff line number Diff line change
@@ -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
135 changes: 135 additions & 0 deletions ansible/roles/openstack_glance_image_upload/tasks/main.yml
Original file line number Diff line number Diff line change
@@ -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
Loading