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)