From 94767d1434d866903afbc0d05c114b738e3cca8c Mon Sep 17 00:00:00 2001 From: Doug Goldstein Date: Mon, 23 Feb 2026 12:01:30 -0600 Subject: [PATCH 1/2] feat: Utilize Nautobot as the Project Source of Truth To facilitate having multiple Keystones with the same Project IDs, use Nautobot as the global source of truth for projects that way the IDs can stay consistent in all the environments. --- .../keystone-project-reconciliation.md | 213 ++++++++++++ .../keystone-project-reconciler.md | 165 +++++++++ mkdocs.yml | 2 + python/understack-workflows/pyproject.toml | 1 + .../tests/test_keystone_project_reconciler.py | 187 ++++++++++ .../keystone_project_reconciler.py | 322 ++++++++++++++++++ .../main/keystone_project_reconciler.py | 176 ++++++++++ python/understack-workflows/uv.lock | 2 +- 8 files changed, 1067 insertions(+), 1 deletion(-) create mode 100644 docs/design-guide/keystone-project-reconciliation.md create mode 100644 docs/operator-guide/keystone-project-reconciler.md create mode 100644 python/understack-workflows/tests/test_keystone_project_reconciler.py create mode 100644 python/understack-workflows/understack_workflows/keystone_project_reconciler.py create mode 100644 python/understack-workflows/understack_workflows/main/keystone_project_reconciler.py 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/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/pyproject.toml b/python/understack-workflows/pyproject.toml index 90b4b000a..13e8c0a60 100644 --- a/python/understack-workflows/pyproject.toml +++ b/python/understack-workflows/pyproject.toml @@ -40,6 +40,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/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/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" From 88e8e00a207e31433422fa6d2ce521e819dbe6d2 Mon Sep 17 00:00:00 2001 From: Doug Goldstein Date: Mon, 23 Feb 2026 12:10:39 -0600 Subject: [PATCH 2/2] chore: remove the old sync-keystone to push projects to Nautobot Now that we are syncing projects from Nautobot, we don't want to push them up to Nautobot from Keystone so remove that machinery. --- components/site-workflows/kustomization.yaml | 1 - .../sensor-keystone-event-project.yaml | 81 ------- docs/operator-guide/workflows.md | 9 +- python/understack-workflows/README.md | 6 - python/understack-workflows/pyproject.toml | 1 - .../tests/test_sync_keystone.py | 152 ------------- .../main/sync_keystone.py | 206 ------------------ workflows/argo-events/kustomization.yaml | 1 - .../keystone-event-project.yaml | 51 ----- 9 files changed, 2 insertions(+), 506 deletions(-) delete mode 100644 components/site-workflows/sensors/sensor-keystone-event-project.yaml delete mode 100644 python/understack-workflows/tests/test_sync_keystone.py delete mode 100644 python/understack-workflows/understack_workflows/main/sync_keystone.py delete mode 100644 workflows/argo-events/workflowtemplates/keystone-event-project.yaml 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/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/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 13e8c0a60..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" 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/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/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