diff --git a/.github/workflows/build-container-images.yaml b/.github/workflows/build-container-images.yaml index 77f8ca59..3d63d5d5 100644 --- a/.github/workflows/build-container-images.yaml +++ b/.github/workflows/build-container-images.yaml @@ -24,6 +24,7 @@ env: VERSION_ARGO_UTILS: 0.0.1 VERSION_OBM_UTILS: 0.0.1 VERSION_PYTHON_NAUTOBOT_INT_SYNC: 0.0.1 + VERSION_PYTHON_NAUTOBOT: 0.0.1 jobs: build-ghcr-registry: @@ -109,3 +110,13 @@ jobs: tags: ghcr.io/rackerlabs/understack/nautobot-interfaces-sync:latest,ghcr.io/rackerlabs/understack/nautobot-interfaces-sync:${{ env.VERSION_PYTHON_NAUTOBOT_INT_SYNC }} labels: | org.opencontainers.image.version=${{ env.VERSION_PYTHON_NAUTOBOT_INT_SYNC }} + - name: Build and deploy Python 3.11 with PyNautobot + uses: docker/build-push-action@v5 + with: + context: argo-workflows/ironic-to-nautobot-sync + file: argo-workflows/ironic-to-nautobot-sync/containers/Dockerfile + # push for all main branch commits + push: ${{ github.event_name != 'pull_request' }} + tags: ghcr.io/rackerlabs/understack/ironic-to-nautobot-sync:latest,ghcr.io/rackerlabs/understack/ironic-to-nautobot-sync:${{ env.VERSION_PYTHON_NAUTOBOT }} + labels: | + org.opencontainers.image.version=${{ env.VERSION_PYTHON_NAUTOBOT }} diff --git a/argo-workflows/ironic-to-nautobot-sync/code/__init__.py b/argo-workflows/ironic-to-nautobot-sync/code/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/argo-workflows/ironic-to-nautobot-sync/code/helpers.py b/argo-workflows/ironic-to-nautobot-sync/code/helpers.py new file mode 100644 index 00000000..fe5a7fbb --- /dev/null +++ b/argo-workflows/ironic-to-nautobot-sync/code/helpers.py @@ -0,0 +1,44 @@ +import argparse +import logging +import os +import sys + +logger = logging.getLogger(__name__) + + +def setup_logger(name): + logger = logging.getLogger(name) + handler = logging.StreamHandler() + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + handler.setFormatter(formatter) + logger.addHandler(handler) + logger.setLevel(logging.DEBUG) + return logger + + +def arg_parser(name): + parser = argparse.ArgumentParser( + prog=os.path.basename(name), description="Ironic to Nautobot provisioning state sync" + ) + parser.add_argument("--device_uuid", required=True, + help="Nautobot device UUID") + parser.add_argument("--provisioning-state", required=True) + parser.add_argument("--nautobot_url", required=False) + parser.add_argument("--nautobot_token", required=False) + + return parser + + +def exit_with_error(error): + logger.error(error) + sys.exit(1) + + +def credential(subpath, item): + try: + return open(f"/etc/{subpath}/{item}", "r").read().strip() + except FileNotFoundError: + exit_with_error(f"{subpath} {item} not found in mounted files") + diff --git a/argo-workflows/ironic-to-nautobot-sync/code/main.py b/argo-workflows/ironic-to-nautobot-sync/code/main.py new file mode 100644 index 00000000..0304d4b4 --- /dev/null +++ b/argo-workflows/ironic-to-nautobot-sync/code/main.py @@ -0,0 +1,24 @@ +from nautobot import Nautobot +from helpers import arg_parser +from helpers import credential +from helpers import setup_logger + +logger = setup_logger(__name__) + + +def main(): + parser = arg_parser(__file__) + args = parser.parse_args() + + default_nb_url = "http://nautobot-default.nautobot.svc.cluster.local" + device_uuid = args.device_uuid + provision_state = args.provisioning_state + nb_url = args.nautobot_url or default_nb_url + nb_token = args.nautobot_token or credential("nb-token", "token") + + nautobot = Nautobot(nb_url, nb_token, logger=logger) + nautobot.update_status(device_uuid, provision_state) + + +if __name__ == "__main__": + main() diff --git a/argo-workflows/ironic-to-nautobot-sync/code/nautobot.py b/argo-workflows/ironic-to-nautobot-sync/code/nautobot.py new file mode 100644 index 00000000..110a2c8c --- /dev/null +++ b/argo-workflows/ironic-to-nautobot-sync/code/nautobot.py @@ -0,0 +1,70 @@ +import logging +import pynautobot +import requests +import sys +from typing import Protocol +from pynautobot.core.api import Api as NautobotApi +from pynautobot.models.dcim import Devices as NautobotDevice + +class Nautobot: + ALLOWED_STATUSES = [ + "enroll", + "verifying", + "manageable", + "inspecting", + "inspect wait", + "inspect failed", + "cleaning", + "clean wait", + "available", + "deploying", + "wait call-back", + "deploy failed", + "active", + "deleting", + "error", + "adopting", + "rescuing", + "rescue wait", + "rescue failed", + "rescue", + "unrescuing", + "unrescue failed", + "servicing", + "service wait", + "service failed", + ] + def __init__(self, url, token, logger=None, session=None): + self.url = url + self.token = token + self.logger = logger or logging.getLogger(__name__) + self.session = session or self.api_session(self.url, self.token) + + def exit_with_error(self, error): + self.logger.error(error) + sys.exit(1) + + def api_session(self, url: str, token: str) -> NautobotApi: + try: + return pynautobot.api(url, token=token) + except requests.exceptions.ConnectionError as e: + self.exit_with_error(e) + except pynautobot.core.query.RequestError as e: + self.exit_with_error(e) + + def device_by_id(self, device_id: str) -> NautobotDevice: + device = self.session.dcim.devices.get(device_id) + if not device: + self.exit_with_error(f"Device {device_id} not found in Nautobot") + return device + + def update_status(self, device_id, new_status: str): + device = self.device_by_id(device_id) + + if new_status not in self.ALLOWED_STATUSES: + raise Exception(f"Status {new_status} for device {device_id} is not in ALLOWED_STATUSES.") + + device.custom_fields['ironic_provisioning_status'] = new_status + response = device.save() + print(f"save result: {response}") + return response diff --git a/argo-workflows/ironic-to-nautobot-sync/containers/Dockerfile b/argo-workflows/ironic-to-nautobot-sync/containers/Dockerfile new file mode 100644 index 00000000..1b3a31c5 --- /dev/null +++ b/argo-workflows/ironic-to-nautobot-sync/containers/Dockerfile @@ -0,0 +1,30 @@ +ARG BASE=ghcr.io/rackerlabs/understack/argo-python3.11.8-alpine3.19:latest + +FROM ${BASE} as builder + +ARG APP_PATH=/app +ARG APP_USER=appuser +ARG APP_GROUP=appgroup +ARG APP_USER_UID=1000 +ARG APP_GROUP_GID=1000 + +COPY --chown=${APP_USER}:${APP_GROUP} containers/requirements.txt /app +RUN --mount=type=cache,target=/var/cache/apk apk add --virtual build-deps gcc python3-dev musl-dev linux-headers +RUN --mount=type=cache,target=/root/.cache/.pip pip install --no-cache-dir -r /app/requirements.txt + +FROM ${BASE} as prod + +LABEL org.opencontainers.image.title="Python 3.11 image with pynautobot" +LABEL org.opencontainers.image.base.name="ghcr.io/rackerlabs/understack/ironic-to-nautobot-sync-python3.11.8" +LABEL org.opencontainers.image.source=https://github.com/rackerlabs/understack + + +ENV PATH="/opt/venv/bin:$PATH" +COPY --from=builder /opt/venv /opt/venv + +WORKDIR /app + +USER $APP_USER + +COPY --chown=${APP_USER}:${APP_GROUP} code/ /app +CMD ["python", "/app/main.py"] diff --git a/argo-workflows/ironic-to-nautobot-sync/containers/requirements.txt b/argo-workflows/ironic-to-nautobot-sync/containers/requirements.txt new file mode 100644 index 00000000..9e442c35 --- /dev/null +++ b/argo-workflows/ironic-to-nautobot-sync/containers/requirements.txt @@ -0,0 +1,2 @@ +requests==2.31.0 +pynautobot==2.1.1 diff --git a/argo-workflows/ironic-to-nautobot-sync/sensors/ironic-to-nautobot-sensor.yaml b/argo-workflows/ironic-to-nautobot-sync/sensors/ironic-to-nautobot-sensor.yaml new file mode 100644 index 00000000..f50c4b46 --- /dev/null +++ b/argo-workflows/ironic-to-nautobot-sync/sensors/ironic-to-nautobot-sensor.yaml @@ -0,0 +1,68 @@ +--- +apiVersion: argoproj.io/v1alpha1 +kind: Sensor +metadata: + finalizers: + - sensor-controller + labels: + argocd.argoproj.io/instance: argo-events + name: ironic-node-update + namespace: argo-events +spec: + dependencies: + - eventName: openstack + eventSourceName: openstack-amqp + name: ironic-dep + transform: + jq: ".body[\"oslo.message\"] | fromjson" + filters: + dataLogicalOperator: "and" + data: + - path: "event_type" + type: "string" + value: + - "baremetal.node.update.end" + template: + serviceAccountName: operate-workflow-sa + triggers: + - template: + name: ironic-node-update-trigger + k8s: + operation: create + parameters: + - dest: spec.arguments.parameters.0.value + src: + dataKey: payload.ironic_object\.data.uuid + dependencyName: ironic-dep + - dest: spec.arguments.parameters.1.value + src: + dataKey: payload.ironic_object\.data.provision_state + dependencyName: ironic-dep + source: + resource: + apiVersion: argoproj.io/v1alpha1 + kind: Workflow + metadata: + generateName: ironic-node-update- + spec: + arguments: + parameters: + - name: device_uuid + value: Device UUID + - name: provision_state + value: Ironic Provision state + entrypoint: start + serviceAccountName: workflow + templates: + - name: start + steps: + - - name: synchronize-provision-state-to-nautobot + templateRef: + name: synchronize-provision-state-to-nautobot + template: synchronize-state + arguments: + parameters: + - name: device_uuid + value: "{{workflow.parameters.device_uuid}}" + - name: provision_state + value: "{{workflow.parameters.provision_state}}" diff --git a/argo-workflows/ironic-to-nautobot-sync/workflowtemplates/sync.yaml b/argo-workflows/ironic-to-nautobot-sync/workflowtemplates/sync.yaml new file mode 100644 index 00000000..f35412b2 --- /dev/null +++ b/argo-workflows/ironic-to-nautobot-sync/workflowtemplates/sync.yaml @@ -0,0 +1,35 @@ +apiVersion: argoproj.io/v1alpha1 +metadata: + name: synchronize-provision-state-to-nautobot +kind: WorkflowTemplate +spec: + arguments: + parameters: + - name: device_uuid + value: "{}" + - name: provision_state + value: "{}" + templates: + - name: synchronize-state + container: + image: ghcr.io/rackerlabs/understack/ironic-to-nautobot-sync:latest + command: + - python + - /app/main.py + args: + - --device_uuid + - "{{workflow.parameters.device_uuid}}" + - --provisioning-state + - "{{workflow.parameters.provision_state}}" + volumeMounts: + - mountPath: /etc/nb-token/ + name: nb-token + readOnly: true + inputs: + parameters: + - name: device_uuid + - name: provision_state + volumes: + - name: nb-token + secret: + secretName: nautobot-token