Add metadata sync command

This commit is contained in:
Tobias Kunze 2025-12-04 17:31:37 +01:00 committed by Tobias Brunner
parent 450fe0949e
commit 15ed5837b9
Signed by: tobru
SSH key fingerprint: SHA256:kOXg1R6c11XW3/Pt9dbLdQvOJGFAy+B2K6v6PtRWBGQ

View file

@ -0,0 +1,254 @@
import kubernetes
from django.core.management.base import BaseCommand
from django_scopes import scopes_disabled
from servala.core.models import ControlPlane, Organization, ServiceInstance
class Command(BaseCommand):
help = (
"Sync billing labels and annotations to organization namespaces "
"and service instances on all or selected control planes."
)
def add_arguments(self, parser):
parser.add_argument(
"--control-plane",
type=int,
action="append",
dest="control_plane_ids",
help="Control plane ID to sync (can be specified multiple times). "
"If not specified, syncs all control planes.",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be changed without making actual changes.",
)
parser.add_argument(
"--namespaces-only",
action="store_true",
help="Only sync organization namespace labels/annotations, skip service instances.",
)
parser.add_argument(
"--instances-only",
action="store_true",
help="Only sync service instance annotations, skip namespaces.",
)
@scopes_disabled()
def handle(self, *args, **options):
control_plane_ids = options.get("control_plane_ids")
dry_run = options.get("dry_run", False)
namespaces_only = options.get("namespaces_only", False)
instances_only = options.get("instances_only", False)
if namespaces_only and instances_only:
self.stdout.write(
self.style.ERROR(
"Cannot use both --namespaces-only and --instances-only"
)
)
return
if dry_run:
self.stdout.write(self.style.WARNING("DRY RUN - no changes will be made"))
if control_plane_ids:
control_planes = ControlPlane.objects.filter(id__in=control_plane_ids)
if not control_planes.exists():
self.stdout.write(
self.style.ERROR("No control planes found with the specified IDs.")
)
return
else:
control_planes = ControlPlane.objects.all()
self.stdout.write(
f"Syncing billing metadata on {control_planes.count()} control plane(s)..."
)
for control_plane in control_planes:
self.stdout.write(
f"\nControl Plane: {control_plane.name} (ID: {control_plane.pk})"
)
if not instances_only:
self._sync_namespaces(control_plane, dry_run)
if not namespaces_only:
self._sync_instances(control_plane, dry_run)
self.stdout.write(self.style.SUCCESS("\nSync completed."))
def _build_namespace_metadata(self, organization):
labels = {
"servala.com/organization_id": str(organization.id),
}
annotations = {
"servala.com/organization": organization.name,
"servala.com/origin": organization.origin.name,
}
if organization.billing_entity:
annotations["servala.com/billing"] = organization.billing_entity.name
for field in ("company_id", "invoice_id"):
if value := getattr(organization.billing_entity, f"odoo_{field}"):
labels[f"servala.com/erp_{field}"] = str(value)
if organization.odoo_sale_order_id:
labels["servala.com/erp_sale_order_id"] = str(
organization.odoo_sale_order_id
)
return labels, annotations
def _sync_namespaces(self, control_plane, dry_run):
self.stdout.write(" Syncing organization namespaces...")
try:
api_instance = kubernetes.client.CoreV1Api(
control_plane.get_kubernetes_client()
)
except Exception as e:
self.stdout.write(
self.style.ERROR(f" Failed to connect to control plane: {e}")
)
return
organizations = Organization.objects.select_related(
"origin", "billing_entity"
).all()
synced = 0
skipped = 0
errors = 0
for org in organizations:
if not org.namespace:
continue
try:
try:
api_instance.read_namespace(name=org.namespace)
except kubernetes.client.ApiException as e:
if e.status == 404:
skipped += 1
continue
raise
labels, annotations = self._build_namespace_metadata(org)
if dry_run:
self.stdout.write(
f" [DRY RUN] Would update namespace {org.namespace}"
)
self.stdout.write(f" Labels: {labels}")
self.stdout.write(f" Annotations: {annotations}")
else:
body = {
"metadata": {
"labels": labels,
"annotations": annotations,
}
}
api_instance.patch_namespace(name=org.namespace, body=body)
self.stdout.write(f" Updated namespace {org.namespace}")
synced += 1
except Exception as e:
self.stdout.write(
self.style.ERROR(
f" Error syncing namespace {org.namespace}: {e}"
)
)
errors += 1
self.stdout.write(
f" Namespaces: {synced} synced, {skipped} skipped (not found), {errors} errors"
)
def _sync_instances(self, control_plane, dry_run):
self.stdout.write(" Syncing service instance annotations...")
instances = ServiceInstance.objects.filter(
context__control_plane=control_plane
).select_related(
"organization",
"organization__origin",
"context",
"context__control_plane",
"context__control_plane__cloud_provider",
"context__service_offering",
"context__service_offering__service",
"compute_plan_assignment",
)
synced = 0
skipped = 0
errors = 0
for instance in instances:
try:
annotations = ServiceInstance._build_billing_annotations(
compute_plan_assignment=instance.compute_plan_assignment,
control_plane=instance.context.control_plane,
instance_name=instance.name,
organization=instance.organization,
service=instance.context.service_offering.service,
)
if not annotations:
skipped += 1
continue
if dry_run:
self.stdout.write(
f" [DRY RUN] Would update instance {instance.name} "
f"(org: {instance.organization.name})"
)
self.stdout.write(f" Annotations: {annotations}")
else:
api_instance = instance.context.control_plane.custom_objects_api
patch_body = {"metadata": {"annotations": annotations}}
try:
api_instance.patch_namespaced_custom_object(
group=instance.context.group,
version=instance.context.version,
namespace=instance.organization.namespace,
plural=instance.context.kind_plural,
name=instance.name,
body=patch_body,
)
self.stdout.write(
f" Updated instance {instance.name} "
f"(org: {instance.organization.name})"
)
except kubernetes.client.ApiException as e:
if e.status == 404:
self.stdout.write(
self.style.WARNING(
f" Instance {instance.name} not found in Kubernetes "
f"(org: {instance.organization.name})"
)
)
skipped += 1
continue
raise
synced += 1
except Exception as e:
self.stdout.write(
self.style.ERROR(
f" Error syncing instance {instance.name} "
f"(org: {instance.organization.name}): {e}"
)
)
errors += 1
self.stdout.write(
f" Instances: {synced} synced, {skipped} skipped, {errors} errors"
)