diff --git a/components/site-workflows/kustomization.yaml b/components/site-workflows/kustomization.yaml index fd4605e00..987353290 100644 --- a/components/site-workflows/kustomization.yaml +++ b/components/site-workflows/kustomization.yaml @@ -14,7 +14,6 @@ resources: - serviceaccounts/serviceaccount-sensor-submit-workflow.yaml - serviceaccounts/serviceaccount-k8s-openstack-events-secrets.yaml - sensors/sensor-ironic-node-update.yaml - - sensors/sensor-keystone-event-project.yaml - sensors/sensor-keystone-oslo-event.yaml - sensors/sensor-keystone-automation-user-upsert.yaml - sensors/sensor-keystone-automation-user-delete.yaml diff --git a/components/site-workflows/sensors/sensor-keystone-event-project.yaml b/components/site-workflows/sensors/sensor-keystone-event-project.yaml deleted file mode 100644 index 60e8330aa..000000000 --- a/components/site-workflows/sensors/sensor-keystone-event-project.yaml +++ /dev/null @@ -1,81 +0,0 @@ ---- -apiVersion: argoproj.io/v1alpha1 -kind: Sensor -metadata: - name: keystone-event-project - annotations: - workflows.argoproj.io/title: CRUD Nautobot Tenants from Keystone Projects - workflows.argoproj.io/description: |+ - Triggers on the following Keystone Events: - - - identity.project.created - - identity.project.updated - - identity.project.deleted - - Currently parses out the following fields: - - - target.id which is the project_id - - Resulting code should be very similar to: - - ``` - argo -n argo-events submit --from workflowtemplate/keystone-event-project \ - -p event_type identity.project.created -p project_uuid=00000000-0000-0000-0000-000000000000 - ``` - - Defined in `components/site-workflows/sensors/sensor-keystone-event-project.yaml` -spec: - dependencies: - - eventName: notifications - eventSourceName: openstack-keystone - name: keystone-msg - transform: - # the event is a string-ified JSON so we need to decode it - jq: ".body[\"oslo.message\"] | fromjson" - filters: - # applies each of the items in data with 'and' but there's only one - dataLogicalOperator: "and" - data: - - path: "event_type" - type: "string" - # any of the values will trigger - value: - - "identity.project.created" - - "identity.project.updated" - - "identity.project.deleted" - template: - serviceAccountName: sensor-submit-workflow - triggers: - - template: - name: keystone-event-project - # creates workflow object directly via k8s API - k8s: - operation: create - parameters: - # first parameter's value is replaced with the event type - - dest: spec.arguments.parameters.0.value - src: - dataKey: event_type - dependencyName: keystone-msg - # second parameter's value is replaced with the project id - - dest: spec.arguments.parameters.1.value - src: - dataKey: payload.target.id - dependencyName: keystone-msg - source: - # create a workflow in argo-events prefixed with keystone-event-project- - resource: - apiVersion: argoproj.io/v1alpha1 - kind: Workflow - metadata: - generateName: keystone-event-project- - namespace: argo-events - spec: - # defines the parameters being replaced above - arguments: - parameters: - - name: event_type - - name: project_uuid - # references the workflow - workflowTemplateRef: - name: keystone-event-project diff --git a/docs/design-guide/keystone-project-reconciliation.md b/docs/design-guide/keystone-project-reconciliation.md new file mode 100644 index 000000000..4b683ae94 --- /dev/null +++ b/docs/design-guide/keystone-project-reconciliation.md @@ -0,0 +1,213 @@ +# Keystone Project Reconciliation + +This document describes the planned design for synchronizing OpenStack Keystone +projects across all site clusters from a single source of truth in Nautobot. + +## Problem + +UnderStack deploys independent Keystone instances per site for resiliency. +Keystone project UUIDs must be consistent across all sites, but Keystone API +project creation does not let operators provide a project UUID. + +## Goals + +- Use Nautobot as the source of truth for project identity and metadata. +- Keep project UUIDs identical across all sites. +- Reconcile changes within 120 seconds. +- Never hard-delete Keystone projects from automation. +- Scale from tens to thousands of projects. + +## Non-Goals + +- Managing Keystone domains dynamically. Domains are pre-created. +- Using per-project GitOps commits or per-project Kubernetes resources. + +## Source of Truth Mapping + +- Nautobot `tenant.id` -> Keystone `project.id` (authoritative UUID) +- Nautobot `tenant_group.name` -> Keystone `domain.name` (exact match) +- Nautobot `tenant.name` format -> `:` + - `:` is reserved as delimiter + - `:` is not allowed in the project-name segment + - prefix before `:` must exactly match `tenant_group.name` +- Nautobot `tenant.description` -> Keystone `project.description` +- Nautobot tags -> Keystone project tags (exact managed set), except: + - `disabled` is control-only and does not get written as a Keystone tag + +Special tags: + +- `disabled` (lowercase) on Nautobot tenant means Keystone `enabled=false` +- `UNDERSTACK_SVM` is managed exactly from Nautobot tags +- `UNDERSTACK_ID_CONFLICT` is used for quarantined Keystone projects + +## Reconciler Topology + +Run one site-local reconciler process per site as a Kubernetes `Deployment` +with a single replica. + +Reasons: + +- 30-second cadence is required for SLO and is not a good fit for cron syntax. +- Long-running worker avoids Pod churn. +- Site-local pull remains operational during partial partition events. + +Each site reconciler can call global Nautobot API directly. + +## Reconciliation Model + +The reconciler uses a pull loop with three cadences: + +- Every 30s: incremental Nautobot fetch using in-memory `updated_since` + watermark. +- Every 60s: lightweight full tenant index fetch (`id`, `name`, + `tenant_group`, `tags`) to detect deletes quickly. +- Every 10m: full drift reconciliation. + +On startup, run a full reconciliation before entering the loop. + +## Write Paths + +Hybrid Keystone write strategy: + +- Create with fixed UUID: Keystone library/DB path (direct DB write path). +- Update operations (rename, description, enabled state, tag set): + Keystone API (`openstacksdk`). + +## Domain and Project Behavior + +- All Tenant Groups are treated as managed Keystone domains. +- Managed domain exclusions: `service`, `infra` are excluded from + disable/quarantine sweeps. +- Domain is immutable for a given project UUID. + - If Nautobot moves a tenant to another Tenant Group, reconciler alerts and + skips automatic migration. + +## Delete and Disable Semantics + +- If tenant has `disabled` tag, disable matching Keystone project. +- If tenant is removed from Nautobot, disable matching Keystone project. +- If a disabled tenant becomes active again, re-enable Keystone project. +- No automated Keystone project hard-delete. + +## Drift Handling + +Within managed domains (except excluded domains), Keystone projects not present +in Nautobot desired state are disabled by reconciliation. + +If a whole Tenant Group disappears from Nautobot, reconciler only alerts and +does not bulk-disable that domain. + +## Conflict Quarantine + +If target `(domain, project_name)` already exists in Keystone with a different +UUID than Nautobot: + +1. Disable the conflicting Keystone project. +2. Rename to deterministic quarantine name: + `:INVALID:`. +3. Add tag `UNDERSTACK_ID_CONFLICT`. +4. Mark for manual remediation only (no auto-recovery). + +## Circuit Breaker + +Each cycle is `plan -> guard -> apply`: + +1. Build full action plan first (`create`, `update`, `disable`, `quarantine`). +2. Evaluate breaker. +3. If tripped, block all writes for that cycle and alert. + +Initial thresholds: + +- `disable_count > 10` OR +- `disable_count / managed_project_count > 0.05` + +Manual override: + +- Per-site override secret with expiry timestamp. +- Override allows temporary breaker bypass for controlled runs. + +## Observability + +Emit logs and OpenTelemetry metrics for: + +- Cycle duration +- Planned/applied action counts by action type +- Breaker trips +- Last successful reconcile timestamp +- Alert-worthy data validation failures (name format, missing tenant group, + domain immutability violations, conflicts) +- Tag enforcement rejections (per-tag skip events) + +## Security and Credentials + +Credentials are provided via multiple Kubernetes secrets: + +- Global Nautobot API URL/token +- Keystone API credentials +- Keystone DB credentials for fixed UUID creation path + +Use dedicated least-privilege credentials where possible. + +## Proposed Implementation Phases + +1. Implement reconciler command in `python/understack-workflows`. +2. Add plan/apply engine, conflict quarantine, and circuit breaker. +3. Add OpenTelemetry metrics and structured logging. +4. Add Kubernetes `Deployment` and config/secret wiring in site workflows. +5. Add test coverage for mapping, conflicts, deletes, breaker, and tag + behavior. + +## Implementation Plan + +### Phase 0: Cutover Preparation + +- Disable existing Keystone -> Nautobot project synchronization. +- Confirm Nautobot tenant data quality for: + - required Tenant Group + - `:` naming + - tag usage (`disabled`, `UNDERSTACK_SVM`) + +### Phase 1: Planner Prototype + +- Build deterministic `plan -> guard` engine: + - desired-state mapping from Nautobot tenants + - conflict quarantine planning + - disable planning for stale projects in managed domains + - source-completeness gate (block writes if source index incomplete) + - circuit breaker thresholds and block-all-writes behavior +- Add unit tests for planner behavior. + +### Phase 2: Reconciler Worker Skeleton + +- Add long-running site-local worker process (`Deployment`, replicas=1). +- Startup full reconcile, then 30s incremental + 60s index + 10m full loop. +- Emit logs and OpenTelemetry metrics for cycle state and guard outcomes. + +### Phase 3: Apply Engine + +- Implement write paths: + - fixed UUID project create via Keystone library/DB + - update/disable/tag via Keystone API +- Add ordered apply execution and error handling with partial-failure + reporting. + +### Phase 4: Rollout Safety + +- Add audit-only domain mode for staged rollout. +- Enable domain-by-domain enforcement after clean audits. +- Add timed per-site manual breaker override. + +### Phase 5: Production Hardening + +- Add integration tests (API + DB path). +- Add SLO-aligned alerting and dashboards. +- Add runbooks for conflict remediation and override usage. + +## Tag Rejection Handling + +Keystone project tags are expected to be freeform, but if a tag update is +rejected at API time: + +- skip only the offending tag(s) for that project +- continue reconciliation for the rest of that project state +- emit alerts and OpenTelemetry metrics for operator follow-up diff --git a/docs/operator-guide/keystone-project-reconciler.md b/docs/operator-guide/keystone-project-reconciler.md new file mode 100644 index 000000000..abbe50d8f --- /dev/null +++ b/docs/operator-guide/keystone-project-reconciler.md @@ -0,0 +1,165 @@ +# Keystone Project Reconciler + +This page describes the planned operator model for the site-local Keystone +project reconciler. + +## Overview + +Each site runs one reconciler worker that: + +- Pulls desired project state from global Nautobot +- Reconciles local Keystone projects to match desired state +- Creates projects with fixed UUIDs using Keystone DB/library path +- Updates project metadata/state/tags using Keystone API +- Disables (not deletes) stale projects +- Applies circuit breaker protections before writes + +## Deployment Model + +- Workload type: Kubernetes `Deployment` +- Replicas: `1` per site +- Startup behavior: full reconcile before loop +- Loop cadence: + - incremental reconcile every 30 seconds + - lightweight full ID index every 60 seconds + - full drift reconcile every 10 minutes + +## Rollout Sequence + +1. Disable Keystone -> Nautobot project sync. +2. Deploy reconciler in planner/audit mode first. +3. Validate diff outputs, validation errors, and breaker behavior. +4. Enable enforcement per managed domain. +5. Monitor SLO and breaker metrics during ramp-up. + +## Required Inputs + +Use separate secrets for each credential set. + +### Secrets + +- Nautobot API credentials + - token + - URL +- Keystone API credentials + - auth URL + - username/password or equivalent + - domain/project scope +- Keystone DB credentials + - DSN or host/port/user/password/db name + +### Config + +Recommended runtime configuration: + +```yaml +reconcile: + incremental_interval_seconds: 30 + index_interval_seconds: 60 + full_interval_seconds: 600 + startup_full_reconcile: true + +domains: + excluded: + - service + - infra + +tags: + disabled_control_tag: disabled + conflict_tag: UNDERSTACK_ID_CONFLICT + +breaker: + max_disable_abs: 10 + max_disable_pct: 0.05 + block_all_writes: true + override_secret_name: understack-keystone-reconcile-override + override_expiry_key: expires_at +``` + +## Circuit Breaker Behavior + +Each cycle uses a plan-first workflow: + +1. Build planned actions. +2. Evaluate thresholds. +3. If breaker trips, apply no writes. +4. Emit logs and metrics for alerting. + +Trip conditions: + +- planned disables > `max_disable_abs` +- or planned disables / managed projects > `max_disable_pct` + +Manual override: + +- Override is per-site and time-limited via secret expiry. +- Expired override must not bypass breaker. + +Completeness guard: + +- If the source index pull is incomplete for a cycle, that cycle performs no + writes and alerts. + +## Conflict Quarantine Runbook + +When `(domain, project_name)` exists with the wrong UUID: + +1. Disable the conflicting project. +2. Rename to `:INVALID:`. +3. Add `UNDERSTACK_ID_CONFLICT` tag. +4. Alert for manual remediation. + +Quarantined projects are manual-only and are not auto-recovered. + +## Day-2 Operations + +### Verify Worker Health + +- Pod is running and stable +- Last successful reconcile metric is updating +- Breaker trip count is not increasing unexpectedly + +### Force Full Reconcile + +- Restart reconciler Pod (startup full reconcile runs automatically) + +### Enable Temporary Breaker Override + +Create/update override secret with a near-term expiry timestamp. Remove or let +expire after change window. + +### Investigate Data Validation Alerts + +Common causes: + +- Tenant name not in `:` format +- Tenant name prefix does not match Tenant Group name +- Tenant missing Tenant Group +- Domain immutability violation for existing project UUID +- Keystone tag update rejection (offending tag skipped) + +## Metrics and Alerting + +Emit OpenTelemetry metrics and alert on: + +- reconcile cycle failures +- breaker trips +- conflict quarantine actions +- validation skips +- stale last-success timestamp +- tag-skip events caused by Keystone tag update rejections + +Recommended metric dimensions: + +- `site` +- `action_type` +- `result` +- `domain` + +## Notes + +- Automation never hard-deletes Keystone projects. +- Removed or disabled Nautobot tenants result in Keystone disable operations. +- Tag management is exact from Nautobot except `disabled`, which is control-only. +- If Keystone rejects a specific tag update, the worker skips only that tag, + continues project reconciliation, and alerts via logs/metrics. diff --git a/docs/operator-guide/workflows.md b/docs/operator-guide/workflows.md index 48b39010a..91f4598b6 100644 --- a/docs/operator-guide/workflows.md +++ b/docs/operator-guide/workflows.md @@ -16,11 +16,11 @@ Alerts Firing: Labels: alertname = KubePodNotReady namespace = argo-events -pod = keystone-event-project-4ksc7 +pod = ironic-node-update-wx6gk prometheus = monitoring/kube-prometheus-stack-prometheus severity = warning Annotations: -description = Pod argo-events/keystone-event-project-4ksc7 has been in a non-ready state for longer than 15 minutes. +description = Pod argo-events/ironic-node-update-wx6gk has been in a non-ready state for longer than 15 minutes. ``` We can then examine the argo workflow logs and kubernetes pod logs to investigate @@ -55,11 +55,6 @@ ironic-node-update-rswvq Failed 2h 10s 0 Error (exit ironic-node-update-kk5sf Failed 19h 10s 0 Error (exit code 1) ironic-node-update-wl2n7 Failed 19h 10s 0 Error (exit code 1) ironic-node-update-jd727 Failed 19h 10s 0 Error (exit code 1) -keystone-event-project-zjnzv Failed 1d 10s 0 Error (exit code 1) -keystone-event-project-6gxzn Failed 1d 10s 0 Error (exit code 1) -keystone-event-project-45jf9 Failed 1d 10s 0 Error (exit code 1) -keystone-event-project-pxcxg Failed 1d 10s 0 Error (exit code 1) -keystone-event-project-mtl4s Error 45d 21s 0 Error (exit code 1): pods "keystone-event-project-mtl4s" is forbidden: User "system:serviceaccount:argo-events:default" cannot patch resource "pods" in API group "" in the namespace "argo-events" ``` You can delete a single workflow: diff --git a/mkdocs.yml b/mkdocs.yml index b11c3d4a2..5c2a8dd0e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -134,6 +134,7 @@ nav: - design-guide/neutron-networking.md - design-guide/argo-workflows.md - design-guide/argo-events.md + - design-guide/keystone-project-reconciliation.md - 'Deployment Guide': - deploy-guide/welcome.md - deploy-guide/requirements.md @@ -174,6 +175,7 @@ nav: - 'Infrastructure': - operator-guide/argocd-helm-chart.md - operator-guide/workflows.md + - operator-guide/keystone-project-reconciler.md - operator-guide/monitoring.md - operator-guide/gateway-api.md - operator-guide/bmc-password.md diff --git a/python/understack-workflows/README.md b/python/understack-workflows/README.md index b6deac02f..9eb2da649 100644 --- a/python/understack-workflows/README.md +++ b/python/understack-workflows/README.md @@ -2,9 +2,3 @@ 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/pyproject.toml b/python/understack-workflows/pyproject.toml index 90b4b000a..08df68b12 100644 --- a/python/understack-workflows/pyproject.toml +++ b/python/understack-workflows/pyproject.toml @@ -30,7 +30,6 @@ dependencies = [ ] [project.scripts] -sync-keystone = "understack_workflows.main.sync_keystone:main" undersync-switch = "understack_workflows.main.undersync_switch:main" enroll-server = "understack_workflows.main.enroll_server:main" get-raid-devices = "understack_workflows.main.get_raid_devices:main" @@ -40,6 +39,7 @@ sync-network-segment-range = "understack_workflows.main.sync_ucvni_group_range:m openstack-oslo-event = "understack_workflows.main.openstack_oslo_event:main" netapp-create-svm = "understack_workflows.main.netapp_create_svm:main" netapp-configure-interfaces = "understack_workflows.main.netapp_configure_net:main" +keystone-project-reconciler = "understack_workflows.main.keystone_project_reconciler:main" [dependency-groups] test = [ diff --git a/python/understack-workflows/tests/test_keystone_project_reconciler.py b/python/understack-workflows/tests/test_keystone_project_reconciler.py new file mode 100644 index 000000000..4cf9981e2 --- /dev/null +++ b/python/understack-workflows/tests/test_keystone_project_reconciler.py @@ -0,0 +1,187 @@ +from understack_workflows.keystone_project_reconciler import ActionType +from understack_workflows.keystone_project_reconciler import KeystoneProject +from understack_workflows.keystone_project_reconciler import NautobotTenant +from understack_workflows.keystone_project_reconciler import ReconcilerConfig +from understack_workflows.keystone_project_reconciler import build_reconcile_plan +from understack_workflows.keystone_project_reconciler import parse_tenant_project_name + + +def _config(**kwargs) -> ReconcilerConfig: + base = ReconcilerConfig( + excluded_domains=frozenset({"service", "infra"}), + disabled_control_tag="disabled", + conflict_tag="UNDERSTACK_ID_CONFLICT", + max_disable_abs=10, + max_disable_pct=0.05, + ) + values = {**base.__dict__, **kwargs} + return ReconcilerConfig(**values) + + +def test_parse_tenant_project_name_valid(): + assert parse_tenant_project_name("default:project-a", "default") == ( + "default", + "project-a", + ) + + +def test_parse_tenant_project_name_invalid_prefix(): + assert parse_tenant_project_name("default:project-a", "sandbox") is None + + +def test_build_plan_blocks_writes_when_source_incomplete(): + plan = build_reconcile_plan( + nautobot_tenants=[], + keystone_projects=[], + source_complete=False, + config=_config(), + ) + assert plan.blocked is True + assert plan.actions == [] + + +def test_build_plan_create_for_missing_project(): + tenant = NautobotTenant( + id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + name="default:alpha", + tenant_group="default", + description="alpha project", + tags=frozenset({"UNDERSTACK_SVM"}), + ) + plan = build_reconcile_plan( + nautobot_tenants=[tenant], + keystone_projects=[], + source_complete=True, + config=_config(max_disable_pct=1.0), + ) + assert plan.blocked is False + assert plan.count(ActionType.CREATE) == 1 + create_action = plan.actions[0] + assert create_action.details["enabled"] == "True" + assert create_action.details["tags"] == "UNDERSTACK_SVM" + + +def test_build_plan_disabled_tag_becomes_enabled_false(): + tenant = NautobotTenant( + id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + name="default:beta", + tenant_group="default", + description="beta project", + tags=frozenset({"disabled", "UNDERSTACK_SVM"}), + ) + plan = build_reconcile_plan( + nautobot_tenants=[tenant], + keystone_projects=[], + source_complete=True, + config=_config(max_disable_pct=1.0), + ) + create_action = plan.actions[0] + assert create_action.details["enabled"] == "False" + assert create_action.details["tags"] == "UNDERSTACK_SVM" + + +def test_build_plan_conflict_quarantine_and_create(): + tenant = NautobotTenant( + id="cccccccc-cccc-cccc-cccc-cccccccccccc", + name="default:gamma", + tenant_group="default", + description="gamma project", + tags=frozenset(), + ) + conflicting_project = KeystoneProject( + id="dddddddd-dddd-dddd-dddd-dddddddddddd", + domain="default", + name="gamma", + description="old", + enabled=True, + tags=frozenset({"foo"}), + ) + plan = build_reconcile_plan( + nautobot_tenants=[tenant], + keystone_projects=[conflicting_project], + source_complete=True, + config=_config(max_disable_pct=1.0), + ) + assert plan.count(ActionType.QUARANTINE) == 1 + assert plan.count(ActionType.CREATE) == 1 + quarantine = [a for a in plan.actions if a.action_type == ActionType.QUARANTINE][0] + assert quarantine.details["enabled"] == "False" + assert "UNDERSTACK_ID_CONFLICT" in quarantine.details["tags"] + + +def test_build_plan_disable_unknown_in_managed_domain(): + tenant = NautobotTenant( + id="eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", + name="default:delta", + tenant_group="default", + description="delta project", + tags=frozenset(), + ) + unknown = KeystoneProject( + id="ffffffff-ffff-ffff-ffff-ffffffffffff", + domain="default", + name="orphan", + description="old", + enabled=True, + tags=frozenset(), + ) + plan = build_reconcile_plan( + nautobot_tenants=[tenant], + keystone_projects=[unknown], + source_complete=True, + config=_config(max_disable_pct=1.0), + ) + assert plan.count(ActionType.DISABLE) == 1 + + +def test_build_plan_excluded_domain_not_disabled(): + # No desired projects, but service domain should be excluded from disable. + service_project = KeystoneProject( + id="11111111-1111-1111-1111-111111111111", + domain="service", + name="svc", + description="service project", + enabled=True, + tags=frozenset(), + ) + plan = build_reconcile_plan( + nautobot_tenants=[], + keystone_projects=[service_project], + source_complete=True, + config=_config(max_disable_pct=1.0), + ) + assert plan.count(ActionType.DISABLE) == 0 + + +def test_build_plan_breaker_blocks_all_writes(): + tenant = NautobotTenant( + id="22222222-2222-2222-2222-222222222222", + name="default:epsilon", + tenant_group="default", + description="epsilon project", + tags=frozenset(), + ) + unknown1 = KeystoneProject( + id="33333333-3333-3333-3333-333333333333", + domain="default", + name="unknown1", + description="", + enabled=True, + tags=frozenset(), + ) + unknown2 = KeystoneProject( + id="44444444-4444-4444-4444-444444444444", + domain="default", + name="unknown2", + description="", + enabled=True, + tags=frozenset(), + ) + plan = build_reconcile_plan( + nautobot_tenants=[tenant], + keystone_projects=[unknown1, unknown2], + source_complete=True, + config=_config(max_disable_abs=1, max_disable_pct=1.0), + ) + assert plan.blocked is True + assert plan.block_reason is not None diff --git a/python/understack-workflows/tests/test_sync_keystone.py b/python/understack-workflows/tests/test_sync_keystone.py deleted file mode 100644 index f2441a698..000000000 --- a/python/understack-workflows/tests/test_sync_keystone.py +++ /dev/null @@ -1,152 +0,0 @@ -import uuid -from contextlib import nullcontext -from unittest.mock import MagicMock - -import pytest -from openstack.connection import Connection -from pytest_lazy_fixtures import lf - -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 handle_project_delete - - -@pytest.fixture -def mock_pynautobot_api(mocker): - mock_client = MagicMock(name="MockPynautobotApi") - - mock_devices = MagicMock() - mock_devices.filter.return_value = [] - mock_devices.update.return_value = True - mock_client.dcim.devices = mock_devices - - mock_tenants = MagicMock() - mock_tenants.get.return_value = None - mock_tenants.delete.return_value = True - mock_client.tenancy.tenants = mock_tenants - - mocker.patch( - "understack_workflows.main.sync_keystone.pynautobot.api", - return_value=mock_client, - ) - - return mock_client - - -@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"), - ), - ( - [ - "identity.project.created", - lf("project_id"), - ], - nullcontext(), - lf("project_id"), - ), - ( - ["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 - - -def test_create_project( - os_conn, - mock_pynautobot_api, - project_id: uuid.UUID, - domain_id: uuid.UUID, -): - ret = do_action(os_conn, mock_pynautobot_api, Event.ProjectCreate, project_id) - os_conn.identity.get_project.assert_any_call(project_id.hex) - os_conn.identity.get_domain.assert_any_call(domain_id.hex) - assert ret == 0 - - -def test_update_project( - os_conn, - mock_pynautobot_api, - project_id: uuid.UUID, - domain_id: uuid.UUID, -): - ret = do_action(os_conn, mock_pynautobot_api, Event.ProjectUpdate, project_id) - os_conn.identity.get_project.assert_any_call(project_id.hex) - os_conn.identity.get_domain.assert_any_call(domain_id.hex) - assert ret == 0 - - -def test_update_project_domain_skipped( - os_conn, - mock_pynautobot_api, - domain_id: uuid.UUID, -): - """Test that domains are skipped during update events.""" - ret = do_action(os_conn, mock_pynautobot_api, Event.ProjectUpdate, domain_id) - # Should fetch the project to check if it's a domain - os_conn.identity.get_project.assert_called_once_with(domain_id.hex) - # Should NOT call get_domain or create/update tenant since it's a domain - os_conn.identity.get_domain.assert_not_called() - mock_pynautobot_api.tenancy.tenants.get.assert_not_called() - mock_pynautobot_api.tenancy.tenants.create.assert_not_called() - assert ret == 0 - - -def test_delete_project( - os_conn, - mock_pynautobot_api, - project_id: uuid.UUID, -): - ret = do_action(os_conn, mock_pynautobot_api, Event.ProjectDelete, project_id) - assert ret == 0 - - -@pytest.mark.parametrize( - "tenant_exists, expect_delete_call, expect_unmap_call", - [ - (False, False, False), # Tenant does NOT exist - (True, True, True), # Tenant exists - ], -) -def test_handle_project_delete( - mocker, mock_pynautobot_api, tenant_exists, expect_delete_call, expect_unmap_call -): - project_id = uuid.uuid4() - - tenant_obj = MagicMock() - mock_pynautobot_api.tenancy.tenants.get.return_value = ( - tenant_obj if tenant_exists else None - ) - - mock_unmap_devices = mocker.patch( - "understack_workflows.main.sync_keystone._unmap_tenant_from_devices" - ) - conn_mock: Connection = MagicMock(spec=Connection) - ret = handle_project_delete(conn_mock, mock_pynautobot_api, project_id) - - assert ret == 0 - mock_pynautobot_api.tenancy.tenants.get.assert_called_once_with(id=project_id) - - if tenant_exists: - mock_unmap_devices.assert_called_once_with( - tenant_id=project_id, nautobot=mock_pynautobot_api - ) - tenant_obj.delete.assert_called_once() - else: - mock_unmap_devices.assert_not_called() - tenant_obj.delete.assert_not_called() diff --git a/python/understack-workflows/understack_workflows/keystone_project_reconciler.py b/python/understack-workflows/understack_workflows/keystone_project_reconciler.py new file mode 100644 index 000000000..6090a2555 --- /dev/null +++ b/python/understack-workflows/understack_workflows/keystone_project_reconciler.py @@ -0,0 +1,322 @@ +"""Prototype planner for Nautobot -> Keystone project reconciliation. + +This module intentionally focuses on deterministic plan generation. +Live API/DB apply logic is added in later iterations. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + + +class ActionType(StrEnum): + CREATE = "create" + UPDATE = "update" + DISABLE = "disable" + QUARANTINE = "quarantine" + + +@dataclass(frozen=True) +class NautobotTenant: + id: str + name: str + tenant_group: str | None + description: str + tags: frozenset[str] + + +@dataclass(frozen=True) +class KeystoneProject: + id: str + name: str + domain: str + description: str + enabled: bool + tags: frozenset[str] + + +@dataclass(frozen=True) +class DesiredProject: + id: str + domain: str + name: str + description: str + enabled: bool + tags: frozenset[str] + + +@dataclass(frozen=True) +class ReconcilerConfig: + excluded_domains: frozenset[str] + disabled_control_tag: str + conflict_tag: str + max_disable_abs: int + max_disable_pct: float + + +@dataclass(frozen=True) +class Action: + action_type: ActionType + project_id: str + domain: str + name: str + details: dict[str, str] + + +@dataclass +class PlanResult: + source_complete: bool + blocked: bool + block_reason: str | None + actions: list[Action] + validation_errors: list[str] + + def count(self, action_type: ActionType) -> int: + return sum(1 for action in self.actions if action.action_type == action_type) + + +def parse_tenant_project_name( + tenant_name: str, tenant_group_name: str +) -> tuple[str, str] | None: + """Parse tenant name format ":". + + Returns tuple(domain, project_name) on success, otherwise None. + """ + parts = tenant_name.split(":", 1) + if len(parts) != 2: + return None + domain_from_name, project_name = parts + if domain_from_name != tenant_group_name: + return None + if not project_name or ":" in project_name: + return None + return domain_from_name, project_name + + +def desired_project_from_tenant( + tenant: NautobotTenant, + disabled_control_tag: str, +) -> tuple[DesiredProject | None, str | None]: + if not tenant.tenant_group: + return None, f"tenant {tenant.id} missing tenant_group" + + parsed = parse_tenant_project_name(tenant.name, tenant.tenant_group) + if parsed is None: + return ( + None, + ( + f"tenant {tenant.id} invalid name format '{tenant.name}', expected " + f"'{tenant.tenant_group}:'" + ), + ) + domain, project_name = parsed + + enabled = disabled_control_tag not in tenant.tags + desired_tags = frozenset(tag for tag in tenant.tags if tag != disabled_control_tag) + return ( + DesiredProject( + id=tenant.id, + domain=domain, + name=project_name, + description=tenant.description or "", + enabled=enabled, + tags=desired_tags, + ), + None, + ) + + +def _short_id(project_id: str, length: int = 8) -> str: + cleaned = project_id.replace("-", "") + return cleaned[:length] + + +def _should_disable_unknown( + project: KeystoneProject, managed_domains: set[str] +) -> bool: + if project.domain not in managed_domains: + return False + return project.enabled + + +def _add_update_actions( + actions: list[Action], desired: DesiredProject, actual: KeystoneProject +): + details: dict[str, str] = {} + if actual.name != desired.name: + details["name"] = desired.name + if (actual.description or "") != (desired.description or ""): + details["description"] = desired.description + if actual.enabled != desired.enabled: + details["enabled"] = str(desired.enabled) + if actual.tags != desired.tags: + details["tags"] = ",".join(sorted(desired.tags)) + + if details: + actions.append( + Action( + action_type=ActionType.UPDATE, + project_id=desired.id, + domain=desired.domain, + name=desired.name, + details=details, + ) + ) + + +def _evaluate_breaker( + actions: list[Action], + managed_project_count: int, + max_disable_abs: int, + max_disable_pct: float, +) -> tuple[bool, str | None]: + disable_count = sum( + 1 + for action in actions + if action.action_type in (ActionType.DISABLE, ActionType.QUARANTINE) + ) + if disable_count > max_disable_abs: + return ( + True, + ( + "breaker tripped: disables " + f"{disable_count} exceed max_disable_abs {max_disable_abs}" + ), + ) + + if managed_project_count <= 0: + return False, None + + disable_pct = disable_count / managed_project_count + if disable_pct > max_disable_pct: + return ( + True, + ( + "breaker tripped: disable ratio " + f"{disable_pct:.3f} exceeds max_disable_pct {max_disable_pct:.3f}" + ), + ) + + return False, None + + +def build_reconcile_plan( + nautobot_tenants: list[NautobotTenant], + keystone_projects: list[KeystoneProject], + source_complete: bool, + config: ReconcilerConfig, +) -> PlanResult: + if not source_complete: + return PlanResult( + source_complete=False, + blocked=True, + block_reason="source index incomplete; all writes blocked for this cycle", + actions=[], + validation_errors=[], + ) + + actions: list[Action] = [] + validation_errors: list[str] = [] + desired_by_id: dict[str, DesiredProject] = {} + desired_by_domain_name: dict[tuple[str, str], DesiredProject] = {} + + for tenant in nautobot_tenants: + desired, error = desired_project_from_tenant( + tenant, config.disabled_control_tag + ) + if error: + validation_errors.append(error) + continue + if desired is None: + # Defensive guard: keep planner deterministic if parser contract changes. + validation_errors.append( + f"tenant {tenant.id} produced no desired project and no error" + ) + continue + desired_by_id[desired.id] = desired + desired_by_domain_name[(desired.domain, desired.name)] = desired + + actual_by_id = {project.id: project for project in keystone_projects} + actual_by_domain_name = {(p.domain, p.name): p for p in keystone_projects} + + managed_domains = { + desired.domain + for desired in desired_by_id.values() + if desired.domain not in config.excluded_domains + } + + # Create/update path for desired projects. + for desired in desired_by_id.values(): + actual = actual_by_id.get(desired.id) + if actual: + if actual.domain != desired.domain: + validation_errors.append( + f"domain immutability violation for {desired.id}: " + f"keystone={actual.domain} nautobot={desired.domain}" + ) + continue + _add_update_actions(actions, desired, actual) + continue + + conflict = actual_by_domain_name.get((desired.domain, desired.name)) + if conflict and conflict.id != desired.id: + quarantine_name = f"{conflict.name}:INVALID:{_short_id(conflict.id)}" + quarantine_tags = sorted(set(conflict.tags) | {config.conflict_tag}) + actions.append( + Action( + action_type=ActionType.QUARANTINE, + project_id=conflict.id, + domain=conflict.domain, + name=quarantine_name, + details={ + "enabled": "False", + "tags": ",".join(quarantine_tags), + }, + ) + ) + + actions.append( + Action( + action_type=ActionType.CREATE, + project_id=desired.id, + domain=desired.domain, + name=desired.name, + details={ + "description": desired.description, + "enabled": str(desired.enabled), + "tags": ",".join(sorted(desired.tags)), + }, + ) + ) + + # Disable unknown projects in managed domains. + for actual in keystone_projects: + if actual.id in desired_by_id: + continue + if not _should_disable_unknown(actual, managed_domains): + continue + actions.append( + Action( + action_type=ActionType.DISABLE, + project_id=actual.id, + domain=actual.domain, + name=actual.name, + details={"enabled": "False"}, + ) + ) + + breaker_blocked, breaker_reason = _evaluate_breaker( + actions=actions, + managed_project_count=len(desired_by_id), + max_disable_abs=config.max_disable_abs, + max_disable_pct=config.max_disable_pct, + ) + + return PlanResult( + source_complete=True, + blocked=breaker_blocked, + block_reason=breaker_reason, + actions=actions, + validation_errors=validation_errors, + ) diff --git a/python/understack-workflows/understack_workflows/main/keystone_project_reconciler.py b/python/understack-workflows/understack_workflows/main/keystone_project_reconciler.py new file mode 100644 index 000000000..a5b2e4ed8 --- /dev/null +++ b/python/understack-workflows/understack_workflows/main/keystone_project_reconciler.py @@ -0,0 +1,176 @@ +import argparse +import json +import logging +import pathlib +import sys +from typing import Any + +from understack_workflows.helpers import setup_logger +from understack_workflows.keystone_project_reconciler import ActionType +from understack_workflows.keystone_project_reconciler import KeystoneProject +from understack_workflows.keystone_project_reconciler import NautobotTenant +from understack_workflows.keystone_project_reconciler import ReconcilerConfig +from understack_workflows.keystone_project_reconciler import build_reconcile_plan + +logger = logging.getLogger(__name__) + + +_EXIT_SUCCESS = 0 +_EXIT_BLOCKED = 2 +_EXIT_INPUT_ERROR = 3 + + +def argument_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Prototype Nautobot -> Keystone reconcile planner" + ) + parser.add_argument( + "--nautobot-tenants-json", + type=pathlib.Path, + required=True, + help="Path to JSON array of Nautobot tenants", + ) + parser.add_argument( + "--keystone-projects-json", + type=pathlib.Path, + required=True, + help="Path to JSON array of Keystone projects", + ) + parser.add_argument( + "--source-complete", + action=argparse.BooleanOptionalAction, + default=True, + help="Whether this cycle has a complete source index pull", + ) + parser.add_argument( + "--max-disable-abs", + type=int, + default=10, + help="Circuit breaker absolute disable threshold", + ) + parser.add_argument( + "--max-disable-pct", + type=float, + default=0.05, + help="Circuit breaker disable ratio threshold", + ) + parser.add_argument( + "--disabled-control-tag", + type=str, + default="disabled", + help="Nautobot control-only tag used to disable Keystone project", + ) + parser.add_argument( + "--conflict-tag", + type=str, + default="UNDERSTACK_ID_CONFLICT", + help="Tag applied to quarantined conflicting Keystone projects", + ) + parser.add_argument( + "--excluded-domain", + action="append", + default=["service", "infra"], + help="Domain excluded from disable/quarantine sweep (repeatable)", + ) + return parser + + +def _read_json_array(path: pathlib.Path) -> list[dict[str, Any]]: + try: + with path.open() as f: + data = json.load(f) + except Exception as exc: # pragma: no cover - covered by main integration path + raise ValueError(f"failed to read JSON file {path}: {exc}") from exc + + if not isinstance(data, list): + raise ValueError(f"{path} must contain a JSON array") + for idx, item in enumerate(data): + if not isinstance(item, dict): + raise ValueError(f"{path} item #{idx} must be a JSON object") + return data + + +def _as_tenant(item: dict[str, Any]) -> NautobotTenant: + return NautobotTenant( + id=str(item["id"]), + name=str(item["name"]), + tenant_group=( + str(item["tenant_group"]) if item.get("tenant_group") is not None else None + ), + description=str(item.get("description") or ""), + tags=frozenset(str(tag) for tag in item.get("tags", [])), + ) + + +def _as_project(item: dict[str, Any]) -> KeystoneProject: + return KeystoneProject( + id=str(item["id"]), + name=str(item["name"]), + domain=str(item["domain"]), + description=str(item.get("description") or ""), + enabled=bool(item.get("enabled", True)), + tags=frozenset(str(tag) for tag in item.get("tags", [])), + ) + + +def _serialize_plan(plan_result) -> dict[str, Any]: + return { + "source_complete": plan_result.source_complete, + "blocked": plan_result.blocked, + "block_reason": plan_result.block_reason, + "summary": { + "create": plan_result.count(ActionType.CREATE), + "update": plan_result.count(ActionType.UPDATE), + "disable": plan_result.count(ActionType.DISABLE), + "quarantine": plan_result.count(ActionType.QUARANTINE), + "validation_errors": len(plan_result.validation_errors), + }, + "validation_errors": plan_result.validation_errors, + "actions": [ + { + "action_type": action.action_type.value, + "project_id": action.project_id, + "domain": action.domain, + "name": action.name, + "details": action.details, + } + for action in plan_result.actions + ], + } + + +def main() -> int: + setup_logger(level=logging.INFO) + args = argument_parser().parse_args() + + try: + tenants_raw = _read_json_array(args.nautobot_tenants_json) + projects_raw = _read_json_array(args.keystone_projects_json) + tenants = [_as_tenant(item) for item in tenants_raw] + projects = [_as_project(item) for item in projects_raw] + except Exception as exc: + logger.error("input parsing failed: %s", exc) + return _EXIT_INPUT_ERROR + + config = ReconcilerConfig( + excluded_domains=frozenset(args.excluded_domain), + disabled_control_tag=args.disabled_control_tag, + conflict_tag=args.conflict_tag, + max_disable_abs=args.max_disable_abs, + max_disable_pct=args.max_disable_pct, + ) + plan = build_reconcile_plan( + nautobot_tenants=tenants, + keystone_projects=projects, + source_complete=args.source_complete, + config=config, + ) + + print(json.dumps(_serialize_plan(plan), indent=2, sort_keys=True)) + if plan.blocked: + return _EXIT_BLOCKED + return _EXIT_SUCCESS + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/python/understack-workflows/understack_workflows/main/sync_keystone.py b/python/understack-workflows/understack_workflows/main/sync_keystone.py deleted file mode 100644 index 6ab7bfcc4..000000000 --- a/python/understack-workflows/understack_workflows/main/sync_keystone.py +++ /dev/null @@ -1,206 +0,0 @@ -import argparse -import logging -import uuid -from collections.abc import Sequence -from enum import StrEnum -from typing import cast - -import pynautobot -from openstack.identity.v3.project import Project -from pynautobot.core.response import Record - -from understack_workflows.helpers import credential -from understack_workflows.helpers import parser_nautobot_args -from understack_workflows.helpers import setup_logger -from understack_workflows.openstack.client import Connection -from understack_workflows.openstack.client import get_openstack_client - -logger = logging.getLogger(__name__) - - -_EXIT_SUCCESS = 0 -_EXIT_API_ERROR = 1 -_EXIT_EVENT_UNKNOWN = 2 - - -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("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 _get_project(conn: Connection, project_id: uuid.UUID) -> Project: - """Fetch a project from OpenStack by UUID.""" - return conn.identity.get_project(project_id.hex) # type: ignore - - -def _is_domain(project: Project) -> bool: - """Check if a project is actually a domain. - - Returns True if the project is a domain, False otherwise. - Domains should not be synced to Nautobot. - - Note: This check is only needed for update events, since Keystone sends - identity.project.updated for both projects AND domains (it sends both - identity.project.updated and identity.domain.updated for domain updates). - For create events, domains only send identity.domain.created. - """ - return getattr(project, "is_domain", False) - - -def _tenant_attrs(conn: Connection, project: Project) -> tuple[str, str]: - domain_id = project.domain_id - - if domain_id == "default": - domain_name = "default" - elif domain_id: - domain = conn.identity.get_domain(domain_id) # type: ignore - domain_name = domain.name - else: - # This shouldn't happen for regular projects - logger.error( - "Project %s has no domain_id. " - "This indicates a malformed project. Using 'unknown' as domain name.", - project.id, - ) - domain_name = "unknown" - - tenant_name = f"{domain_name}:{project.name}" - return tenant_name, str(project.description) - - -def _unmap_tenant_from_devices( - tenant_id: uuid.UUID, - nautobot: pynautobot.api, -): - devices: Sequence[Record] = list(nautobot.dcim.devices.filter(tenant=tenant_id)) - for d in devices: - d.tenant = None # type: ignore[attr-defined] - nautobot.dcim.devices.update(devices) - - -def handle_project_create( - conn: Connection, nautobot: pynautobot.api, project_id: uuid.UUID -) -> int: - logger.info("got request to create tenant %s", project_id.hex) - - project = _get_project(conn, project_id) - tenant_name, tenant_description = _tenant_attrs(conn, project) - - try: - tenant = nautobot.tenancy.tenants.create( - id=str(project_id), name=tenant_name, description=tenant_description - ) - except Exception: - logger.exception( - "Unable to create project %s / %s", str(project_id), tenant_name - ) - return _EXIT_API_ERROR - - logger.info("tenant %s created %s", project_id, tenant.created) # type: ignore - return _EXIT_SUCCESS - - -def handle_project_update( - conn: Connection, nautobot: pynautobot.api, project_id: uuid.UUID -) -> int: - logger.info("got request to update tenant %s", project_id.hex) - - project = _get_project(conn, project_id) - if _is_domain(project): - logger.info( - "Skipping domain %s - domains are not synced to Nautobot", project_id.hex - ) - return _EXIT_SUCCESS - - tenant_name, tenant_description = _tenant_attrs(conn, project) - - existing_tenant = nautobot.tenancy.tenants.get(id=project_id) - logger.info("existing_tenant: %s", existing_tenant) - try: - if existing_tenant is None: - new_tenant = nautobot.tenancy.tenants.create( - id=str(project_id), name=tenant_name, description=tenant_description - ) - logger.info("tenant %s created %s", project_id, new_tenant.created) # type: ignore - else: - existing_tenant = cast(Record, existing_tenant) - existing_tenant.update( - {"name": tenant_name, "description": tenant_description} - ) # type: ignore - logger.info( - "tenant %s last updated %s", - project_id, - existing_tenant.last_updated, # type: ignore - ) - - except Exception: - logger.exception( - "Unable to update project %s / %s", str(project_id), tenant_name - ) - return _EXIT_API_ERROR - return _EXIT_SUCCESS - - -def handle_project_delete( - _: Connection, nautobot: pynautobot.api, project_id: uuid.UUID -) -> int: - logger.info("got request to delete tenant %s", project_id) - tenant = nautobot.tenancy.tenants.get(id=project_id) - if not tenant: - logger.warning( - "tenant %s does not exist in Nautobot, nothing to delete", project_id - ) - return _EXIT_SUCCESS - - _unmap_tenant_from_devices(tenant_id=project_id, nautobot=nautobot) - - tenant = cast(Record, tenant) - tenant.delete() - logger.info("deleted tenant %s", project_id) - return _EXIT_SUCCESS - - -def do_action( - conn: Connection, - nautobot: pynautobot.api, - event: Event, - project_id: uuid.UUID, -) -> int: - match event: - case Event.ProjectCreate: - return handle_project_create(conn, nautobot, project_id) - case Event.ProjectUpdate: - return handle_project_update(conn, nautobot, project_id) - case Event.ProjectDelete: - return handle_project_delete(conn, nautobot, project_id) - - -def main() -> int: - setup_logger(level=logging.INFO) - args = argument_parser().parse_args() - - conn = get_openstack_client(cloud=args.os_cloud) - nb_token = args.nautobot_token or credential("nb-token", "token") - nautobot = pynautobot.api(args.nautobot_url, token=nb_token) - return do_action(conn, nautobot, args.event, args.object) diff --git a/python/understack-workflows/uv.lock b/python/understack-workflows/uv.lock index cf0ad1766..ad47070fd 100644 --- a/python/understack-workflows/uv.lock +++ b/python/understack-workflows/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = "==3.12.*" +requires-python = "==3.11.*" [[package]] name = "annotated-types" diff --git a/workflows/argo-events/kustomization.yaml b/workflows/argo-events/kustomization.yaml index d5040c3f3..9dd40b741 100644 --- a/workflows/argo-events/kustomization.yaml +++ b/workflows/argo-events/kustomization.yaml @@ -13,7 +13,6 @@ resources: - workflowtemplates/nautobot-api.yaml - workflowtemplates/sync-provision-state-to-nautobot.yaml - workflowtemplates/undersync-switch.yaml - - workflowtemplates/keystone-event-project.yaml - workflowtemplates/enroll-server.yaml - workflowtemplates/reclean-server.yaml - workflowtemplates/openstack-oslo-event.yaml diff --git a/workflows/argo-events/workflowtemplates/keystone-event-project.yaml b/workflows/argo-events/workflowtemplates/keystone-event-project.yaml deleted file mode 100644 index 7e3242a18..000000000 --- a/workflows/argo-events/workflowtemplates/keystone-event-project.yaml +++ /dev/null @@ -1,51 +0,0 @@ -apiVersion: argoproj.io/v1alpha1 -metadata: - name: keystone-event-project - annotations: - workflows.argoproj.io/title: CRUD Nautobot Tenants with Keystone Projects - workflows.argoproj.io/description: | - Updates Nautobot with data from a Keystone Project. - - To test this workflow you can run it with the following: - - ``` - argo -n argo-events submit --from workflowtemplate/keystone-event-project \ - -p event_type identity.project.created -p project_uuid=00000000-0000-0000-0000-000000000000 - ``` - - Defined in `workflows/argo-events/workflowtemplates/keystone-event-project.yaml` -kind: WorkflowTemplate -spec: - entrypoint: sync-keystone - serviceAccountName: workflow - arguments: - parameters: - - name: event_type - - name: project_uuid - templates: - - name: sync-keystone - inputs: - parameters: - - name: event_type - - name: project_uuid - container: - image: ghcr.io/rackerlabs/understack/ironic-nautobot-client:latest - command: - - sync-keystone - args: - - "{{inputs.parameters.event_type}}" - - "{{inputs.parameters.project_uuid}}" - volumeMounts: - - mountPath: /etc/nb-token/ - name: nb-token - readOnly: true - - mountPath: /etc/openstack - name: openstack-svc-acct - readOnly: true - volumes: - - name: nb-token - secret: - secretName: nautobot-token - - name: openstack-svc-acct - secret: - secretName: openstack-svc-acct