diff --git a/.github/workflows/yamllint.yaml b/.github/workflows/yamllint.yaml index cb6827d74..1f4ec2229 100644 --- a/.github/workflows/yamllint.yaml +++ b/.github/workflows/yamllint.yaml @@ -46,6 +46,7 @@ jobs: argo-workflows/**/workflowtemplates/*.y*ml argo-workflows/**/sensors/*.y*ml argo-workflows/**/workflows/*.y*ml + apps/understack-workflows/workflows/workflowtemplates/*.y*ml shellcheck: runs-on: ubuntu-latest diff --git a/apps/understack-workflows/READMD.md b/apps/understack-workflows/READMD.md new file mode 120000 index 000000000..14e39e062 --- /dev/null +++ b/apps/understack-workflows/READMD.md @@ -0,0 +1 @@ +../../docs/component-understack-workflows.md \ No newline at end of file diff --git a/apps/understack-workflows/eventsource-openstack/argo-rabbitmq.yaml b/apps/understack-workflows/eventsource-openstack/argo-rabbitmq.yaml index e8ac747e2..373926ed8 100644 --- a/apps/understack-workflows/eventsource-openstack/argo-rabbitmq.yaml +++ b/apps/understack-workflows/eventsource-openstack/argo-rabbitmq.yaml @@ -23,3 +23,19 @@ spec: rabbitmqClusterReference: name: rabbitmq # rabbitmqCluster must exist in the same namespace as this resource namespace: openstack +--- +apiVersion: rabbitmq.com/v1beta1 +kind: Permission +metadata: + name: argo-to-keystone-permission +spec: + vhost: "keystone" + userReference: + name: "argo" # name of a user.rabbitmq.com in the same namespace; must specify either spec.userReference or spec.user + permissions: + write: ".*" + configure: ".*" + read: ".*" + rabbitmqClusterReference: + name: rabbitmq # rabbitmqCluster must exist in the same namespace as this resource + namespace: openstack diff --git a/apps/understack-workflows/eventsource-openstack/kustomization.yaml b/apps/understack-workflows/eventsource-openstack/kustomization.yaml index 5f308fc53..6828ca22d 100644 --- a/apps/understack-workflows/eventsource-openstack/kustomization.yaml +++ b/apps/understack-workflows/eventsource-openstack/kustomization.yaml @@ -7,3 +7,4 @@ resources: - argo-rabbitmq.yaml - eventbus-default.yaml - openstack-event-source.yaml + - sensor-keystone-event-project.yaml diff --git a/apps/understack-workflows/eventsource-openstack/openstack-event-source.yaml b/apps/understack-workflows/eventsource-openstack/openstack-event-source.yaml index 3c0c22f4c..4e71dfe5e 100644 --- a/apps/understack-workflows/eventsource-openstack/openstack-event-source.yaml +++ b/apps/understack-workflows/eventsource-openstack/openstack-event-source.yaml @@ -7,7 +7,6 @@ spec: openstack: # amqp server url url: amqp://rabbitmq-server-0.rabbitmq-nodes.openstack.svc.cluster.local:5672/ironic - routingKey: 'ironic_versioned_notifications.info' # jsonBody specifies that all event body payload coming from this # source will be JSON jsonBody: true @@ -16,6 +15,44 @@ spec: exchangeType: topic exchangeDeclare: durable: false + # routing key for messages within the exchange + routingKey: 'ironic_versioned_notifications.info' + # optional consume settings + # if not provided, default values will be used + consume: + consumerTag: "argo-events" + autoAck: true + exclusive: false + noLocal: false + # username and password for authentication + # use secret selectors + auth: + username: + name: argo-user-credentials + key: username + password: + name: argo-user-credentials + key: password +--- +apiVersion: argoproj.io/v1alpha1 +kind: EventSource +metadata: + name: openstack-keystone +spec: + amqp: + notifications: + # amqp server url + url: amqp://rabbitmq-server-0.rabbitmq-nodes.openstack.svc.cluster.local:5672/keystone + # jsonBody specifies that all event body payload coming from this + # source will be JSON + jsonBody: true + # name of the exchange. + exchangeName: keystone + exchangeType: topic + exchangeDeclare: + durable: false + # routing key for messages within the exchange + routingKey: 'notifications.info' # optional consume settings # if not provided, default values will be used consume: diff --git a/apps/understack-workflows/eventsource-openstack/sensor-keystone-event-project.yaml b/apps/understack-workflows/eventsource-openstack/sensor-keystone-event-project.yaml new file mode 100644 index 000000000..fa800dbae --- /dev/null +++ b/apps/understack-workflows/eventsource-openstack/sensor-keystone-event-project.yaml @@ -0,0 +1,67 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: sensor-submit-workflow +--- +apiVersion: argoproj.io/v1alpha1 +kind: Sensor +metadata: + finalizers: + - sensor-controller + labels: + argocd.argoproj.io/instance: argo-events + name: keystone-event-project + namespace: argo-events + annotations: + workflows.argoproj.io/description: | + Defined in `apps/understack-workflows/sensors/sensor-keystone-event-project.yaml` +spec: + dependencies: + - eventName: notifications + eventSourceName: openstack-keystone + name: keystone-msg + transform: + jq: ".body[\"oslo.message\"] | fromjson" + filters: + dataLogicalOperator: "and" + data: + - path: "event_type" + type: "string" + value: + - "identity.project.created" + - "identity.project.updated" + - "identity.project.deleted" + template: + serviceAccountName: sensor-submit-workflow + triggers: + - template: + name: keystone-event-project + argoWorkflow: + operation: submit + parameters: + - dest: spec.arguments.parameters.0.value + src: + dataKey: event_type + dependencyName: keystone-msg + - dest: spec.arguments.parameters.1.value + src: + dataKey: payload.target.id + dependencyName: keystone-msg + source: + resource: + apiVersion: argoproj.io/v1alpha1 + kind: Workflow + metadata: + generateName: keystone-event-project- + namespace: argo-events + spec: + arguments: + parameters: + - name: event_type + value: "replaced by parameters section" + - name: project_uuid + value: "replaced by parameters section" + serviceAccountName: workflow + workflowTemplateRef: + name: keystone-event-project diff --git a/apps/understack-workflows/kustomization.yaml b/apps/understack-workflows/kustomization.yaml index bc3323f17..d8a7641f5 100644 --- a/apps/understack-workflows/kustomization.yaml +++ b/apps/understack-workflows/kustomization.yaml @@ -3,3 +3,4 @@ kind: Kustomization resources: - eventsource-openstack + - workflowtemplates diff --git a/apps/understack-workflows/workflows/kustomization.yaml b/apps/understack-workflows/workflows/kustomization.yaml new file mode 100644 index 000000000..a68ceb149 --- /dev/null +++ b/apps/understack-workflows/workflows/kustomization.yaml @@ -0,0 +1,10 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +# this is where our workflows currently run +namespace: argo-events + +resources: + - openstack-svc-acct.yaml + - sensor-submit-rbac.yaml + - workflowtemplates/keystone-event-project.yaml diff --git a/apps/understack-workflows/workflows/openstack-svc-acct.yaml b/apps/understack-workflows/workflows/openstack-svc-acct.yaml new file mode 100644 index 000000000..0793273ce --- /dev/null +++ b/apps/understack-workflows/workflows/openstack-svc-acct.yaml @@ -0,0 +1,28 @@ +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: openstack-svc-acct +spec: + refreshInterval: 1h + secretStoreRef: + kind: ClusterSecretStore + name: openstack + target: + name: openstack-svc-acct + template: + engineVersion: v2 + data: + clouds.yaml: | + clouds: + understack: + auth_url: http://keystone-api.openstack.svc.cluster.local:5000/v3 + user_domain_name: {{ .user_domain }} + username: {{ .username }} + password: {{ .password }} + # this should switch to where we will be creating the ironic nodes + # in the future + project_domain_name: default + project_name: undercloud + dataFrom: + - extract: + key: svc-acct-argoworkflow diff --git a/apps/understack-workflows/workflows/sensor-submit-rbac.yaml b/apps/understack-workflows/workflows/sensor-submit-rbac.yaml new file mode 100644 index 000000000..f5f69a163 --- /dev/null +++ b/apps/understack-workflows/workflows/sensor-submit-rbac.yaml @@ -0,0 +1,40 @@ +--- +# Similarly you can use a ClusterRole and ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: sensor-submit-workflow-role +rules: + - apiGroups: + - argoproj.io + verbs: + - get + - watch + - list + resources: + - workflowtemplates + - clusterworkflowtemplates + - apiGroups: + - argoproj.io + verbs: + - create + - get + - list + - watch + - update + - patch + resources: + - workflows +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: openstack-sensor-submit-workflow +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: sensor-submit-workflow-role +subjects: + - kind: ServiceAccount + name: sensor-submit-workflow + namespace: openstack diff --git a/apps/understack-workflows/workflows/workflowtemplates/wf-keystone-event-project.yaml b/apps/understack-workflows/workflows/workflowtemplates/wf-keystone-event-project.yaml new file mode 100644 index 000000000..a2772b2ac --- /dev/null +++ b/apps/understack-workflows/workflows/workflowtemplates/wf-keystone-event-project.yaml @@ -0,0 +1,37 @@ +apiVersion: argoproj.io/v1alpha1 +metadata: + name: keystone-event-project + annotations: + workflows.argoproj.io/description: | + Defined in `apps/understack-workflows/workflowtemplates/wf-keystone-event-project.yaml` +kind: WorkflowTemplate +spec: + entrypoint: sync-keystone + templates: + - name: sync-keystone + container: + image: ghcr.io/rackerlabs/understack/ironic-nautobot-client:latest + command: + - sync-keystone + args: + - "--only-domain" + - "default" + - "{{workflow.parameters.event_type}}" + - "{{workflow.parameters.project_uuid}}" + volumeMounts: + - mountPath: /etc/nb-token/ + name: nb-token + readOnly: true + - mountPath: /etc/openstack + name: openstack-svc-acct + readOnly: true + inputs: + parameters: + - name: project_uuid + volumes: + - name: nb-token + secret: + secretName: nautobot-token + - name: openstack-svc-acct + secret: + secretName: openstack-svc-acct diff --git a/components/openstack/kustomization.yaml b/components/openstack/kustomization.yaml index c7f3ca442..29b5d8e8f 100644 --- a/components/openstack/kustomization.yaml +++ b/components/openstack/kustomization.yaml @@ -6,6 +6,11 @@ resources: - mariadb-configmap.yaml - mariadb-instance.yaml - openstack-cluster.yaml + # a secret store that let's us copy creds to other namespaces + # for service accounts + - secretstore-openstack.yaml + # defines the service account 'argoworkflow' used by our workflows + - svc-acct-argoworkflow.yaml helmCharts: - name: memcached diff --git a/components/openstack/secretstore-openstack.yaml b/components/openstack/secretstore-openstack.yaml new file mode 100644 index 000000000..60e7762f9 --- /dev/null +++ b/components/openstack/secretstore-openstack.yaml @@ -0,0 +1,65 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: eso-openstack +--- +apiVersion: v1 +kind: Secret +metadata: + annotations: + kubernetes.io/service-account.name: eso-openstack + name: eso-openstack.service-account-token +type: kubernetes.io/service-account-token +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: eso-openstack-role +rules: +- apiGroups: [""] + resources: + - secrets + verbs: + - get + - list + - watch + resourceNames: + - svc-acct-argoworkflow +- apiGroups: + - authorization.k8s.io + resources: + - selfsubjectrulesreviews + verbs: + - create +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: eso-openstack-rolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: eso-openstack-role +subjects: +- kind: ServiceAccount + name: eso-openstack +--- +apiVersion: external-secrets.io/v1beta1 +kind: ClusterSecretStore +metadata: + name: openstack +spec: + provider: + kubernetes: + remoteNamespace: openstack + server: + caProvider: + type: ConfigMap + name: kube-root-ca.crt + key: ca.crt + namespace: openstack + auth: + serviceAccount: + name: eso-openstack + namespace: openstack diff --git a/components/openstack/svc-acct-argoworkflow.yaml b/components/openstack/svc-acct-argoworkflow.yaml new file mode 100644 index 000000000..a2e8975cd --- /dev/null +++ b/components/openstack/svc-acct-argoworkflow.yaml @@ -0,0 +1,28 @@ +apiVersion: generators.external-secrets.io/v1alpha1 +kind: Fake +metadata: + name: svc-acct-argoworkflow +spec: + data: + # this provider needs to go away for a generated account + # but it currently must be in sync with the keystone bootstrap + # script + # this should be the 'service' domain in the future + user_domain: default + username: argoworkflow + password: demo +--- +apiVersion: external-secrets.io/v1beta1 +kind: ExternalSecret +metadata: + name: svc-acct-argoworkflow +spec: + refreshInterval: 1h + target: + name: svc-acct-argoworkflow + dataFrom: + - sourceRef: + generatorRef: + apiVersion: generators.external-secrets.io/v1alpha1 + kind: Fake + name: svc-acct-argoworkflow diff --git a/docs/component-understack-workflows.md b/docs/component-understack-workflows.md new file mode 100644 index 000000000..5310c0673 --- /dev/null +++ b/docs/component-understack-workflows.md @@ -0,0 +1,45 @@ +# Understack Workflows + +This is the Kubernetes installation of the Argo Workflows +and their associated support bits to add the actual workflows, +sensors and triggers into a Kubernetes cluster. + +Due to the scoping of resources into different namespaces, this +must also be split into multiple namespaces. + +This code lives in `apps/understack-workflows` of this repo. + +Specifics about the workflows can be seen in the Workflows +section. + +## eventsource-openstack + +The resources managed here are: + +1. A RabbitMQ user named `argo` on the OpenStack RabbitMQ cluster, which has +permissions to listen for notifications from OpenStack components. At this +time it is listening to keystone and ironic only. +1. [External Secrets][eso] Secret Store to allow access the + following secrets: + + - an OpenStack user our workflows can use + - a Nautobot token our workflows can use + +1. An [Argo Events][argo-events] Event Bus +to push the received notifications into. +1. A Kubernetes Service account `sensor-submit-workflow` which +allows an Argo Events Trigger from a Sensor to read look up +[Argo Workflows][argo-wf] Workflow Templates and use them to +execute a Workflow. +1. An [Argo Events][argo-events] Sensors and Triggers that +execute workflows. + +## workflowtemplates + +1. A Kubernetes Role Binding allowing the `sensor-submit-workflow` +the access it needs to run Workflows. +1. A number of Workflow Templates. + +[argo-events]: +[argo-wf]: +[eso]: diff --git a/mkdocs.yml b/mkdocs.yml index ca1381738..cb1549670 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -110,6 +110,7 @@ nav: - component-overview.md - component-argo-events.md - component-argo-workflows.md + - component-understack-workflows.md - 'Deployment Guide': - deploy-guide/index.md - Quick Start: deploy-guide/gitops-install.md diff --git a/python/understack-workflows/README.md b/python/understack-workflows/README.md index e69de29bb..b6deac02f 100644 --- a/python/understack-workflows/README.md +++ b/python/understack-workflows/README.md @@ -0,0 +1,10 @@ +# understack-workflows + +This Python package aims to provide all the scripts that we will +use inside of Argo Workflows. + +## sync-keystone + +This script takes an OpenStack Project ID and ensures the proper +operation happens against the Nautobot Tenants. Operations include +create, update and delete. diff --git a/python/understack-workflows/poetry.lock b/python/understack-workflows/poetry.lock index 043cbff8b..f1b7b33a4 100644 --- a/python/understack-workflows/poetry.lock +++ b/python/understack-workflows/poetry.lock @@ -1025,6 +1025,20 @@ files = [ [package.dependencies] pytest = ">=4.0.0" +[[package]] +name = "pytest-lazy-fixtures" +version = "1.1.1" +description = "Allows you to use fixtures in @pytest.mark.parametrize." +optional = false +python-versions = "<4.0,>=3.8" +files = [ + {file = "pytest_lazy_fixtures-1.1.1-py3-none-any.whl", hash = "sha256:a4b396a361faf56c6305535fd0175ce82902ca7cf668c4d812a25ed2bcde8183"}, + {file = "pytest_lazy_fixtures-1.1.1.tar.gz", hash = "sha256:0c561f0d29eea5b55cf29b9264a3241999ffdb74c6b6e8c4ccc0bd2c934d01ed"}, +] + +[package.dependencies] +pytest = ">=7" + [[package]] name = "pytest-mock" version = "3.14.0" @@ -1181,6 +1195,23 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "requests-mock" +version = "1.12.1" +description = "Mock out responses from the requests package" +optional = false +python-versions = ">=3.5" +files = [ + {file = "requests-mock-1.12.1.tar.gz", hash = "sha256:e9e12e333b525156e82a3c852f22016b9158220d2f47454de9cae8a77d371401"}, + {file = "requests_mock-1.12.1-py2.py3-none-any.whl", hash = "sha256:b1e37054004cdd5e56c84454cc7df12b25f90f382159087f4b6915aaeef39563"}, +] + +[package.dependencies] +requests = ">=2.22,<3" + +[package.extras] +fixture = ["fixtures"] + [[package]] name = "requestsexceptions" version = "1.4.0" @@ -1492,4 +1523,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = "~3.11.0" -content-hash = "4aaf7a6a91016484d665e34af1d5389c9f971ec57d7d69b61922fd5aeb031b31" +content-hash = "d72c985172f6a8612612450ceb96a585c69ad1a8c37969ce5e73a0f5ac9224e6" diff --git a/python/understack-workflows/pyproject.toml b/python/understack-workflows/pyproject.toml index be27d4e62..ea4377790 100644 --- a/python/understack-workflows/pyproject.toml +++ b/python/understack-workflows/pyproject.toml @@ -39,8 +39,11 @@ pytest = "^7" pytest-github-actions-annotate-failures = "*" pytest-cov = "^5.0.0" pytest-mock = "^3.14.0" +pytest-lazy-fixtures = "^1.1.1" +requests-mock = "^1.12.1" [tool.poetry.scripts] +sync-keystone = "understack_workflows.main.sync_keystone:main" sync-interfaces = "understack_workflows.main.sync_interfaces:main" sync-obm-creds = "understack_workflows.main.sync_obm_creds:main" sync-server = "understack_workflows.main.sync_server:main" diff --git a/python/understack-workflows/tests/conftest.py b/python/understack-workflows/tests/conftest.py index c7377e7a3..0dd9d3913 100644 --- a/python/understack-workflows/tests/conftest.py +++ b/python/understack-workflows/tests/conftest.py @@ -1,8 +1,99 @@ import uuid +from unittest.mock import MagicMock +import openstack import pytest +from pynautobot import __version__ as pynautobot_version + +from understack_workflows.nautobot import Nautobot @pytest.fixture def device_id() -> uuid.UUID: return uuid.uuid4() + + +@pytest.fixture +def domain_id() -> uuid.UUID: + return uuid.uuid4() + + +@pytest.fixture +def project_id() -> uuid.UUID: + return uuid.uuid4() + + +@pytest.fixture +def project_data(domain_id: uuid.UUID, project_id: uuid.UUID): + return { + "id": project_id, + "domain_id": domain_id, + "name": "test project", + "description": "this is a test project", + "enabled": True, + } + + +@pytest.fixture +def os_conn(project_data: dict) -> openstack.connection.Connection: + def _get_project(project_id): + if project_id == project_data["id"].hex: + data = { + **project_data, + "id": project_id, + "domain_id": project_data["domain_id"].hex, + } + return openstack.identity.v3.project.Project(**data) + raise openstack.exceptions.NotFoundException + + conn = MagicMock(spec_set=openstack.connection.Connection) + conn.identity.get_project.side_effect = _get_project + return conn + + +@pytest.fixture +def nautobot_url() -> str: + return "http://127.0.0.1" + + +@pytest.fixture +def tenant_data(nautobot_url: str, project_data: dict) -> dict: + project_id = str(project_data["id"]) + project_url = f"{nautobot_url}/api/tenancy/tenants/{project_id}/" + return { + "id": project_id, + "object_type": "tenancy.tenant", + "display": project_data["name"], + "url": project_url, + "natural_slug": f"{project_data['name']}_6fe6", + "circuit_count": 0, + "device_count": 0, + "ipaddress_count": 0, + "prefix_count": 0, + "rack_count": 0, + "virtualmachine_count": 0, + "vlan_count": 0, + "vrf_count": 0, + "name": "project 1", + "description": project_data["description"], + "comments": "", + "tenant_group": {}, + "created": "2024-08-09T14:03:57.772916Z", + "last_updated": "2024-08-09T14:03:57.772956Z", + "tags": [], + "notes_url": f"{project_url}notes", + "custom_fields": {}, + } + + +@pytest.fixture +def nautobot(requests_mock, nautobot_url: str, tenant_data: dict) -> Nautobot: + requests_mock.get( + f"{nautobot_url}/api/", headers={"API-Version": pynautobot_version} + ) + requests_mock.get(tenant_data["url"], json=tenant_data) + requests_mock.delete(tenant_data["url"]) + requests_mock.post( + f"{nautobot_url}/api/plugins/uuid-api-endpoints/tenant/", json=tenant_data + ) + return Nautobot(nautobot_url, "blah") diff --git a/python/understack-workflows/tests/test_sync_keystone.py b/python/understack-workflows/tests/test_sync_keystone.py new file mode 100644 index 000000000..0f41413cf --- /dev/null +++ b/python/understack-workflows/tests/test_sync_keystone.py @@ -0,0 +1,111 @@ +import uuid +from contextlib import nullcontext + +import pytest +from pytest_lazy_fixtures import lf + +from understack_workflows.domain import DefaultDomain +from understack_workflows.main.sync_keystone import Event +from understack_workflows.main.sync_keystone import argument_parser +from understack_workflows.main.sync_keystone import do_action +from understack_workflows.main.sync_keystone import is_valid_domain + + +@pytest.mark.parametrize( + "arg_list,context,expected_id", + [ + (["identity.project.created", ""], pytest.raises(SystemExit), None), + (["identity.project.created", "http"], pytest.raises(SystemExit), None), + ( + ["identity.project.created", lf("project_id")], + nullcontext(), + lf("project_id"), + ), + ( + [ + "--only-domain", + lf("domain_id"), + "identity.project.created", + lf("project_id"), + ], + nullcontext(), + lf("project_id"), + ), + ( + ["--only-domain", "default", "identity.project.created", lf("project_id")], + nullcontext(), + lf("project_id"), + ), + ], +) +def test_parse_object_id(arg_list, context, expected_id): + parser = argument_parser() + with context: + args = parser.parse_args([str(arg) for arg in arg_list]) + + assert args.object == expected_id + + +@pytest.mark.parametrize( + "only_domain,expected", + [ + (None, True), + (DefaultDomain(), False), + (lf("domain_id"), True), + ], +) +def test_is_valid_domain(os_conn, project_id, only_domain, expected): + assert is_valid_domain(os_conn, project_id, only_domain) == expected + + +@pytest.mark.parametrize( + "only_domain", + [ + None, + lf("domain_id"), + uuid.uuid4(), + ], +) +def test_create_project( + os_conn, + nautobot, + project_id: uuid.UUID, + only_domain: uuid.UUID | DefaultDomain | None, +): + do_action(os_conn, nautobot, Event.ProjectCreate, project_id, only_domain) + os_conn.identity.get_project.assert_called_with(project_id.hex) + + +@pytest.mark.parametrize( + "only_domain", + [ + None, + lf("domain_id"), + uuid.uuid4(), + ], +) +def test_update_project( + os_conn, + nautobot, + project_id: uuid.UUID, + only_domain: uuid.UUID | DefaultDomain | None, +): + do_action(os_conn, nautobot, Event.ProjectUpdate, project_id, only_domain) + os_conn.identity.get_project.assert_called_with(project_id.hex) + + +@pytest.mark.parametrize( + "only_domain", + [ + None, + lf("domain_id"), + uuid.uuid4(), + ], +) +def test_delete_project( + os_conn, + nautobot, + project_id: uuid.UUID, + only_domain: uuid.UUID | DefaultDomain | None, +): + do_action(os_conn, nautobot, Event.ProjectDelete, project_id, only_domain) diff --git a/python/understack-workflows/understack_workflows/domain.py b/python/understack-workflows/understack_workflows/domain.py new file mode 100644 index 000000000..2178931c9 --- /dev/null +++ b/python/understack-workflows/understack_workflows/domain.py @@ -0,0 +1,13 @@ +"""helper for OpenStack Domain IDs.""" + +import uuid + + +class DefaultDomain: + @property + def hex(self): + return "default" + + +def domain_id(data): + return DefaultDomain() if data == "default" else uuid.UUID(data) diff --git a/python/understack-workflows/understack_workflows/main/sync_keystone.py b/python/understack-workflows/understack_workflows/main/sync_keystone.py new file mode 100644 index 000000000..f1f3b6bac --- /dev/null +++ b/python/understack-workflows/understack_workflows/main/sync_keystone.py @@ -0,0 +1,130 @@ +import argparse +import logging +import uuid +from enum import StrEnum + +import openstack +from openstack.connection import Connection + +from understack_workflows.domain import DefaultDomain +from understack_workflows.domain import domain_id +from understack_workflows.helpers import credential +from understack_workflows.helpers import parser_nautobot_args +from understack_workflows.helpers import setup_logger +from understack_workflows.nautobot import Nautobot + +logger = setup_logger(__name__, level=logging.INFO) + + +class Event(StrEnum): + ProjectCreate = "identity.project.created" + ProjectUpdate = "identity.project.updated" + ProjectDelete = "identity.project.deleted" + + +def argument_parser(): + parser = argparse.ArgumentParser( + description="Handle Keystone Events", + ) + parser.add_argument( + "--os-cloud", + type=str, + default="understack", + help="Cloud to load. default: %(default)s", + ) + + parser.add_argument( + "--only-domain", + type=domain_id, + help="Only operate on projects from specified domain", + ) + parser.add_argument("event", type=Event, choices=[item.value for item in Event]) + parser.add_argument( + "object", type=uuid.UUID, help="Keystone ID of object the event happened on" + ) + parser = parser_nautobot_args(parser) + + return parser + + +def is_valid_domain( + conn: Connection, + project_id: uuid.UUID, + only_domain: uuid.UUID | DefaultDomain | None, +) -> bool: + if only_domain is None: + return True + project = conn.identity.get_project(project_id.hex) + ret = project.domain_id == only_domain.hex + if not ret: + logger.info( + f"keystone project {project_id!s} part of domain " + f"{project.domain_id} and not {only_domain!s}" + ) + return ret + + +def handle_project_create(conn: Connection, nautobot: Nautobot, project_id: uuid.UUID): + logger.info(f"got request to create tenant {project_id!s}") + project = conn.identity.get_project(project_id.hex) + ten_api = nautobot.session.tenancy.tenants + ten_api.url = f"{ten_api.base_url}/plugins/uuid-api-endpoints/tenant" + ten = ten_api.create( + id=str(project_id), name=project.name, description=project.description + ) + logger.info(f"tenant '{project_id!s}' created {ten.created}") + + +def handle_project_update(conn: Connection, nautobot: Nautobot, project_id: uuid.UUID): + logger.info(f"got request to update tenant {project_id!s}") + project = conn.identity.get_project(project_id.hex) + ten = nautobot.session.tenancy.tenants.get(project_id) + ten.description = project.description + ten.save() + logger.info(f"tenant '{project_id!s}' last updated {ten.last_updated}") + + +def handle_project_delete(conn: Connection, nautobot: Nautobot, project_id: uuid.UUID): + logger.info(f"got request to delete tenant {project_id!s}") + ten = nautobot.session.tenancy.tenants.get(project_id) + if not ten: + logger.warn(f"tenant '{project_id!s}' does not exist already") + return + ten.delete() + logger.info(f"deleted tenant {project_id!s}") + + +def do_action( + conn: Connection, + nautobot: Nautobot, + event: Event, + project_id: uuid.UUID, + only_domain: uuid.UUID | DefaultDomain | None, +): + if event in [Event.ProjectCreate, Event.ProjectUpdate] and not is_valid_domain( + conn, project_id, only_domain + ): + logger.info( + f"keystone project {project_id!s} not part of {only_domain!s}, skipping" + ) + return + + match event: + case Event.ProjectCreate: + handle_project_create(conn, nautobot, project_id) + case Event.ProjectUpdate: + handle_project_update(conn, nautobot, project_id) + case Event.ProjectDelete: + handle_project_delete(conn, nautobot, project_id) + case _: + raise Exception(f"Cannot handle event: {event}") + + +def main(): + args = argument_parser().parse_args() + + conn = openstack.connect(cloud=args.os_cloud) + nb_token = args.nautobot_token or credential("nb-token", "token") + nautobot = Nautobot(args.nautobot_url, nb_token, logger=logger) + + do_action(conn, nautobot, args.event, args.object, args.only_domain)