Add metadata sync command
This commit is contained in:
parent
450fe0949e
commit
15ed5837b9
1 changed files with 254 additions and 0 deletions
254
src/servala/core/management/commands/sync_billing_metadata.py
Normal file
254
src/servala/core/management/commands/sync_billing_metadata.py
Normal 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"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue