Two persistent fixes flagged from a Codex session:
1. ``archcenter_pattern_lookup`` ran in 78 s on WSL2 because:
- ``_cached_assets`` did ``iterdir`` + recursive ``rglob`` over the
whole 113-folder cache for EVERY catalog entry (123x).
- ``_patterns_for`` reloaded ``reference-patterns.yaml`` for every
match (125 reloads = ~5 s).
- Description text from disk was read for every entry, even those
that wouldn't make the top-K.
Now: one-shot scan of the cache dir cached in-memory AND persisted
to ``kb/diagram/assets/archcenter-refs-index.json`` keyed by mtime;
patterns YAML loaded once and indexed by URL; a two-pass scoring
pipeline that only reads description text + cached_assets +
visual_patterns for the top-K candidates instead of every entry.
Result: cold run 14 s (one-time index build), warm run 1.1 s.
2. The drawio renderer had no alias for ``adb_s`` / ``adb_serverless``
/ ``autonomous_database_serverless`` / ``refreshable_clone`` —
Codex's spec used ``type: adb_s`` and the icon never resolved.
Mapped them to ``autonomous_database`` (the canonical 7-cell
stencil shipped by the OCI Toolkit), with ``adb_d`` as fallback.
Same proactive expansion in the drawio side that the PPTX side
already got (oci_goldengate, dynamic_routing_gateway, atp/adw,
kms/secret, identity/iam, iac/terraform, oac/oic, kafka).
The persisted lookup index is committed so a fresh clone hits warm
performance on first ``make diagram-lookup``.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3214 lines
146 KiB
Python
3214 lines
146 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
OCI Architecture Diagram Generator — Hybrid drawpyo + Custom Icon Injection
|
|
Produces .drawio files with Oracle official container styles and OCI Library icons.
|
|
|
|
HYBRID APPROACH:
|
|
- drawpyo handles: File, Page, Object (containers/groups), Edge (connections), save
|
|
- Custom icon extractor (kb/diagram/oci-icons.json) handles: multi-cell OCI stencil icons
|
|
injected as raw XML into drawpyo's output
|
|
|
|
Styles extracted from:
|
|
- OCI Library.xml — 224 official OCI service icons
|
|
- OCI Architecture Diagram Toolkit v24.2.drawio
|
|
- Reference architectures: select-ai-apex, exadb-dr-on-db-at-azure
|
|
|
|
Usage:
|
|
python3.12 oci_diagram_gen.py --spec architecture-spec.yaml --output diagram.drawio
|
|
|
|
Or import and use programmatically:
|
|
from oci_diagram_gen import OCIDiagramGenerator
|
|
gen = OCIDiagramGenerator()
|
|
gen.add_region("region1", "US East (Ashburn)", parent="tenancy", ...)
|
|
gen.save("output.drawio")
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import yaml
|
|
import argparse
|
|
import sys
|
|
import xml.etree.ElementTree as ET
|
|
from typing import Optional
|
|
|
|
import drawpyo
|
|
import drawpyo.diagram
|
|
|
|
# ============================================================
|
|
# OCI OFFICIAL STYLES (from OCI Style Guide for Draw.io v24.2)
|
|
# ============================================================
|
|
#
|
|
# Color palette (no #FFFFFF — use #FCFBFA near-white instead):
|
|
# Charcoal #312D2A — text, connectors
|
|
# Warm Gray #9E9892 — container borders
|
|
# Light #F5F4F2 — region/subnet fill
|
|
# Medium #E4E1DD — AD fill
|
|
# Near-White#FCFBFA — background elements
|
|
# Teal #2D5967 — infrastructure services
|
|
# Copper #AA643B — database services (NOT #AE562C)
|
|
# Burnt-Org #AE562C — VCN/subnet borders ONLY
|
|
# Purple #804998 — integration services
|
|
# Muted #70665E — legacy, external actors
|
|
|
|
STYLES = {
|
|
# --- Container styles (OCI Architecture Diagram Toolkit v24.2) ---
|
|
"tenancy": (
|
|
"whiteSpace=wrap;html=1;strokeWidth=1;dashed=1;dashPattern=6 4;align=left;"
|
|
"fontFamily=Oracle Sans;verticalAlign=top;fillColor=none;"
|
|
"fontColor=#312D2A;strokeColor=#9E9892;fontSize=13;fontStyle=1;"
|
|
"spacingLeft=10;spacingTop=8;rounded=1;arcSize=1;"
|
|
),
|
|
"region": (
|
|
"whiteSpace=wrap;html=1;align=left;fontFamily=Oracle Sans;"
|
|
"verticalAlign=top;fillColor=#F5F4F2;rounded=1;arcSize=5;"
|
|
"strokeColor=#9E9892;fontColor=#312D2A;fontSize=13;fontStyle=1;"
|
|
"spacingLeft=10;spacingTop=8;"
|
|
),
|
|
"ad": (
|
|
"whiteSpace=wrap;html=1;strokeWidth=1;align=center;"
|
|
"fontFamily=Oracle Sans;verticalAlign=top;fillColor=#DFDCD8;"
|
|
"fontColor=#312D2A;strokeColor=#9E9892;rounded=1;arcSize=1;"
|
|
"fontSize=12;fontStyle=1;"
|
|
),
|
|
"fault_domain": (
|
|
"whiteSpace=wrap;html=1;strokeWidth=1;align=center;"
|
|
"fontFamily=Oracle Sans;verticalAlign=top;fillColor=#FCFBFA;"
|
|
"fontColor=#312D2A;strokeColor=#9E9892;rounded=1;arcSize=3;"
|
|
"fontSize=11;"
|
|
),
|
|
"vcn": (
|
|
"whiteSpace=wrap;html=1;strokeWidth=2;dashed=1;dashPattern=4 2;align=left;"
|
|
"fontFamily=Oracle Sans;verticalAlign=top;fillColor=none;"
|
|
"fontColor=#aa643b;strokeColor=#aa643b;perimeterSpacing=0;"
|
|
"fontSize=12;fontStyle=1;spacingLeft=8;spacingTop=6;"
|
|
),
|
|
"subnet": (
|
|
"whiteSpace=wrap;html=1;strokeWidth=2;dashed=1;dashPattern=4 2;align=left;"
|
|
"fontFamily=Oracle Sans;verticalAlign=top;fillColor=none;"
|
|
"fontColor=#aa643b;strokeColor=#aa643b;fontSize=11;fontStyle=1;"
|
|
"spacingLeft=8;spacingTop=4;"
|
|
),
|
|
"compartment": (
|
|
"whiteSpace=wrap;html=1;strokeWidth=1;dashed=1;dashPattern=6 4;align=left;"
|
|
"fontFamily=Oracle Sans;verticalAlign=top;fillColor=none;"
|
|
"fontColor=#312D2A;strokeColor=#9E9892;fontSize=12;fontStyle=1;"
|
|
"spacingLeft=5;spacingTop=5;rounded=1;arcSize=1;"
|
|
),
|
|
# --- External cloud containers (AWS, Azure, GCP) ---
|
|
# Generic / AWS: dashed muted border (Oracle style for non-Azure clouds)
|
|
"cloud_container": (
|
|
"whiteSpace=wrap;html=1;strokeWidth=2;dashed=1;dashPattern=6 4;align=left;"
|
|
"fontFamily=Oracle Sans;verticalAlign=top;fillColor=none;"
|
|
"fontColor=#70665E;strokeColor=#70665E;fontSize=12;spacingLeft=5;"
|
|
"spacingTop=5;rounded=1;arcSize=6;"
|
|
),
|
|
# Azure: solid blue border + light blue fill (matches Oracle ref arch style)
|
|
# Source: confirmed from 4 Oracle reference architecture diagrams (exadb-dr, adb-azure, etc.)
|
|
"azure_container": (
|
|
"whiteSpace=wrap;html=1;strokeWidth=2;align=left;"
|
|
"fontFamily=Oracle Sans;verticalAlign=top;fillColor=#EBF5FF;"
|
|
"fontColor=#0050A2;strokeColor=#0078D4;fontSize=12;fontStyle=1;"
|
|
"spacingLeft=5;spacingTop=5;rounded=1;arcSize=3;"
|
|
),
|
|
# GCP: solid green border + light green fill
|
|
"gcp_container": (
|
|
"whiteSpace=wrap;html=1;strokeWidth=2;align=left;"
|
|
"fontFamily=Oracle Sans;verticalAlign=top;fillColor=#E8F5E9;"
|
|
"fontColor=#1E6B3C;strokeColor=#34A853;fontSize=12;fontStyle=1;"
|
|
"spacingLeft=5;spacingTop=5;rounded=1;arcSize=3;"
|
|
),
|
|
# Oracle Services Network: dashed gray column (right-side managed services panel)
|
|
# Source: confirmed in hub-spoke, OKE, lakehouse Oracle reference architectures
|
|
"oracle_services_network": (
|
|
"whiteSpace=wrap;html=1;strokeWidth=1;dashed=1;dashPattern=6 4;align=left;"
|
|
"fontFamily=Oracle Sans;verticalAlign=top;fillColor=none;"
|
|
"fontColor=#312D2A;strokeColor=#9E9892;fontSize=12;fontStyle=1;"
|
|
"spacingLeft=5;spacingTop=5;rounded=1;arcSize=1;"
|
|
),
|
|
|
|
# --- Service block styles (fallback when no icon available) ---
|
|
# OUTLINED style — Oracle reference archs never use solid-filled rectangles.
|
|
# Fallback uses near-white fill + colored border + colored text to approximate
|
|
# the lightweight look of OCI line-art stencil icons.
|
|
"svc_infra": (
|
|
"rounded=1;whiteSpace=wrap;html=1;fillColor=#FCFBFA;"
|
|
"strokeColor=#2D5967;strokeWidth=1.5;fontColor=#2D5967;fontSize=11;"
|
|
"fontFamily=Oracle Sans;arcSize=8;verticalAlign=middle;align=center;"
|
|
),
|
|
"svc_database": (
|
|
"rounded=1;whiteSpace=wrap;html=1;fillColor=#FCFBFA;"
|
|
"strokeColor=#AA643B;strokeWidth=1.5;fontColor=#AA643B;fontSize=11;"
|
|
"fontFamily=Oracle Sans;arcSize=8;verticalAlign=middle;align=center;"
|
|
),
|
|
"svc_integration": (
|
|
"rounded=1;whiteSpace=wrap;html=1;fillColor=#FCFBFA;"
|
|
"strokeColor=#804998;strokeWidth=1.5;fontColor=#804998;fontSize=11;"
|
|
"fontFamily=Oracle Sans;arcSize=8;verticalAlign=middle;align=center;"
|
|
),
|
|
"svc_dormant": (
|
|
"rounded=1;whiteSpace=wrap;html=1;fillColor=#FCFBFA;"
|
|
"strokeColor=#9E9892;strokeWidth=1;fontColor=#70665E;fontSize=11;"
|
|
"fontFamily=Oracle Sans;arcSize=8;fontStyle=2;verticalAlign=middle;align=center;"
|
|
),
|
|
"svc_legacy": (
|
|
"rounded=1;whiteSpace=wrap;html=1;fillColor=#FCFBFA;"
|
|
"strokeColor=#70665E;strokeWidth=1.5;fontColor=#70665E;fontSize=11;"
|
|
"fontFamily=Oracle Sans;arcSize=8;verticalAlign=middle;align=center;"
|
|
),
|
|
"obs_bar": (
|
|
"rounded=1;whiteSpace=wrap;html=1;fillColor=#2D5967;"
|
|
"strokeColor=none;fontColor=#FCFBFA;fontSize=10;"
|
|
"fontFamily=Oracle Sans;arcSize=10;"
|
|
),
|
|
|
|
# --- External actor: person silhouette (head circle + body arc) ---
|
|
# Rendered as raw SVG in to_xml() post-processing, not as a drawpyo stencil.
|
|
# This style is for the invisible connectable group + label below the icon.
|
|
"external_user": (
|
|
"group;fillColor=none;strokeColor=none;connectable=1;pointerEvents=1;"
|
|
),
|
|
# --- External actor: cloud (internet) ---
|
|
"external_internet": (
|
|
"shape=mxgraph.cisco.clouds.cloud;"
|
|
"sketch=0;fillColor=#2D5967;strokeColor=none;"
|
|
"fontColor=#312D2A;fontSize=12;fontFamily=Oracle Sans;"
|
|
"verticalLabelPosition=bottom;verticalAlign=top;"
|
|
"align=center;html=1;"
|
|
),
|
|
# --- External actor: generic box ---
|
|
"external_generic": (
|
|
"rounded=1;whiteSpace=wrap;html=1;fillColor=#70665E;"
|
|
"strokeColor=none;fontColor=#FCFBFA;fontSize=12;"
|
|
"fontFamily=Oracle Sans;arcSize=12;verticalAlign=middle;align=center;"
|
|
),
|
|
|
|
# --- Flow number badge (gray circle with white number) ---
|
|
"flow_badge": (
|
|
"ellipse;whiteSpace=wrap;html=1;fillColor=#2D5967;"
|
|
"strokeColor=none;fontColor=#FCFBFA;fontSize=10;"
|
|
"fontFamily=Oracle Sans;fontStyle=1;"
|
|
"verticalAlign=middle;align=center;"
|
|
),
|
|
|
|
# --- Title ---
|
|
"title": (
|
|
"text;html=1;fontSize=18;fontColor=#312D2A;"
|
|
"fontFamily=Oracle Sans;align=center;verticalAlign=middle;fontStyle=1;"
|
|
),
|
|
}
|
|
|
|
# Connection style strings — used ONLY for raw XML fallback summary stats.
|
|
# Actual edge styling is done via drawpyo Edge attributes.
|
|
CONN_STYLES = {
|
|
"conn_standard": (
|
|
"endArrow=open;endFill=0;startArrow=none;startFill=0;html=1;"
|
|
"strokeColor=#312D2A;strokeWidth=1;rounded=1;"
|
|
"edgeStyle=orthogonalEdgeStyle;endSize=6;elbow=vertical;"
|
|
"fontFamily=Oracle Sans;fontSize=10;fontColor=#312D2A;"
|
|
"labelBackgroundColor=none;"
|
|
),
|
|
"conn_db": (
|
|
"endArrow=open;endFill=0;startArrow=none;startFill=0;html=1;"
|
|
"strokeColor=#312D2A;strokeWidth=1;rounded=1;"
|
|
"edgeStyle=orthogonalEdgeStyle;endSize=6;elbow=vertical;"
|
|
"fontFamily=Oracle Sans;fontSize=10;fontColor=#312D2A;"
|
|
"labelBackgroundColor=none;"
|
|
),
|
|
"conn_adg": (
|
|
"endArrow=open;endFill=0;startArrow=none;startFill=0;html=1;"
|
|
"strokeColor=#312D2A;strokeWidth=1;rounded=1;"
|
|
"edgeStyle=orthogonalEdgeStyle;endSize=6;elbow=vertical;"
|
|
"dashed=1;dashPattern=8 4;"
|
|
"fontFamily=Oracle Sans;fontSize=10;fontColor=#312D2A;"
|
|
"labelBackgroundColor=none;"
|
|
),
|
|
"conn_fastconnect": (
|
|
"endArrow=open;endFill=0;startArrow=open;startFill=0;html=1;"
|
|
"strokeColor=#312D2A;strokeWidth=1;rounded=1;"
|
|
"edgeStyle=orthogonalEdgeStyle;endSize=6;elbow=vertical;"
|
|
"fontFamily=Oracle Sans;fontSize=10;fontColor=#312D2A;"
|
|
"labelBackgroundColor=none;"
|
|
),
|
|
"conn_migration": (
|
|
"endArrow=open;endFill=0;startArrow=none;startFill=0;html=1;"
|
|
"strokeColor=#312D2A;strokeWidth=1;rounded=1;"
|
|
"edgeStyle=orthogonalEdgeStyle;endSize=6;elbow=vertical;"
|
|
"dashed=1;dashPattern=8 4;"
|
|
"fontFamily=Oracle Sans;fontSize=10;fontColor=#312D2A;"
|
|
"labelBackgroundColor=none;"
|
|
),
|
|
"conn_etl": (
|
|
"endArrow=open;endFill=0;startArrow=none;startFill=0;html=1;"
|
|
"strokeColor=#312D2A;strokeWidth=1;rounded=1;"
|
|
"edgeStyle=orthogonalEdgeStyle;endSize=6;elbow=vertical;"
|
|
"dashed=1;dashPattern=6 4;"
|
|
"fontFamily=Oracle Sans;fontSize=10;fontColor=#312D2A;"
|
|
"labelBackgroundColor=none;"
|
|
),
|
|
}
|
|
|
|
# Map service types to style categories
|
|
SVC_CATEGORY = {
|
|
# Infrastructure (teal #2D5967)
|
|
"compute": "svc_infra", "vm": "svc_infra", "bare_metal": "svc_infra",
|
|
"load_balancer": "svc_infra", "flexible_lb": "svc_infra",
|
|
"network_lb": "svc_infra",
|
|
"igw": "svc_infra", "internet_gateway": "svc_infra",
|
|
"nat_gateway": "svc_infra", "natgw": "svc_infra",
|
|
"service_gateway": "svc_infra", "sgw": "svc_infra",
|
|
"waf": "svc_infra", "bastion": "svc_infra",
|
|
"network_firewall": "svc_infra",
|
|
"functions": "svc_infra", "oke": "svc_infra",
|
|
"api_gateway": "svc_infra", "apigw": "svc_infra",
|
|
"monitoring": "svc_infra", "logging": "svc_infra",
|
|
"db_management": "svc_infra", "ops_insights": "svc_infra",
|
|
"notifications": "svc_infra", "events": "svc_infra",
|
|
"cloud_guard": "svc_infra",
|
|
"security_zone": "svc_infra",
|
|
"vulnerability_scanning": "svc_infra",
|
|
"object_storage": "svc_infra", "block_storage": "svc_infra",
|
|
"file_storage": "svc_infra",
|
|
"dns": "svc_infra", "traffic_management": "svc_infra",
|
|
"certificates": "svc_infra", "apm": "svc_infra",
|
|
"logging_analytics": "svc_infra",
|
|
"os_management": "svc_infra",
|
|
"resource_manager": "svc_infra",
|
|
"devops": "svc_infra",
|
|
# AI Services (teal — OCI AI service category)
|
|
"gen_ai": "svc_infra", "generative_ai": "svc_infra",
|
|
"language_ai": "svc_infra", "vision_ai": "svc_infra",
|
|
"speech_ai": "svc_infra", "document_understanding": "svc_infra",
|
|
"anomaly_detection": "svc_infra", "digital_assistant": "svc_infra",
|
|
|
|
# Database (copper #AA643B)
|
|
"adb": "svc_database", "adb_s": "svc_database",
|
|
"adb_d": "svc_database", "autonomous_db": "svc_database",
|
|
"atp": "svc_database", "adw": "svc_database",
|
|
"dbcs": "svc_database", "db_system": "svc_database",
|
|
"exadata": "svc_database", "exacs": "svc_database",
|
|
"nosql": "svc_database", "mysql": "svc_database",
|
|
"mysql_heatwave": "svc_database",
|
|
"postgresql": "svc_database", "opensearch": "svc_database",
|
|
"cache": "svc_database", "redis": "svc_database",
|
|
"goldengate": "svc_database",
|
|
"data_catalog": "svc_database",
|
|
# Security services that touch data tier use database copper
|
|
"data_safe": "svc_database",
|
|
"vault": "svc_database",
|
|
|
|
# Integration (purple #804998)
|
|
"drg": "svc_integration",
|
|
"dynamic_routing_gateway": "svc_integration",
|
|
"rpc": "svc_integration",
|
|
"remote_peering_connection": "svc_integration",
|
|
"streaming": "svc_integration", "kafka": "svc_integration",
|
|
"queue": "svc_integration", "oci_queue": "svc_integration",
|
|
"oic": "svc_integration", "integration_cloud": "svc_integration",
|
|
"fastconnect": "svc_integration",
|
|
"service_connector_hub": "svc_integration",
|
|
"vpn": "svc_integration",
|
|
|
|
# Special
|
|
"dormant": "svc_dormant",
|
|
"standby": "svc_dormant",
|
|
"legacy": "svc_legacy",
|
|
"external": "svc_legacy",
|
|
}
|
|
|
|
# ============================================================
|
|
# AWS / GCP / AZURE ICON STYLES (mxgraph stencil libraries)
|
|
# ============================================================
|
|
# draw.io includes official cloud provider shape libraries:
|
|
# AWS: mxgraph.aws4.* (orange #ED7100, dark text #232F3E)
|
|
# GCP: mxgraph.gcp2.* (blue #4285F4)
|
|
# Azure: mxgraph.azure.* (blue #0078D4)
|
|
#
|
|
# These are used when a service inside a cloud container specifies
|
|
# a cloud_icon field (e.g., cloud_icon: "aws4.ec2").
|
|
|
|
# AWS service type → (resIcon stencil path, fillColor)
|
|
AWS_ICONS = {
|
|
"ec2": ("mxgraph.aws4.ec2", "#ED7100"),
|
|
"eks": ("mxgraph.aws4.eks", "#ED7100"),
|
|
"ecs": ("mxgraph.aws4.ecs", "#ED7100"),
|
|
"lambda": ("mxgraph.aws4.lambda_function", "#ED7100"),
|
|
"rds": ("mxgraph.aws4.rds", "#C925D1"),
|
|
"aurora": ("mxgraph.aws4.aurora", "#C925D1"),
|
|
"dynamodb": ("mxgraph.aws4.dynamodb", "#C925D1"),
|
|
"s3": ("mxgraph.aws4.s3", "#3F8624"),
|
|
"elb": ("mxgraph.aws4.elb", "#8C4FFF"),
|
|
"alb": ("mxgraph.aws4.application_load_balancer", "#8C4FFF"),
|
|
"cloudfront": ("mxgraph.aws4.cloudfront", "#8C4FFF"),
|
|
"vpc": ("mxgraph.aws4.vpc", "#8C4FFF"),
|
|
"direct_connect":("mxgraph.aws4.direct_connect", "#8C4FFF"),
|
|
"route53": ("mxgraph.aws4.route_53", "#8C4FFF"),
|
|
"sqs": ("mxgraph.aws4.sqs", "#E7157B"),
|
|
"sns": ("mxgraph.aws4.sns", "#E7157B"),
|
|
"kinesis": ("mxgraph.aws4.kinesis", "#8C4FFF"),
|
|
"api_gateway": ("mxgraph.aws4.api_gateway", "#E7157B"),
|
|
"cognito": ("mxgraph.aws4.cognito", "#DD344C"),
|
|
"iam": ("mxgraph.aws4.iam", "#DD344C"),
|
|
"cloudwatch": ("mxgraph.aws4.cloudwatch_2", "#E7157B"),
|
|
"sagemaker": ("mxgraph.aws4.sagemaker", "#01A88D"),
|
|
"bedrock": ("mxgraph.aws4.bedrock", "#01A88D"),
|
|
}
|
|
|
|
# GCP service type → (shape path, fillColor)
|
|
GCP_ICONS = {
|
|
"gce": ("mxgraph.gcp2.compute_engine", "#4285F4"),
|
|
"gke": ("mxgraph.gcp2.kubernetes_engine", "#4285F4"),
|
|
"cloud_sql": ("mxgraph.gcp2.cloud_sql", "#4285F4"),
|
|
"bigquery": ("mxgraph.gcp2.bigquery", "#669DF6"),
|
|
"cloud_storage": ("mxgraph.gcp2.cloud_storage", "#AECBFA"),
|
|
"cloud_run": ("mxgraph.gcp2.cloud_run", "#4285F4"),
|
|
"cloud_functions": ("mxgraph.gcp2.cloud_functions", "#4285F4"),
|
|
"pub_sub": ("mxgraph.gcp2.pub_sub", "#4285F4"),
|
|
"cloud_dns": ("mxgraph.gcp2.cloud_dns", "#4285F4"),
|
|
"cloud_load_balancing": ("mxgraph.gcp2.cloud_load_balancing", "#4285F4"),
|
|
"cloud_cdn": ("mxgraph.gcp2.cloud_cdn", "#4285F4"),
|
|
"cloud_iam": ("mxgraph.gcp2.cloud_iam", "#DD344C"),
|
|
"cloud_armor": ("mxgraph.gcp2.cloud_armor", "#DD344C"),
|
|
"spanner": ("mxgraph.gcp2.cloud_spanner", "#4285F4"),
|
|
"firestore": ("mxgraph.gcp2.firestore", "#4285F4"),
|
|
"memorystore": ("mxgraph.gcp2.memorystore", "#4285F4"),
|
|
"ai_platform": ("mxgraph.gcp2.ai_platform", "#01A88D"),
|
|
"vertex_ai": ("mxgraph.gcp2.vertex_ai", "#01A88D"),
|
|
"dataflow": ("mxgraph.gcp2.dataflow", "#669DF6"),
|
|
"dataproc": ("mxgraph.gcp2.cloud_dataproc", "#669DF6"),
|
|
"composer": ("mxgraph.gcp2.cloud_composer", "#669DF6"),
|
|
}
|
|
|
|
# Azure service type → (resIcon stencil, fillColor).
|
|
# drawio includes mxgraph.azure2.* for the modern Azure shape library;
|
|
# fallback to mxgraph.azure.* on older shape sets. Colors follow the
|
|
# Microsoft Azure brand: blue #0078D4 by default, with category tints.
|
|
AZURE_ICONS = {
|
|
"vm": ("mxgraph.azure2.virtual_machine", "#0078D4"),
|
|
"virtual_machine": ("mxgraph.azure2.virtual_machine", "#0078D4"),
|
|
"vmss": ("mxgraph.azure2.virtual_machine_scale_set", "#0078D4"),
|
|
"aks": ("mxgraph.azure2.kubernetes_services", "#0078D4"),
|
|
"app_service": ("mxgraph.azure2.app_services", "#0078D4"),
|
|
"function": ("mxgraph.azure2.function_apps", "#0078D4"),
|
|
"azure_function": ("mxgraph.azure2.function_apps", "#0078D4"),
|
|
"container_instance":("mxgraph.azure2.container_instances", "#0078D4"),
|
|
"sql_database": ("mxgraph.azure2.sql_database", "#C925D1"),
|
|
"azure_sql": ("mxgraph.azure2.sql_database", "#C925D1"),
|
|
"cosmos_db": ("mxgraph.azure2.cosmos_db", "#C925D1"),
|
|
"blob_storage": ("mxgraph.azure2.storage_accounts", "#3F8624"),
|
|
"storage": ("mxgraph.azure2.storage_accounts", "#3F8624"),
|
|
"data_lake": ("mxgraph.azure2.data_lake_storage_gen2", "#3F8624"),
|
|
"vnet": ("mxgraph.azure2.virtual_networks", "#0078D4"),
|
|
"load_balancer": ("mxgraph.azure2.load_balancers", "#0078D4"),
|
|
"application_gateway":("mxgraph.azure2.application_gateways", "#0078D4"),
|
|
"expressroute": ("mxgraph.azure2.expressroute_circuits", "#0078D4"),
|
|
"vpn_gateway": ("mxgraph.azure2.vpn_gateways", "#0078D4"),
|
|
"azure_dns": ("mxgraph.azure2.dns_zones", "#0078D4"),
|
|
"front_door": ("mxgraph.azure2.front_door_and_cdn_profiles", "#0078D4"),
|
|
"key_vault": ("mxgraph.azure2.key_vaults", "#DD344C"),
|
|
"active_directory": ("mxgraph.azure2.azure_active_directory", "#DD344C"),
|
|
"sentinel": ("mxgraph.azure2.azure_sentinel", "#DD344C"),
|
|
"monitor": ("mxgraph.azure2.monitor", "#E7157B"),
|
|
"log_analytics": ("mxgraph.azure2.log_analytics_workspaces", "#E7157B"),
|
|
"event_grid": ("mxgraph.azure2.event_grid", "#E7157B"),
|
|
"service_bus": ("mxgraph.azure2.service_bus", "#E7157B"),
|
|
"api_management": ("mxgraph.azure2.api_management_services", "#E7157B"),
|
|
"openai": ("mxgraph.azure2.azure_openai", "#01A88D"),
|
|
"ml_studio": ("mxgraph.azure2.machine_learning", "#01A88D"),
|
|
}
|
|
|
|
def _aws_icon_style(service_type: str) -> Optional[str]:
|
|
"""Return draw.io style string for an AWS service icon, or None."""
|
|
entry = AWS_ICONS.get(service_type)
|
|
if not entry:
|
|
return None
|
|
stencil, fill = entry
|
|
return (
|
|
f"outlineConnect=0;fontColor=#232F3E;gradientColor=none;"
|
|
f"fillColor={fill};strokeColor=none;dashed=0;"
|
|
f"verticalLabelPosition=bottom;verticalAlign=top;"
|
|
f"align=center;html=1;fontSize=10;fontStyle=0;"
|
|
f"aspect=fixed;"
|
|
f"shape=mxgraph.aws4.resourceIcon;resIcon={stencil};"
|
|
)
|
|
|
|
def _gcp_icon_style(service_type: str) -> Optional[str]:
|
|
"""Return draw.io style string for a GCP service icon, or None."""
|
|
entry = GCP_ICONS.get(service_type)
|
|
if not entry:
|
|
return None
|
|
stencil, fill = entry
|
|
return (
|
|
f"outlineConnect=0;fontColor=#232F3E;gradientColor=none;"
|
|
f"fillColor={fill};strokeColor=none;dashed=0;"
|
|
f"verticalLabelPosition=bottom;verticalAlign=top;"
|
|
f"align=center;html=1;fontSize=10;fontStyle=0;"
|
|
f"aspect=fixed;"
|
|
f"shape={stencil};"
|
|
)
|
|
|
|
|
|
def _azure_icon_style(service_type: str) -> Optional[str]:
|
|
"""Return draw.io style string for an Azure service icon, or None."""
|
|
entry = AZURE_ICONS.get(service_type)
|
|
if not entry:
|
|
return None
|
|
stencil, fill = entry
|
|
return (
|
|
f"outlineConnect=0;fontColor=#0078D4;gradientColor=none;"
|
|
f"fillColor={fill};strokeColor=none;dashed=0;"
|
|
f"verticalLabelPosition=bottom;verticalAlign=top;"
|
|
f"align=center;html=1;fontSize=10;fontStyle=0;"
|
|
f"aspect=fixed;"
|
|
f"shape={stencil};"
|
|
)
|
|
|
|
|
|
# Cloud-specific container styles. Each cloud has its own visual
|
|
# convention for region/VPC/subnet — using the right one makes a
|
|
# multi-cloud diagram instantly readable. The styles mirror what
|
|
# we have for OCI but with each provider's brand color and dash
|
|
# pattern.
|
|
CLOUD_CONTAINER_STYLES = {
|
|
# ── AWS (per AWS Architecture Icon Guidelines) ──
|
|
"aws_region": (
|
|
"whiteSpace=wrap;html=1;align=left;fontFamily=Helvetica;"
|
|
"verticalAlign=top;fillColor=#F2F4F4;rounded=1;arcSize=4;"
|
|
"strokeColor=#232F3E;fontColor=#232F3E;fontSize=12;fontStyle=1;"
|
|
"spacingLeft=8;spacingTop=5;dashed=1;dashPattern=8 4;"
|
|
),
|
|
"aws_vpc": (
|
|
"whiteSpace=wrap;html=1;strokeWidth=2;dashed=1;align=left;"
|
|
"fontFamily=Helvetica;verticalAlign=top;fillColor=none;"
|
|
"fontColor=#8C4FFF;strokeColor=#8C4FFF;fontSize=12;"
|
|
"spacingLeft=5;dashPattern=8 4;"
|
|
),
|
|
"aws_subnet_public": (
|
|
"whiteSpace=wrap;html=1;strokeWidth=1;dashed=1;align=left;"
|
|
"fontFamily=Helvetica;verticalAlign=top;fillColor=#E9F3E6;"
|
|
"fontColor=#7AA116;strokeColor=#7AA116;fontSize=12;"
|
|
"spacingLeft=5;dashPattern=4 4;"
|
|
),
|
|
"aws_subnet_private": (
|
|
"whiteSpace=wrap;html=1;strokeWidth=1;dashed=1;align=left;"
|
|
"fontFamily=Helvetica;verticalAlign=top;fillColor=#E6F2F8;"
|
|
"fontColor=#00A4A6;strokeColor=#00A4A6;fontSize=12;"
|
|
"spacingLeft=5;dashPattern=4 4;"
|
|
),
|
|
"aws_az": (
|
|
"whiteSpace=wrap;html=1;strokeWidth=1;dashed=1;align=center;"
|
|
"fontFamily=Helvetica;verticalAlign=top;fillColor=#FAFAFA;"
|
|
"fontColor=#545B64;strokeColor=#545B64;fontSize=11;"
|
|
"fontStyle=1;dashPattern=4 4;"
|
|
),
|
|
# ── GCP ──
|
|
"gcp_project": (
|
|
"whiteSpace=wrap;html=1;align=left;fontFamily=Helvetica;"
|
|
"verticalAlign=top;fillColor=#F1F8FF;rounded=1;arcSize=4;"
|
|
"strokeColor=#4285F4;fontColor=#4285F4;fontSize=12;fontStyle=1;"
|
|
"spacingLeft=8;spacingTop=5;"
|
|
),
|
|
"gcp_vpc": (
|
|
"whiteSpace=wrap;html=1;strokeWidth=2;dashed=1;align=left;"
|
|
"fontFamily=Helvetica;verticalAlign=top;fillColor=none;"
|
|
"fontColor=#4285F4;strokeColor=#4285F4;fontSize=12;"
|
|
"spacingLeft=5;dashPattern=8 4;"
|
|
),
|
|
"gcp_subnet": (
|
|
"whiteSpace=wrap;html=1;strokeWidth=1;dashed=1;align=left;"
|
|
"fontFamily=Helvetica;verticalAlign=top;fillColor=#E8F0FE;"
|
|
"fontColor=#669DF6;strokeColor=#669DF6;fontSize=12;"
|
|
"spacingLeft=5;dashPattern=4 4;"
|
|
),
|
|
"gcp_region": (
|
|
"whiteSpace=wrap;html=1;strokeWidth=1;dashed=1;align=center;"
|
|
"fontFamily=Helvetica;verticalAlign=top;fillColor=#FAFAFA;"
|
|
"fontColor=#5F6368;strokeColor=#5F6368;fontSize=11;"
|
|
"fontStyle=1;dashPattern=4 4;"
|
|
),
|
|
# ── Azure ──
|
|
"azure_subscription": (
|
|
"whiteSpace=wrap;html=1;align=left;fontFamily=Segoe UI;"
|
|
"verticalAlign=top;fillColor=#F2F8FD;rounded=1;arcSize=4;"
|
|
"strokeColor=#0078D4;fontColor=#0078D4;fontSize=12;fontStyle=1;"
|
|
"spacingLeft=8;spacingTop=5;"
|
|
),
|
|
"azure_vnet": (
|
|
"whiteSpace=wrap;html=1;strokeWidth=2;dashed=1;align=left;"
|
|
"fontFamily=Segoe UI;verticalAlign=top;fillColor=none;"
|
|
"fontColor=#0078D4;strokeColor=#0078D4;fontSize=12;"
|
|
"spacingLeft=5;dashPattern=8 4;"
|
|
),
|
|
"azure_subnet": (
|
|
"whiteSpace=wrap;html=1;strokeWidth=1;dashed=1;align=left;"
|
|
"fontFamily=Segoe UI;verticalAlign=top;fillColor=#E5F1FB;"
|
|
"fontColor=#0078D4;strokeColor=#0078D4;fontSize=12;"
|
|
"spacingLeft=5;dashPattern=4 4;"
|
|
),
|
|
"azure_region": (
|
|
"whiteSpace=wrap;html=1;strokeWidth=1;dashed=1;align=center;"
|
|
"fontFamily=Segoe UI;verticalAlign=top;fillColor=#FAFAFA;"
|
|
"fontColor=#605E5C;strokeColor=#605E5C;fontSize=11;"
|
|
"fontStyle=1;dashPattern=4 4;"
|
|
),
|
|
}
|
|
|
|
|
|
# Map observability bar labels to icon service types
|
|
OBS_TYPE_MAP = {
|
|
"monitoring": "monitoring",
|
|
"logging": "logging",
|
|
"logging analytics": "logging_analytics",
|
|
"apm": "apm",
|
|
"db management": "db_management",
|
|
"ops insights": "ops_insights",
|
|
"notifications": "notifications",
|
|
"events": "events",
|
|
"auditing": "monitoring", # uses monitoring icon as closest match
|
|
}
|
|
|
|
|
|
def pick(mapping: dict, *keys, default=""):
|
|
"""Return the first non-empty value for any of the provided keys."""
|
|
if not isinstance(mapping, dict):
|
|
return default
|
|
for key in keys:
|
|
if key in mapping:
|
|
value = mapping.get(key)
|
|
if value is not None:
|
|
return value
|
|
return default
|
|
|
|
|
|
def pick_list(mapping: dict, *keys):
|
|
"""Return the first list-like field normalized to a list."""
|
|
if not isinstance(mapping, dict):
|
|
return []
|
|
for key in keys:
|
|
if key in mapping:
|
|
value = mapping.get(key)
|
|
if value is None:
|
|
return []
|
|
if isinstance(value, list):
|
|
return value
|
|
return [value]
|
|
return []
|
|
|
|
|
|
def _slugify(value: str) -> str:
|
|
text = re.sub(r"[^a-z0-9]+", "-", str(value or "").lower()).strip("-")
|
|
return text or "item"
|
|
|
|
|
|
class _SpecIdGenerator:
|
|
"""Generate stable unique ids within one normalized spec."""
|
|
|
|
def __init__(self):
|
|
self.used = set()
|
|
self.counters = {}
|
|
|
|
def make(self, prefix: str, seed: str = "") -> str:
|
|
base = f"{prefix}-{_slugify(seed)}" if seed else prefix
|
|
candidate = base
|
|
idx = 2
|
|
while candidate in self.used:
|
|
candidate = f"{base}-{idx}"
|
|
idx += 1
|
|
self.used.add(candidate)
|
|
return candidate
|
|
|
|
|
|
def _normalize_diagram_spec(spec: dict) -> dict:
|
|
"""Normalize aliases and synthesize ids before rendering."""
|
|
spec = spec or {}
|
|
id_gen = _SpecIdGenerator()
|
|
aliases = {}
|
|
|
|
def remember(node: dict, node_id: str):
|
|
for key in ("id", "name", "label", "title"):
|
|
value = node.get(key)
|
|
if value:
|
|
aliases[str(value)] = node_id
|
|
|
|
def resolve_ref(value):
|
|
if value in (None, ""):
|
|
return None
|
|
return aliases.get(str(value), str(value))
|
|
|
|
def normalize_service(node: dict, prefix: str, default_type: str = "compute") -> dict:
|
|
raw = dict(node or {})
|
|
label = pick(raw, "label", "name", "title")
|
|
node_id = pick(raw, "id", "name") or id_gen.make(prefix, label)
|
|
normalized = dict(raw)
|
|
normalized["id"] = str(node_id)
|
|
normalized["label"] = label or str(node_id)
|
|
normalized["type"] = pick(raw, "type", "service_type", default=default_type)
|
|
remember(raw, normalized["id"])
|
|
return normalized
|
|
|
|
normalized = {
|
|
"title": spec.get("title", ""),
|
|
"banner": spec.get("banner"),
|
|
"callouts": spec.get("callouts") or [],
|
|
"absolute_layout": spec.get("absolute_layout"),
|
|
"external": [],
|
|
"clouds": [],
|
|
"tenancy": {},
|
|
"connections": [],
|
|
}
|
|
|
|
for ext in pick_list(spec, "external"):
|
|
normalized["external"].append(normalize_service(ext, "external"))
|
|
|
|
for cloud in pick_list(spec, "clouds"):
|
|
raw_cloud = dict(cloud or {})
|
|
label = pick(raw_cloud, "label", "name", "title")
|
|
cloud_id = pick(raw_cloud, "id", "name") or id_gen.make("cloud", label)
|
|
normalized_cloud = dict(raw_cloud)
|
|
normalized_cloud["id"] = str(cloud_id)
|
|
normalized_cloud["label"] = label or str(cloud_id)
|
|
normalized_cloud["services"] = [
|
|
normalize_service(svc, "cloud-svc", default_type="compute")
|
|
for svc in pick_list(raw_cloud, "services")
|
|
]
|
|
remember(raw_cloud, normalized_cloud["id"])
|
|
normalized["clouds"].append(normalized_cloud)
|
|
|
|
tenancy_spec = dict(spec.get("tenancy", {}) or {})
|
|
normalized_tenancy = dict(tenancy_spec)
|
|
normalized_tenancy["label"] = pick(tenancy_spec, "label", "name", "title", default="Oracle Cloud Infrastructure")
|
|
normalized_tenancy["regions"] = []
|
|
|
|
for region in pick_list(tenancy_spec, "regions"):
|
|
raw_region = dict(region or {})
|
|
region_label = pick(raw_region, "label", "name", "title")
|
|
region_id = pick(raw_region, "id", "name") or id_gen.make("region", region_label)
|
|
normalized_region = dict(raw_region)
|
|
normalized_region["id"] = str(region_id)
|
|
normalized_region["label"] = region_label or str(region_id)
|
|
normalized_region["availability_domains"] = []
|
|
normalized_region["vcns"] = []
|
|
normalized_region["compartments"] = []
|
|
normalized_region["services"] = [
|
|
normalize_service(svc, "region-svc", default_type="compute")
|
|
for svc in pick_list(raw_region, "services")
|
|
]
|
|
normalized_region["observability"] = pick_list(raw_region, "observability")
|
|
remember(raw_region, normalized_region["id"])
|
|
|
|
for ad in pick_list(raw_region, "availability_domains"):
|
|
raw_ad = dict(ad or {})
|
|
ad_label = pick(raw_ad, "label", "name", "title")
|
|
ad_id = pick(raw_ad, "id", "name") or id_gen.make("ad", ad_label)
|
|
normalized_ad = dict(raw_ad)
|
|
normalized_ad["id"] = str(ad_id)
|
|
normalized_ad["label"] = ad_label or str(ad_id)
|
|
remember(raw_ad, normalized_ad["id"])
|
|
normalized_region["availability_domains"].append(normalized_ad)
|
|
|
|
def normalize_vcn(raw_vcn: dict) -> dict:
|
|
vcn_label = pick(raw_vcn, "label", "name", "title")
|
|
vcn_id = pick(raw_vcn, "id", "name") or id_gen.make("vcn", vcn_label)
|
|
normalized_vcn = dict(raw_vcn)
|
|
normalized_vcn["id"] = str(vcn_id)
|
|
normalized_vcn["label"] = vcn_label or str(vcn_id)
|
|
parent_ref = pick(raw_vcn, "parent", default="")
|
|
if parent_ref:
|
|
normalized_vcn["parent"] = resolve_ref(parent_ref) or str(parent_ref)
|
|
normalized_vcn["gateways"] = [
|
|
normalize_service(gw, "gw", default_type="drg" if pick(gw, "type", "service_type") in ("drg", "dynamic_routing_gateway") else "compute")
|
|
for gw in pick_list(raw_vcn, "gateways")
|
|
]
|
|
normalized_vcn["subnets"] = []
|
|
remember(raw_vcn, normalized_vcn["id"])
|
|
|
|
for subnet in pick_list(raw_vcn, "subnets"):
|
|
raw_subnet = dict(subnet or {})
|
|
subnet_label = pick(raw_subnet, "label", "name", "title")
|
|
subnet_id = pick(raw_subnet, "id", "name") or id_gen.make("subnet", subnet_label)
|
|
normalized_subnet = dict(raw_subnet)
|
|
normalized_subnet["id"] = str(subnet_id)
|
|
normalized_subnet["label"] = subnet_label or str(subnet_id)
|
|
normalized_subnet["services"] = [
|
|
normalize_service(svc, "svc", default_type="compute")
|
|
for svc in pick_list(raw_subnet, "services")
|
|
]
|
|
remember(raw_subnet, normalized_subnet["id"])
|
|
normalized_vcn["subnets"].append(normalized_subnet)
|
|
return normalized_vcn
|
|
|
|
for vcn in pick_list(raw_region, "vcns"):
|
|
normalized_region["vcns"].append(normalize_vcn(dict(vcn or {})))
|
|
|
|
for comp in pick_list(raw_region, "compartments"):
|
|
raw_comp = dict(comp or {})
|
|
comp_label = pick(raw_comp, "label", "name", "title")
|
|
comp_id = pick(raw_comp, "id", "name") or id_gen.make("compartment", comp_label)
|
|
normalized_comp = dict(raw_comp)
|
|
normalized_comp["id"] = str(comp_id)
|
|
normalized_comp["label"] = comp_label or str(comp_id)
|
|
normalized_comp["vcns"] = [
|
|
normalize_vcn(dict(vcn or {}))
|
|
for vcn in pick_list(raw_comp, "vcns")
|
|
]
|
|
normalized_comp["services"] = [
|
|
normalize_service(svc, "comp-svc", default_type="compute")
|
|
for svc in pick_list(raw_comp, "services")
|
|
]
|
|
remember(raw_comp, normalized_comp["id"])
|
|
normalized_region["compartments"].append(normalized_comp)
|
|
|
|
normalized_tenancy["regions"].append(normalized_region)
|
|
|
|
normalized["tenancy"] = normalized_tenancy
|
|
|
|
onprem_spec = spec.get("onprem")
|
|
if not onprem_spec:
|
|
onprem_spec = spec.get("on_prem")
|
|
if onprem_spec:
|
|
raw_onprem = dict(onprem_spec or {})
|
|
onprem_label = pick(raw_onprem, "label", "name", "title", default="On-Premises Data Center")
|
|
normalized_onprem = dict(raw_onprem)
|
|
normalized_onprem["id"] = pick(raw_onprem, "id", "name", default="onprem") or "onprem"
|
|
normalized_onprem["label"] = onprem_label
|
|
normalized_onprem["services"] = [
|
|
normalize_service(svc, "onprem-svc", default_type="compute")
|
|
for svc in pick_list(raw_onprem, "services")
|
|
]
|
|
remember(raw_onprem, normalized_onprem["id"])
|
|
normalized["onprem"] = normalized_onprem
|
|
|
|
for conn in pick_list(spec, "connections"):
|
|
raw_conn = dict(conn or {})
|
|
source = resolve_ref(pick(raw_conn, "from", "source", default=None))
|
|
target = resolve_ref(pick(raw_conn, "to", "target", default=None))
|
|
if not source or not target:
|
|
continue
|
|
normalized_conn = dict(raw_conn)
|
|
normalized_conn["from"] = source
|
|
normalized_conn["to"] = target
|
|
normalized_conn["type"] = pick(raw_conn, "type", "service_type", default="standard")
|
|
normalized["connections"].append(normalized_conn)
|
|
|
|
return normalized
|
|
|
|
# Unicode circled numbers for flow badges ① ② ③ ... ⑳
|
|
_CIRCLED_NUMS = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳"
|
|
|
|
|
|
class OCIDiagramGenerator:
|
|
"""Generate .drawio files using drawpyo for structure + custom icon injection.
|
|
|
|
drawpyo handles: File, Page, Object (containers/groups), Edge (connections).
|
|
Custom code handles: injecting multi-cell OCI stencil icons from oci-icons.json.
|
|
"""
|
|
|
|
# Lazily loaded icon cache from kb/diagram/oci-icons.json
|
|
ICON_CACHE = None
|
|
ICON_TYPE_ALIASES = {
|
|
"database_system": ["db_system", "dbcs"],
|
|
"base_db": ["db_system", "dbcs"],
|
|
"base_database": ["db_system", "dbcs"],
|
|
"virtual_machine": ["compute", "vm"],
|
|
"compute_instance": ["compute", "vm"],
|
|
"oci_functions": ["functions"],
|
|
"function": ["functions"],
|
|
# ADB family — drawio toolkit ships ``autonomous_database`` (the
|
|
# canonical generic icon, 7 cells) and ``adb_d`` (Dedicated, 28
|
|
# cells with the wider footprint). There is NO separate
|
|
# ``adb_s`` stencil — Serverless is rendered with the canonical
|
|
# ``autonomous_database`` icon. Map every ADB-S synonym to that
|
|
# entry first, falling back to ``adb_d`` only if the canonical
|
|
# is absent. Diego flagged 2026-04-25: "el icono de ADB-S no se
|
|
# genero en drawio" because the renderer had no alias for it.
|
|
"adb_s": ["autonomous_database", "autonomous_db", "adb_d"],
|
|
"adb_serverless": ["autonomous_database", "autonomous_db", "adb_d"],
|
|
"autonomous_database_serverless": ["autonomous_database", "autonomous_db", "adb_d"],
|
|
"autonomous_database": ["autonomous_database", "autonomous_db"],
|
|
"refreshable_clone": ["autonomous_database", "autonomous_db", "adb_d"],
|
|
"adb_clone": ["autonomous_database", "autonomous_db", "adb_d"],
|
|
"adb_d": ["adb_d", "adb", "autonomous_db"],
|
|
"adb_dedicated": ["adb_d", "adb", "autonomous_db"],
|
|
"autonomous_database_dedicated": ["adb_d", "adb", "autonomous_db"],
|
|
"adw_d": ["adw", "autonomous_db"],
|
|
"adw": ["adw", "autonomous_db"],
|
|
"atp_d": ["atp", "autonomous_db"],
|
|
"atp": ["atp", "autonomous_db"],
|
|
# GoldenGate aliases — same rule that fixes the PPTX side.
|
|
"oci_goldengate": ["goldengate"],
|
|
"ogg": ["goldengate"],
|
|
"oracle_goldengate": ["goldengate"],
|
|
# DRG variants
|
|
"dynamic_routing_gateway": ["drg"],
|
|
# Misc
|
|
"block_volume": ["block_storage"],
|
|
"oci_streaming": ["streaming", "kafka"],
|
|
"kafka": ["streaming"],
|
|
"web_application_firewall": ["waf"],
|
|
"network_firewall": ["network_firewall"],
|
|
"queue": ["queue", "oci_queue"],
|
|
"kms": ["vault"],
|
|
"secret": ["vault"],
|
|
"secrets_manager": ["vault"],
|
|
"identity": ["oracle_identity"],
|
|
"iam": ["oracle_identity"],
|
|
"iac": ["resource_manager"],
|
|
"terraform": ["resource_manager"],
|
|
"oac": ["analytics"],
|
|
"oic": ["integration"],
|
|
}
|
|
|
|
@classmethod
|
|
def _load_icon_cache(cls):
|
|
"""Load icon data from oci-icons.json if available."""
|
|
if cls.ICON_CACHE is not None:
|
|
return
|
|
# Try multiple paths: relative to this script, then cwd
|
|
candidates = [
|
|
os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
|
"..", "kb", "diagram", "oci-icons.json"),
|
|
os.path.join("kb", "diagram", "oci-icons.json"),
|
|
]
|
|
for path in candidates:
|
|
if os.path.exists(path):
|
|
with open(path, "r", encoding="utf-8") as f:
|
|
cls.ICON_CACHE = json.load(f)
|
|
return
|
|
# Not found — fall back to empty dict (colored rectangles)
|
|
cls.ICON_CACHE = {}
|
|
|
|
@classmethod
|
|
def _resolve_icon_entry(cls, service_type: str):
|
|
if cls.ICON_CACHE is None:
|
|
cls._load_icon_cache()
|
|
candidates = [service_type]
|
|
candidates.extend(cls.ICON_TYPE_ALIASES.get(service_type, []))
|
|
for candidate in candidates:
|
|
icon_entry = cls.ICON_CACHE.get(candidate) if cls.ICON_CACHE else None
|
|
if icon_entry:
|
|
return icon_entry, candidate
|
|
return None, None
|
|
|
|
def __init__(self):
|
|
self._load_icon_cache()
|
|
self.file = drawpyo.File()
|
|
self.page = drawpyo.Page(file=self.file)
|
|
self._objects = {} # cell_id (str) -> drawpyo Object
|
|
self._abs_positions = {} # cell_id -> (abs_x, abs_y) for relative→absolute conversion
|
|
self._raw_underlay_cells = [] # raw mxCell XML strings for page underlay/context stencils
|
|
self._raw_cells = [] # raw mxCell XML strings for icon stencil cells
|
|
self._edge_extras = {} # drawpyo edge id -> extra style attrs string
|
|
self._edge_objects = {} # drawpyo edge id -> (source_id, target_id) for badge placement
|
|
self._flow_badges = [] # list of (flow_order, edge_id) for deferred badge creation
|
|
self._id_counter = 1000 # counter for auto-generated cell IDs
|
|
# Track counts for summary
|
|
self._container_count = 0
|
|
self._service_count = 0
|
|
self._connection_count = 0
|
|
|
|
def _next_id(self) -> str:
|
|
self._id_counter += 1
|
|
return str(self._id_counter)
|
|
|
|
@staticmethod
|
|
def _escape(text: str) -> str:
|
|
"""Escape XML special characters and convert newlines."""
|
|
if not text:
|
|
return ""
|
|
return (text
|
|
.replace("&", "&")
|
|
.replace("<", "<")
|
|
.replace(">", ">")
|
|
.replace('"', """)
|
|
.replace("\n", "
"))
|
|
|
|
# Layout constants — calibrated from 37 Oracle Architecture Center .drawio files
|
|
# (12,617 cells parsed: 234x 63x63 service icons, 245x 42x42 gateway icons)
|
|
VCN_TOP_PAD = 50 # Oracle ref: VCN top to first subnet
|
|
SUBNET_GAP = 28 # Oracle ref: subnet-to-subnet gap
|
|
SVC_SPACING = 28 # Oracle ref: service-to-service horizontal gap
|
|
GW_DEFAULT_H = 110 # Gateway block: ~70px icon + 2px gap + 36px label
|
|
GW_SPACING = 20 # Gateway-to-gateway vertical gap
|
|
MIN_ICON_W = 42 # Oracle ref: gateway icons = 42x42
|
|
MIN_ICON_H = 42 # Oracle ref: gateway icons = 42x42
|
|
SVC_ICON_TARGET = 63 # Oracle ref: service icons = 63x63
|
|
GW_ICON_TARGET = 42 # Oracle ref: gateway icons = 42x42
|
|
|
|
@classmethod
|
|
def _calc_service_block_h(cls, label: str, service_type: str) -> int:
|
|
"""Calculate the total height a service block needs (icon + label).
|
|
|
|
Used by both auto-sizing (pre-calculation) and rendering to ensure
|
|
containers are always big enough for their content.
|
|
"""
|
|
if label:
|
|
n_lines = label.count('\n') + 1
|
|
label_h = max(n_lines * 16 + 4, 20)
|
|
else:
|
|
label_h = 0
|
|
|
|
# Estimate icon height using Oracle ref targets (63px height, 45px min width)
|
|
icon_entry, _ = cls._resolve_icon_entry(service_type)
|
|
if icon_entry:
|
|
iw, ih = icon_entry["w"], icon_entry["h"]
|
|
scale = min(63 / ih, 1.0)
|
|
if iw * scale < 45:
|
|
scale = 45 / iw
|
|
icon_h = int(ih * scale)
|
|
return icon_h + 2 + label_h # icon + gap + label
|
|
|
|
# No icon → the service is rendered as a plain coloured pill whose
|
|
# height IS the label's content. Don't reserve phantom icon space.
|
|
return label_h + 10
|
|
|
|
@classmethod
|
|
def _calc_subnet_h(cls, subnet_spec: dict) -> int:
|
|
"""Calculate the auto-height for a subnet from its services."""
|
|
svcs = subnet_spec.get("services", [])
|
|
max_svc_h = 80
|
|
for svc in svcs:
|
|
label = svc.get("label", "").replace("\\n", "\n")
|
|
block_h = cls._calc_service_block_h(label, svc.get("type", "compute"))
|
|
max_svc_h = max(max_svc_h, block_h)
|
|
return 30 + max_svc_h + 15 # top pad + content + bottom pad
|
|
|
|
# Tone palette — named state overrides used on databases/services to show
|
|
# role/health ("primary", "standby_real", "clone_warning", ...) orthogonal
|
|
# to the technology category. Values specify (strokeColor, fontColor,
|
|
# fillColor).
|
|
TONE_COLORS = {
|
|
"primary": ("#C74634", "#C74634", "#FCFBFA"), # Oracle red
|
|
"standby": ("#6E89AD", "#6E89AD", "#FCFBFA"), # slate blue
|
|
"standby_real": ("#6E89AD", "#6E89AD", "#FCFBFA"), # slate blue
|
|
"dr": ("#6E89AD", "#6E89AD", "#FCFBFA"), # slate blue (DR)
|
|
"adg": ("#00875A", "#00875A", "#FCFBFA"), # Oracle green
|
|
"adg_standby": ("#00875A", "#00875A", "#FCFBFA"),
|
|
"clone_warning": ("#E8A92F", "#7A5510", "#FDF4E1"), # amber
|
|
"warning": ("#E8A92F", "#7A5510", "#FDF4E1"),
|
|
"removed": ("#9E9892", "#70665E", "#F2F0EC"), # muted gray
|
|
}
|
|
|
|
def _get_svc_style(
|
|
self,
|
|
service_type: str,
|
|
font_size: Optional[int] = None,
|
|
tone: Optional[str] = None,
|
|
) -> str:
|
|
"""Get the style string for a service type, with optional tone override."""
|
|
category = SVC_CATEGORY.get(service_type, "svc_infra")
|
|
style = STYLES[category]
|
|
if font_size:
|
|
style = re.sub(r"fontSize=\d+", f"fontSize={font_size}", style)
|
|
if tone:
|
|
colors = self.TONE_COLORS.get(tone)
|
|
if colors:
|
|
stroke, font_col, fill = colors
|
|
style = re.sub(r"strokeColor=[^;]+", f"strokeColor={stroke}", style)
|
|
style = re.sub(r"fontColor=[^;]+", f"fontColor={font_col}", style)
|
|
style = re.sub(r"fillColor=[^;]+", f"fillColor={fill}", style)
|
|
return style
|
|
|
|
def _create_object(
|
|
self, cell_id: str, label: str, style_str: str,
|
|
parent_id: Optional[str], x: int, y: int, w: int, h: int,
|
|
) -> "drawpyo.diagram.Object":
|
|
"""Create a drawpyo Object with OCI style, register it, and return it.
|
|
|
|
drawpyo uses ABSOLUTE coordinates for children, but our specs use
|
|
RELATIVE coords (relative to parent). We convert by adding the
|
|
parent's absolute position to the child's relative offset.
|
|
"""
|
|
parent_obj = self._objects.get(parent_id) if parent_id else None
|
|
|
|
# Convert relative (x, y) to absolute by adding parent's absolute position
|
|
abs_x, abs_y = x, y
|
|
if parent_obj:
|
|
parent_abs = self._abs_positions.get(parent_id, (0, 0))
|
|
abs_x = parent_abs[0] + x
|
|
abs_y = parent_abs[1] + y
|
|
|
|
if parent_obj:
|
|
obj = drawpyo.diagram.Object(
|
|
page=self.page, value=label, parent=parent_obj,
|
|
)
|
|
else:
|
|
obj = drawpyo.diagram.Object(page=self.page, value=label)
|
|
obj.apply_style_string(style_str)
|
|
obj.position = (abs_x, abs_y)
|
|
obj.width = w
|
|
obj.height = h
|
|
self._objects[cell_id] = obj
|
|
# Store the absolute position for children to reference
|
|
self._abs_positions[cell_id] = (abs_x, abs_y)
|
|
return obj
|
|
|
|
# ================================================================
|
|
# Container methods
|
|
# ================================================================
|
|
|
|
def add_tenancy(
|
|
self, cell_id: str, label: str,
|
|
x: int = 30, y: int = 80, w: int = 1850, h: int = 720,
|
|
) -> str:
|
|
"""Add the outermost Tenancy container (dashed gray, no fill)."""
|
|
self._create_object(cell_id, label, STYLES["tenancy"], None, x, y, w, h)
|
|
self._container_count += 1
|
|
return cell_id
|
|
|
|
def add_region(
|
|
self, cell_id: str, label: str, parent: str,
|
|
x: int = 15, y: int = 30, w: int = 1140, h: int = 670,
|
|
) -> str:
|
|
"""Add a Region container (solid warm gray fill, rounded)."""
|
|
self._create_object(cell_id, label, STYLES["region"], parent, x, y, w, h)
|
|
self._container_count += 1
|
|
return cell_id
|
|
|
|
def add_ad(
|
|
self, cell_id: str, label: str, parent: str,
|
|
x: int = 15, y: int = 30, w: int = 1100, h: int = 640,
|
|
) -> str:
|
|
"""Add an Availability Domain container (medium gray fill)."""
|
|
self._create_object(cell_id, label, STYLES["ad"], parent, x, y, w, h)
|
|
self._container_count += 1
|
|
return cell_id
|
|
|
|
def add_vcn(
|
|
self, cell_id: str, label: str, parent: str,
|
|
x: int = 15, y: int = 30, w: int = 1100, h: int = 600,
|
|
) -> str:
|
|
"""Add a VCN container (dashed burnt orange border, no fill)."""
|
|
self._create_object(cell_id, label, STYLES["vcn"], parent, x, y, w, h)
|
|
self._container_count += 1
|
|
return cell_id
|
|
|
|
def add_subnet(
|
|
self, cell_id: str, label: str, parent: str,
|
|
x: int = 15, y: int = 30, w: int = 280, h: int = 400,
|
|
) -> str:
|
|
"""Add a Subnet container (dashed orange border, no fill)."""
|
|
self._create_object(cell_id, label, STYLES["subnet"], parent, x, y, w, h)
|
|
self._container_count += 1
|
|
return cell_id
|
|
|
|
# Compartment tone fills — light, semi-transparent-looking tints that
|
|
# colour-code role without dominating the diagram (same visual weight
|
|
# used for cross-AD pools in the comparison tool).
|
|
COMPARTMENT_TONE_FILLS = {
|
|
"primary": "#FDECE9", # very light Oracle red
|
|
"adg": "#E6F0E0", # light green (ADG coverage)
|
|
"adg_zone": "#E6F0E0",
|
|
"ro": "#E6EEF6", # light blue
|
|
"dr": "#E6EEF6", # light blue (DR)
|
|
"warning": "#FDF4E1", # light amber
|
|
"removed": "#F2F0EC", # light warm gray
|
|
"out": "#F2F0EC", # out-of-scope → muted gray
|
|
}
|
|
COMPARTMENT_TONE_STROKES = {
|
|
"primary": "#C74634",
|
|
"adg": "#00875A",
|
|
"adg_zone": "#00875A",
|
|
"ro": "#3B5B82",
|
|
"dr": "#6E89AD",
|
|
"warning": "#E8A92F",
|
|
"removed": "#9E9892",
|
|
"out": "#9E9892",
|
|
}
|
|
|
|
def add_compartment(
|
|
self, cell_id: str, label: str, parent: str,
|
|
x: int = 15, y: int = 30, w: int = 400, h: int = 300,
|
|
tone: Optional[str] = None,
|
|
) -> str:
|
|
"""Add a Compartment / Fault Domain / Tier container.
|
|
|
|
With ``tone`` set (primary/adg/dr/warning/removed/out), the
|
|
compartment is rendered with a light transparent-looking tint +
|
|
coloured border instead of the default dashed grey. This preserves
|
|
the diagram's visual calm while making role zones legible.
|
|
"""
|
|
style = STYLES["compartment"]
|
|
if tone and tone in self.COMPARTMENT_TONE_FILLS:
|
|
fill = self.COMPARTMENT_TONE_FILLS[tone]
|
|
stroke = self.COMPARTMENT_TONE_STROKES[tone]
|
|
style = (
|
|
f"whiteSpace=wrap;html=1;strokeWidth=1;dashed=0;align=left;"
|
|
f"fontFamily=Oracle Sans;verticalAlign=top;fillColor={fill};"
|
|
f"fontColor={stroke};strokeColor={stroke};fontSize=12;"
|
|
f"fontStyle=1;spacingLeft=10;spacingTop=6;rounded=1;arcSize=4;"
|
|
)
|
|
self._create_object(cell_id, label, style, parent, x, y, w, h)
|
|
self._container_count += 1
|
|
return cell_id
|
|
|
|
def add_onprem(
|
|
self, cell_id: str, label: str,
|
|
x: int = 30, y: int = 850, w: int = 650, h: int = 120,
|
|
) -> str:
|
|
"""Add an On-Premises container (outside tenancy, dashed gray)."""
|
|
self._create_object(cell_id, label, STYLES["compartment"], None, x, y, w, h)
|
|
self._container_count += 1
|
|
return cell_id
|
|
|
|
def add_cloud(
|
|
self, cell_id: str, label: str,
|
|
x: int = 10, y: int = 80, w: int = 200, h: int = 300,
|
|
provider: str = "generic",
|
|
) -> str:
|
|
"""Add an external cloud container (AWS, Azure, GCP).
|
|
|
|
provider: "azure" → solid blue border (Oracle multicloud style)
|
|
"gcp" → solid green border
|
|
"aws" → dashed muted border (default)
|
|
"generic" → dashed muted border
|
|
"""
|
|
style_map = {
|
|
"azure": "azure_container",
|
|
"gcp": "gcp_container",
|
|
}
|
|
style_key = style_map.get(provider, "cloud_container")
|
|
self._create_object(cell_id, label, STYLES[style_key], None, x, y, w, h)
|
|
self._container_count += 1
|
|
return cell_id
|
|
|
|
def add_external(
|
|
self, cell_id: str, label: str,
|
|
x: int = 30, y: int = 30, w: int = 50, h: int = 80,
|
|
icon: str = "user",
|
|
) -> str:
|
|
"""Add an external actor with icon.
|
|
|
|
icon: "user" — single person silhouette (circle head + body arc) in teal
|
|
"users" — two person silhouettes side by side (group icon)
|
|
"internet" — cloud stencil
|
|
"generic" — colored rectangle
|
|
"""
|
|
if icon in ("user", "users"):
|
|
# Create an invisible group for connectivity + label
|
|
self._create_object(cell_id, "", STYLES["external_user"], None, x, y, w, h)
|
|
group_placeholder = f"__DRAWPYO_{cell_id}__"
|
|
|
|
if icon == "users":
|
|
# Two person silhouettes side by side (group icon)
|
|
# Left person (slightly behind, offset left)
|
|
cx_l = w // 2 - 10
|
|
self._raw_cells.append(
|
|
f'<mxCell id="{self._next_id()}" value="" '
|
|
f'style="ellipse;fillColor=none;strokeColor=#2D5967;strokeWidth=1.5;" '
|
|
f'vertex="1" parent="{group_placeholder}">'
|
|
f'<mxGeometry x="{cx_l - 8}" y="2" width="16" height="16" as="geometry"/>'
|
|
f'</mxCell>'
|
|
)
|
|
self._raw_cells.append(
|
|
f'<mxCell id="{self._next_id()}" value="" '
|
|
f'style="shape=mxgraph.basic.arc;dx=0.5;fillColor=none;'
|
|
f'strokeColor=#2D5967;strokeWidth=1.5;rotation=180;" '
|
|
f'vertex="1" parent="{group_placeholder}">'
|
|
f'<mxGeometry x="{cx_l - 12}" y="20" width="24" height="16" as="geometry"/>'
|
|
f'</mxCell>'
|
|
)
|
|
# Right person (foreground, offset right)
|
|
cx_r = w // 2 + 10
|
|
self._raw_cells.append(
|
|
f'<mxCell id="{self._next_id()}" value="" '
|
|
f'style="ellipse;fillColor=none;strokeColor=#2D5967;strokeWidth=2;" '
|
|
f'vertex="1" parent="{group_placeholder}">'
|
|
f'<mxGeometry x="{cx_r - 9}" y="0" width="18" height="18" as="geometry"/>'
|
|
f'</mxCell>'
|
|
)
|
|
self._raw_cells.append(
|
|
f'<mxCell id="{self._next_id()}" value="" '
|
|
f'style="shape=mxgraph.basic.arc;dx=0.5;fillColor=none;'
|
|
f'strokeColor=#2D5967;strokeWidth=2;rotation=180;" '
|
|
f'vertex="1" parent="{group_placeholder}">'
|
|
f'<mxGeometry x="{cx_r - 13}" y="20" width="26" height="18" as="geometry"/>'
|
|
f'</mxCell>'
|
|
)
|
|
else:
|
|
# Single person silhouette
|
|
cx = w // 2
|
|
self._raw_cells.append(
|
|
f'<mxCell id="{self._next_id()}" value="" '
|
|
f'style="ellipse;fillColor=none;strokeColor=#2D5967;strokeWidth=2;" '
|
|
f'vertex="1" parent="{group_placeholder}">'
|
|
f'<mxGeometry x="{cx - 11}" y="0" width="22" height="22" as="geometry"/>'
|
|
f'</mxCell>'
|
|
)
|
|
self._raw_cells.append(
|
|
f'<mxCell id="{self._next_id()}" value="" '
|
|
f'style="shape=mxgraph.basic.arc;dx=0.5;fillColor=none;'
|
|
f'strokeColor=#2D5967;strokeWidth=2;rotation=180;" '
|
|
f'vertex="1" parent="{group_placeholder}">'
|
|
f'<mxGeometry x="{cx - 16}" y="24" width="32" height="22" as="geometry"/>'
|
|
f'</mxCell>'
|
|
)
|
|
|
|
# Label below the icon
|
|
label_id = f"{cell_id}_label"
|
|
label_style = (
|
|
"text;html=1;whiteSpace=wrap;align=center;verticalAlign=top;"
|
|
"fontFamily=Oracle Sans;fontSize=11;fontColor=#312D2A;"
|
|
"strokeColor=none;fillColor=none;"
|
|
)
|
|
self._create_object(
|
|
label_id, label, label_style, cell_id,
|
|
-10, 48, w + 20, 20,
|
|
)
|
|
else:
|
|
style_key = f"external_{icon}" if f"external_{icon}" in STYLES else "external_generic"
|
|
self._create_object(cell_id, label, STYLES[style_key], None, x, y, w, h)
|
|
return cell_id
|
|
|
|
# ================================================================
|
|
# Service block methods
|
|
# ================================================================
|
|
|
|
@staticmethod
|
|
def _is_icon_label_cell(style: str) -> bool:
|
|
"""Detect the label cell within an OCI Library icon."""
|
|
return ("verticalAlign=middle" in style
|
|
and ("align=center" in style or "align=left" in style)
|
|
and "shape=stencil(" in style
|
|
and "fillColor=none" in style)
|
|
|
|
def add_service(
|
|
self, cell_id: str, label: str, service_type: str, parent: str,
|
|
x: int = 20, y: int = 35, w: int = 150, h: int = 50,
|
|
font_size: Optional[int] = None,
|
|
tone: Optional[str] = None,
|
|
badges: Optional[list] = None,
|
|
caption: Optional[str] = None,
|
|
exact_size: bool = False,
|
|
raw_icon_cells: Optional[list] = None,
|
|
raw_icon_w: Optional[int] = None,
|
|
raw_icon_h: Optional[int] = None,
|
|
) -> str:
|
|
"""Add a service block with icon (if available) or colored rectangle fallback.
|
|
|
|
``tone`` applies a state-based color overlay (primary/standby/adg/
|
|
clone_warning/dr/removed). For iconified services, the tone renders as
|
|
a thin colored outline around the icon + tinted label, since OCI
|
|
stencils are always teal. ``badges`` is an optional list of short
|
|
labels (e.g., ["R/W", "R/O"]) rendered as small pills beside the
|
|
service to annotate the service roles exposed.
|
|
"""
|
|
icon_entry = None
|
|
if raw_icon_cells:
|
|
icon_entry = {
|
|
"w": max(int(raw_icon_w or w), 1),
|
|
"h": max(int(raw_icon_h or h), 1),
|
|
"cells": list(raw_icon_cells),
|
|
}
|
|
else:
|
|
icon_entry, _ = self._resolve_icon_entry(service_type)
|
|
if icon_entry:
|
|
ret = self._add_service_with_icon(
|
|
cell_id, label, service_type, parent, icon_entry,
|
|
x, y, w, h, font_size, tone=tone, exact_size=exact_size,
|
|
)
|
|
else:
|
|
style = self._get_svc_style(service_type, font_size, tone=tone)
|
|
self._create_object(cell_id, label, style, parent, x, y, w, h)
|
|
self._service_count += 1
|
|
ret = cell_id
|
|
if badges:
|
|
self._add_service_badges(cell_id, badges, parent, x, y, w, h)
|
|
if caption:
|
|
# Small "sticky note" caption beneath the service to annotate
|
|
# intent / open question (e.g., "Use case: TBD — confirm with
|
|
# MELI"). Rendered as a compact amber-bordered block directly
|
|
# below the service block.
|
|
cap_style = (
|
|
"rounded=1;whiteSpace=wrap;html=1;fillColor=#FFF4D6;"
|
|
"strokeColor=#E8A92F;strokeWidth=1;fontColor=#5A4A10;"
|
|
"fontSize=9;fontStyle=2;fontFamily=Oracle Sans;"
|
|
"align=center;verticalAlign=middle;arcSize=6;"
|
|
"spacingLeft=4;spacingRight=4;"
|
|
)
|
|
cap_id = f"{cell_id}__caption"
|
|
cap_y = y + h + 4
|
|
self._create_object(cap_id, caption, cap_style, parent,
|
|
x, cap_y, w, 32)
|
|
return ret
|
|
|
|
def _add_service_badges(self, svc_id, badges, parent, x, y, w, h):
|
|
"""Render small colored pills next to a service to label its exposed
|
|
endpoints (e.g., R/W, R/O). Placed to the right of the service block.
|
|
"""
|
|
BADGE_STYLES = {
|
|
"rw": ("#C74634", "#FCFBFA"), # Oracle red — R/W
|
|
"r/w": ("#C74634", "#FCFBFA"),
|
|
"ro": ("#00875A", "#FCFBFA"), # Oracle green — R/O (ADG)
|
|
"r/o": ("#00875A", "#FCFBFA"),
|
|
"dr": ("#6E89AD", "#FCFBFA"), # slate blue — DR
|
|
"tp": ("#C74634", "#FCFBFA"), # TP = R/W service alias
|
|
"low": ("#00875A", "#FCFBFA"), # LOW = R/O alias
|
|
}
|
|
bw, bh = 34, 16
|
|
bx = x + w + 4
|
|
by = y + 8
|
|
for i, b in enumerate(badges):
|
|
key = str(b).strip().lower()
|
|
fill, fc = BADGE_STYLES.get(key, ("#2D5967", "#FCFBFA"))
|
|
style = (
|
|
f"rounded=1;whiteSpace=wrap;html=1;fillColor={fill};"
|
|
f"strokeColor=none;fontColor={fc};fontSize=9;fontStyle=1;"
|
|
f"fontFamily=Oracle Sans;align=center;verticalAlign=middle;"
|
|
f"arcSize=30;"
|
|
)
|
|
bid = f"{svc_id}__badge{i}"
|
|
self._create_object(bid, str(b), style, parent,
|
|
bx, by + i * (bh + 2), bw, bh)
|
|
|
|
def _add_service_with_icon(
|
|
self, cell_id: str, label: str, service_type: str, parent: str,
|
|
icon_entry: dict,
|
|
x: int, y: int, w: int, h: int,
|
|
font_size: Optional[int] = None,
|
|
tone: Optional[str] = None,
|
|
exact_size: bool = False,
|
|
) -> str:
|
|
"""Embed official OCI icon cells inside a drawpyo group Object.
|
|
|
|
Architecture:
|
|
- The group Object (cell_id) covers ONLY the icon area — its height equals
|
|
the scaled icon height, NOT the full service block height. This ensures
|
|
draw.io routes connection arrows to the icon boundary, not the label area.
|
|
- The label is a SIBLING of the icon group (child of the same parent), placed
|
|
immediately below the group. It does not affect connection geometry.
|
|
|
|
Benefit: connections from/to services visually terminate at the icon itself,
|
|
which matches Oracle Architecture Center reference diagram style.
|
|
"""
|
|
icon_w = icon_entry["w"]
|
|
icon_h = icon_entry["h"]
|
|
icon_cells = icon_entry["cells"]
|
|
|
|
# Label height: proportional to actual line count (no over-allocation).
|
|
# ~16px per line at 12pt Oracle Sans, minimum 20px for single-line labels.
|
|
n_lines = label.count('\n') + 1
|
|
label_h = max(n_lines * 16 + 4, 20)
|
|
|
|
# ── Icon sizing: Oracle ref arch standard ──
|
|
# Oracle reference diagrams use ~55-65px icons that are visually
|
|
# prominent. We enforce minimum rendered width of 50px so that
|
|
# tall/narrow icons (ExaCS, Compute, DBCS, NAT GW — 21 of 61 icons
|
|
# have aspect ratio > 1.3) don't render as tiny slivers.
|
|
MIN_ICON_W = 50 # minimum rendered icon WIDTH (px)
|
|
MIN_ICON_H = 60 # minimum rendered icon HEIGHT (px)
|
|
|
|
# For tall/narrow icons (ratio > 1.3), we need more vertical space
|
|
# so the width doesn't shrink below MIN_ICON_W when scaling to fit.
|
|
aspect_ratio = icon_h / icon_w if icon_w > 0 else 1
|
|
if aspect_ratio > 1.3:
|
|
# Need enough height so that width = icon_w * scale >= MIN_ICON_W
|
|
# scale = MIN_ICON_W / icon_w, needed_h = icon_h * scale
|
|
needed_h = int(icon_h * (MIN_ICON_W / icon_w))
|
|
icon_area_h = max(h - label_h, MIN_ICON_H, needed_h)
|
|
else:
|
|
icon_area_h = max(h - label_h, MIN_ICON_H)
|
|
|
|
if exact_size:
|
|
# Honor the spec's bounding box exactly. Used by absolute_layout
|
|
# when the caller has already authored coordinates that match
|
|
# the canonical reference; auto-scaling would push icons off
|
|
# their position relative to neighbors.
|
|
scale = min(w / icon_w, h / icon_h)
|
|
scaled_w = icon_w * scale
|
|
scaled_h = icon_h * scale
|
|
icon_group_w = max(int(round(scaled_w)), 1)
|
|
icon_group_h = max(int(round(scaled_h)), 1)
|
|
icon_group_x = x
|
|
icon_offset_x = 0
|
|
else:
|
|
# Scale icon to Oracle ref standard (calibrated from 37 Oracle .drawio
|
|
# files): Oracle uses 63x63 square icon boxes. Our OCI Library stencils
|
|
# are rectangular (tall), so we target 63px height and enforce minimum
|
|
# 45px width for legibility of detailed multi-cell stencils.
|
|
ICON_TARGET_H = 63 # Oracle ref: 63px icon box height
|
|
ICON_MIN_W = 45 # Minimum width for legible stencils
|
|
|
|
# Scale to fit target height
|
|
scale = min(ICON_TARGET_H / icon_h, 1.0)
|
|
scaled_w = icon_w * scale
|
|
scaled_h = icon_h * scale
|
|
|
|
# If too narrow, scale up to meet minimum width
|
|
if scaled_w < ICON_MIN_W:
|
|
scale = ICON_MIN_W / icon_w
|
|
scaled_w = icon_w * scale
|
|
scaled_h = icon_h * scale
|
|
# Icon group dimensions = scaled icon size (not spec width).
|
|
# This ensures edges connect TO the visible icon, not to an
|
|
# invisible wide group. The label (sibling) uses spec width.
|
|
icon_group_w = max(int(scaled_w) + 4, MIN_ICON_W) # small padding
|
|
icon_group_h = max(int(scaled_h), MIN_ICON_H)
|
|
|
|
# Center the icon group horizontally within the spec width
|
|
icon_group_x = x + (w - icon_group_w) // 2
|
|
|
|
group_style = (
|
|
"group;fillColor=none;strokeColor=none;pointerEvents=1;"
|
|
"connectable=1;"
|
|
)
|
|
self._create_object(cell_id, "", group_style, parent, icon_group_x, y, icon_group_w, icon_group_h)
|
|
|
|
# Build ID mapping: original cell id -> unique cell id. Some
|
|
# harvested stencils have cells without an explicit id, which
|
|
# would propagate as None and break sorting; filter those out.
|
|
original_ids = set()
|
|
for cell_xml in icon_cells:
|
|
cell_elem = ET.fromstring(cell_xml)
|
|
cid_orig = cell_elem.get("id")
|
|
if cid_orig:
|
|
original_ids.add(cid_orig)
|
|
|
|
id_map = {}
|
|
for orig_id in sorted(
|
|
original_ids,
|
|
key=lambda x_: int(x_) if x_ and x_.isdigit() else 0,
|
|
):
|
|
id_map[orig_id] = f"{cell_id}_i{orig_id}"
|
|
|
|
if not exact_size:
|
|
# Center the icon horizontally within the (now tight) group
|
|
icon_offset_x = (icon_group_w - scaled_w) / 2
|
|
|
|
# Use a placeholder for the group's drawpyo ID — replaced in to_xml()
|
|
group_placeholder = f"__DRAWPYO_{cell_id}__"
|
|
|
|
# Emit icon cells (skip the library's built-in label cell)
|
|
for cell_xml in icon_cells:
|
|
cell_elem = ET.fromstring(cell_xml)
|
|
orig_id = cell_elem.get("id")
|
|
orig_parent = cell_elem.get("parent", "1")
|
|
style = cell_elem.get("style", "")
|
|
|
|
# Skip the icon's built-in label cell — we replace with our own
|
|
if self._is_icon_label_cell(style):
|
|
continue
|
|
|
|
# Some harvested stencils have inner cells without an explicit
|
|
# id (e.g. Oracle exports with vsdxID-only metadata). Mint a
|
|
# synthetic id so the cell still renders inside the group.
|
|
if orig_id is None or orig_id not in id_map:
|
|
synth = f"{cell_id}_syn{len(id_map)}"
|
|
id_map[orig_id] = synth
|
|
new_id = id_map[orig_id]
|
|
|
|
# Remap parent
|
|
if orig_parent == "1":
|
|
new_parent = group_placeholder
|
|
elif orig_parent in id_map:
|
|
new_parent = id_map[orig_parent]
|
|
else:
|
|
new_parent = group_placeholder
|
|
|
|
cell_elem.set("id", new_id)
|
|
cell_elem.set("parent", new_parent)
|
|
|
|
# NOTE: Stencil icons are ALWAYS teal (#2D5967) per OCI visual style.
|
|
# Copper (#AA643B) is only for fallback rectangles (no icon available).
|
|
|
|
# Scale and offset geometry. Cells that were originally parented
|
|
# to "1" (the page root) carry whole-page absolute coordinates
|
|
# — when we re-parent them to the icon group, those absolute
|
|
# coords stop making sense (they would render at the harvested
|
|
# position on the new page, far from the spec-supplied x/y).
|
|
# Zero the root cell's geometry so the group's x/y is the
|
|
# single source of truth for icon placement; child cells keep
|
|
# their parent-relative offsets and just get scaled.
|
|
geo = cell_elem.find("mxGeometry")
|
|
if geo is not None:
|
|
for attr in ("width", "height"):
|
|
val = geo.get(attr)
|
|
if val is not None:
|
|
try:
|
|
geo.set(attr, str(float(val) * scale))
|
|
except ValueError:
|
|
pass
|
|
if orig_parent == "1":
|
|
# Root anchor: its (x,y) in the harvested file was a
|
|
# whole-page absolute. Drop it and center inside the
|
|
# group instead.
|
|
geo.set("x", str(icon_offset_x))
|
|
geo.set("y", "0")
|
|
else:
|
|
for attr in ("x", "y"):
|
|
val = geo.get(attr)
|
|
if val is not None:
|
|
try:
|
|
geo.set(attr, str(float(val) * scale))
|
|
except ValueError:
|
|
pass
|
|
|
|
cell_str = ET.tostring(cell_elem, encoding="unicode")
|
|
self._raw_cells.append(cell_str)
|
|
|
|
# Label is a SIBLING of the icon group (child of `parent`, not of `cell_id`).
|
|
# This keeps connection geometry clean: arrows connect to the icon group only.
|
|
# Oracle ref arch style: 12pt, charcoal (#312D2A), centered below icon.
|
|
# When a tone is set, recolor the label so the state (primary / standby
|
|
# / clone_warning / ...) is visible even though the icon itself stays
|
|
# teal.
|
|
fs = font_size or 12
|
|
label_color = "#312D2A"
|
|
if tone and tone in self.TONE_COLORS:
|
|
_, label_color, _ = self.TONE_COLORS[tone]
|
|
if label:
|
|
label_style = (
|
|
f"text;html=1;whiteSpace=wrap;overflow=visible;align=center;verticalAlign=top;"
|
|
f"fontFamily=Oracle Sans;fontSize={fs};fontColor={label_color};"
|
|
f"strokeColor=none;fillColor=none;spacingTop=2;"
|
|
)
|
|
# Label width = icon width (centered); overflow=visible handles longer text.
|
|
label_w = w
|
|
label_id = f"{cell_id}_label"
|
|
self._create_object(
|
|
label_id, label, label_style, parent,
|
|
x, y + icon_group_h + 2, label_w, label_h,
|
|
)
|
|
|
|
# When a tone is set, draw a thin colored outline around the icon
|
|
# area to tag state (primary / standby / clone / DR) visually.
|
|
if tone and tone in self.TONE_COLORS:
|
|
stroke, _, fill = self.TONE_COLORS[tone]
|
|
outline_style = (
|
|
f"rounded=1;whiteSpace=wrap;html=1;fillColor=none;"
|
|
f"strokeColor={stroke};strokeWidth=2;"
|
|
f"dashed={'1' if tone in ('clone_warning', 'warning', 'removed') else '0'};"
|
|
f"dashPattern=6 4;arcSize=6;"
|
|
)
|
|
outline_id = f"{cell_id}_outline"
|
|
self._create_object(
|
|
outline_id, "", outline_style, parent,
|
|
x - 2, y - 2, icon_group_w + 4, icon_group_h + label_h + 8,
|
|
)
|
|
|
|
self._service_count += 1
|
|
return cell_id
|
|
|
|
def add_obs_bar(
|
|
self, cell_id: str, label: str, parent: str,
|
|
x: int = 20, y: int = 640, w: int = 78, h: int = 22,
|
|
) -> str:
|
|
"""Add an observability bar element (small teal pill)."""
|
|
self._create_object(cell_id, label, STYLES["obs_bar"], parent, x, y, w, h)
|
|
return cell_id
|
|
|
|
# ================================================================
|
|
# NSG corner badge — Oracle visual signature
|
|
# ================================================================
|
|
|
|
# ================================================================
|
|
# Connection methods
|
|
# ================================================================
|
|
|
|
def add_connection(
|
|
self, cell_id: str, label: Optional[str], conn_type: str,
|
|
source: str, target: str,
|
|
waypoints: Optional[list] = None,
|
|
flow_order: Optional[int] = None,
|
|
exit_x: Optional[float] = None,
|
|
exit_y: Optional[float] = None,
|
|
entry_x: Optional[float] = None,
|
|
entry_y: Optional[float] = None,
|
|
) -> str:
|
|
"""Add a connection arrow between two elements using drawpyo Edge.
|
|
|
|
flow_order: if set (1-20), renders a teal circle badge with the number
|
|
on the midpoint of the edge for visual storytelling.
|
|
|
|
exit_x/exit_y/entry_x/entry_y: floats in [0, 1] specifying the exact
|
|
port on the source / target shape where the edge attaches. Use these
|
|
to disambiguate parallel edges sharing a source: e.g. two arrows
|
|
leaving the same icon should have distinct exit_y values so drawio
|
|
doesn't stack them on top of each other.
|
|
|
|
exit_x=0 exit_y=0 → top-left corner of source
|
|
exit_x=0.5 exit_y=0 → top-middle
|
|
exit_x=1 exit_y=0.5 → right-middle
|
|
exit_x=0.5 exit_y=1 → bottom-middle
|
|
"""
|
|
src_obj = self._objects.get(source)
|
|
tgt_obj = self._objects.get(target)
|
|
if not src_obj or not tgt_obj:
|
|
# Source or target not found — skip silently
|
|
return cell_id
|
|
|
|
edge = drawpyo.diagram.Edge(
|
|
page=self.page, source=src_obj, target=tgt_obj,
|
|
label=label or "",
|
|
)
|
|
|
|
# Base OCI style: open arrows, charcoal color
|
|
# Note: orthogonal routing is set via extra_style (edgeStyle=orthogonalEdgeStyle)
|
|
# to avoid conflicts with drawpyo's own style merging
|
|
edge.line_end_target = "open"
|
|
edge.line_end_source = "none"
|
|
edge.strokeColor = "#312D2A"
|
|
edge.strokeWidth = 1
|
|
edge.rounded = True
|
|
edge.endSize = 6
|
|
|
|
# Font styling for the rare edge label. NOTE: Oracle's shipped
|
|
# reference architectures (verified across 3 in kb/diagram/assets/archcenter-refs)
|
|
# have ZERO edges with values and ZERO labelBackgroundColor=#FFFFFF
|
|
# — they put connector text in standalone shapes positioned in
|
|
# whitespace. Slide 11 of the toolkit prescribes edge labels but
|
|
# the published architectures override it. We default to NO white
|
|
# background so the label is visually clean if a spec author
|
|
# opts to use one anyway.
|
|
edge.text_format.fontFamily = "Oracle Sans"
|
|
edge.text_format.fontSize = 9
|
|
edge.text_format.fontColor = "#312D2A"
|
|
|
|
# Extra style attributes that drawpyo Edge doesn't support natively.
|
|
# - edgeStyle=orthogonalEdgeStyle: right-angle routing (no diagonal lines)
|
|
# - jumpStyle=arc: when edges cross, show a small arc instead of overlapping
|
|
# - NO elbow=vertical (it conflicts with orthogonal + port constraints)
|
|
# - NO labelBackgroundColor: Oracle reference archs do not use it
|
|
# on edges; connector text lives in standalone shapes instead.
|
|
extra_style = (
|
|
"edgeStyle=orthogonalEdgeStyle;orthogonalLoop=1;"
|
|
"jettySize=auto;"
|
|
"jumpStyle=arc;jumpSize=8;"
|
|
"endFill=0;startFill=0;"
|
|
"fontFamily=Oracle Sans;fontSize=9;fontColor=#312D2A;"
|
|
)
|
|
|
|
# Customize per connection type
|
|
if conn_type == "fastconnect":
|
|
# Bidirectional, thicker line
|
|
edge.line_end_source = "open"
|
|
edge.strokeWidth = 2
|
|
elif conn_type == "network":
|
|
# Network connections: thicker, teal color
|
|
edge.strokeColor = "#2C5967"
|
|
edge.strokeWidth = 2
|
|
elif conn_type == "adg":
|
|
# Dashed (ADG replication) — toolkit pattern: 8 4
|
|
edge.pattern = "dashed_small"
|
|
extra_style += "dashPattern=8 4;"
|
|
elif conn_type == "migration":
|
|
# Dashed (migration) — toolkit pattern: 8 4
|
|
edge.pattern = "dashed_medium"
|
|
extra_style += "dashPattern=8 4;"
|
|
elif conn_type == "etl":
|
|
# Dashed (ETL/streaming) — toolkit pattern: 6 4
|
|
edge.pattern = "dashed_small"
|
|
extra_style += "dashPattern=6 4;"
|
|
elif conn_type == "internal":
|
|
# Internal/management connections: lighter, thinner
|
|
edge.strokeColor = "#9E9892"
|
|
extra_style += "dashPattern=4 4;"
|
|
# "standard", "data", and "db" use solid line defaults
|
|
|
|
# ── Explicit ports (caller override) ──
|
|
# If the caller passed exit_x/y or entry_x/y, honor them — this lets
|
|
# specs disambiguate parallel edges from the same source by giving
|
|
# each one a distinct exit_y on the source side.
|
|
#
|
|
# IMPORTANT (general rule, not per-case fix):
|
|
# When ports are explicit, we IGNORE waypoints. Mismatched
|
|
# waypoints (a waypoint behind the exit port, or off the edge's
|
|
# natural axis) make drawio render a curve loop / "tail" at the
|
|
# source. The clean default is: ports decide the route — drawio
|
|
# auto-routes orthogonally between them. Spec authors who want
|
|
# full manual control should drop the ports and use waypoints
|
|
# alone. Diego flagged this as "colita / semicírculo al inicio
|
|
# de la flecha" 2026-04-25.
|
|
explicit_ports = any(p is not None for p in (exit_x, exit_y, entry_x, entry_y))
|
|
if explicit_ports:
|
|
ex = 0.5 if exit_x is None else float(exit_x)
|
|
ey = 0.5 if exit_y is None else float(exit_y)
|
|
tx_p = 0.5 if entry_x is None else float(entry_x)
|
|
ty_p = 0.5 if entry_y is None else float(entry_y)
|
|
extra_style += (
|
|
f"exitX={ex};exitY={ey};exitDx=0;exitDy=0;"
|
|
f"entryX={tx_p};entryY={ty_p};entryDx=0;entryDy=0;"
|
|
)
|
|
if waypoints:
|
|
import sys as _sys
|
|
print(
|
|
f"[connection {cell_id}] both exit/entry ports AND waypoints "
|
|
f"specified — dropping waypoints to avoid curve-loop tail. "
|
|
f"Use one or the other.",
|
|
file=_sys.stderr,
|
|
)
|
|
waypoints = None
|
|
|
|
# ── Auto-port detection ──
|
|
# Calculate exit/entry ports based on relative positions of source
|
|
# and target. This prevents draw.io's auto-router from creating
|
|
# crossing arrows — instead, edges exit/enter from the
|
|
# geometrically correct side.
|
|
#
|
|
# IMPORTANT (general rule, 2026-04-25 — Diego flagged "siguen
|
|
# con la colita las que van desde App VM al DB y LBaaS a VMs"):
|
|
# if the spec already provided waypoints, DO NOT also add auto-
|
|
# ports. drawio reconciles "exit at right edge" + "first
|
|
# waypoint above-left" by drawing a small curl back through the
|
|
# source. Waypoints take priority — the spec author already
|
|
# decided the route.
|
|
src_pos = self._abs_positions.get(source)
|
|
tgt_pos = self._abs_positions.get(target)
|
|
if not explicit_ports and not waypoints and src_pos and tgt_pos:
|
|
src_obj_w = getattr(src_obj, 'width', 100) or 100
|
|
src_obj_h = getattr(src_obj, 'height', 60) or 60
|
|
# Center points
|
|
sx = src_pos[0] + src_obj_w / 2
|
|
sy = src_pos[1] + src_obj_h / 2
|
|
tgt_obj_w = getattr(tgt_obj, 'width', 100) or 100
|
|
tgt_obj_h = getattr(tgt_obj, 'height', 60) or 60
|
|
tx = tgt_pos[0] + tgt_obj_w / 2
|
|
ty = tgt_pos[1] + tgt_obj_h / 2
|
|
|
|
dx = tx - sx
|
|
dy = ty - sy
|
|
|
|
# Determine dominant direction and set exit/entry ports.
|
|
# Threshold: prefer vertical for diagonal connections (natural
|
|
# top-down flow between subnets in Oracle ref arch style).
|
|
is_horizontal = abs(dx) > abs(dy) * 1.5
|
|
|
|
if is_horizontal:
|
|
# Horizontal: exit right → enter left (or vice versa)
|
|
if dx > 0:
|
|
extra_style += "exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;"
|
|
else:
|
|
extra_style += "exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;"
|
|
else:
|
|
# Vertical: exit bottom → enter top (or vice versa)
|
|
if dy > 0:
|
|
extra_style += "exitX=0.5;exitY=1;exitDx=0;exitDy=0;entryX=0.5;entryY=0;entryDx=0;entryDy=0;"
|
|
else:
|
|
extra_style += "exitX=0.5;exitY=0;exitDx=0;exitDy=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;"
|
|
|
|
# For vertical edges with labels, position label to the right
|
|
# so it doesn't overlap the source/target icons
|
|
if label:
|
|
extra_style += "align=left;verticalAlign=middle;"
|
|
|
|
# Add waypoints if provided
|
|
if waypoints:
|
|
for wx, wy in waypoints:
|
|
edge.add_point(wx, wy)
|
|
|
|
# Store extra style for post-processing (keyed by drawpyo's edge id)
|
|
self._edge_extras[str(edge.id)] = extra_style
|
|
|
|
# Track edge endpoints for flow badge placement
|
|
self._edge_objects[str(edge.id)] = (source, target)
|
|
|
|
# Queue flow badge if requested
|
|
if flow_order is not None and 1 <= flow_order <= 20:
|
|
self._flow_badges.append((flow_order, source, target))
|
|
|
|
self._objects[cell_id] = src_obj # register for potential referencing
|
|
self._connection_count += 1
|
|
return cell_id
|
|
|
|
# ================================================================
|
|
# Flow badges — numbered circles on connection midpoints
|
|
# ================================================================
|
|
|
|
def _create_flow_badges(self):
|
|
"""Create teal circle badges with flow numbers near connection sources.
|
|
|
|
Called after all objects and connections are registered, before save.
|
|
Badge is positioned at 25% of the path from source (not midpoint) to
|
|
avoid overlapping with destination icons. A small perpendicular offset
|
|
prevents badges from sitting on top of the arrow line itself.
|
|
"""
|
|
badge_size = 18
|
|
for flow_order, source_id, target_id in self._flow_badges:
|
|
src_pos = self._abs_positions.get(source_id)
|
|
tgt_pos = self._abs_positions.get(target_id)
|
|
if not src_pos or not tgt_pos:
|
|
continue
|
|
|
|
src_obj = self._objects.get(source_id)
|
|
tgt_obj = self._objects.get(target_id)
|
|
if not src_obj or not tgt_obj:
|
|
continue
|
|
|
|
# Compute center of source and target
|
|
src_cx = src_pos[0] + src_obj.width / 2
|
|
src_cy = src_pos[1] + src_obj.height / 2
|
|
tgt_cx = tgt_pos[0] + tgt_obj.width / 2
|
|
tgt_cy = tgt_pos[1] + tgt_obj.height / 2
|
|
|
|
# Position at 25% from source (closer to source, avoids target overlap)
|
|
t = 0.25
|
|
mid_x = int(src_cx + (tgt_cx - src_cx) * t - badge_size / 2)
|
|
mid_y = int(src_cy + (tgt_cy - src_cy) * t - badge_size / 2)
|
|
|
|
# Small perpendicular offset to avoid sitting on the arrow line
|
|
dx = tgt_cx - src_cx
|
|
dy = tgt_cy - src_cy
|
|
length = max((dx**2 + dy**2) ** 0.5, 1)
|
|
# Offset 14px perpendicular to the connection direction
|
|
offset_x = int(-dy / length * 14)
|
|
offset_y = int(dx / length * 14)
|
|
mid_x += offset_x
|
|
mid_y += offset_y
|
|
|
|
# Use circled number character or plain number
|
|
if flow_order <= len(_CIRCLED_NUMS):
|
|
badge_label = str(flow_order)
|
|
else:
|
|
badge_label = str(flow_order)
|
|
|
|
badge_id = f"flow_{flow_order}"
|
|
self._create_object(
|
|
badge_id, badge_label, STYLES["flow_badge"],
|
|
None, mid_x, mid_y, badge_size, badge_size,
|
|
)
|
|
|
|
# ================================================================
|
|
# Title
|
|
# ================================================================
|
|
|
|
def add_title(
|
|
self, text: str,
|
|
x: int = 30, y: int = 10, w: int = 800, h: int = 30,
|
|
banner: Optional[str] = None,
|
|
) -> str:
|
|
"""Add a title label. When ``banner`` is set to a color key
|
|
(red/green/amber/blue), a thin colored strip is drawn behind the
|
|
title so it reads as a coloured banner.
|
|
"""
|
|
if banner:
|
|
banner_colors = {
|
|
"red": ("#C74634", "#FCFBFA"),
|
|
"green": ("#00875A", "#FCFBFA"),
|
|
"amber": ("#E8A92F", "#312D2A"),
|
|
"blue": ("#2D5967", "#FCFBFA"),
|
|
"gray": ("#70665E", "#FCFBFA"),
|
|
}
|
|
fill, fc = banner_colors.get(banner, ("#C74634", "#FCFBFA"))
|
|
banner_style = (
|
|
f"rounded=1;whiteSpace=wrap;html=1;fillColor={fill};"
|
|
f"strokeColor=none;fontColor={fc};fontSize=16;fontStyle=1;"
|
|
f"fontFamily=Oracle Sans;align=center;verticalAlign=middle;"
|
|
f"arcSize=4;"
|
|
)
|
|
self._create_object("title", text, banner_style, None, x, y, w, h + 6)
|
|
else:
|
|
self._create_object("title", text, STYLES["title"], None, x, y, w, h)
|
|
return "title"
|
|
|
|
def add_callout(
|
|
self, cell_id: str, text: str, color: str = "red",
|
|
x: int = 30, y: int = 820, w: int = 1500, h: int = 70,
|
|
) -> str:
|
|
"""Add a coloured rectangular callout box with centered text.
|
|
|
|
Used for "key issue" banners, "REMOVED" strips, capacity advisories —
|
|
anything the architect wants to surface above/below the topology.
|
|
``color`` picks from red / green / amber / blue / gray.
|
|
"""
|
|
palette = {
|
|
"red": ("#FBE5E2", "#C74634", "#7A1F12"), # fill, stroke, font
|
|
"green": ("#DFF1E7", "#00875A", "#044C33"),
|
|
"amber": ("#FDF1D9", "#E8A92F", "#7A5510"),
|
|
"blue": ("#E3ECF3", "#2D5967", "#1F3F48"),
|
|
"gray": ("#F0EDE9", "#9E9892", "#312D2A"),
|
|
}
|
|
fill, stroke, fc = palette.get(color, palette["red"])
|
|
style = (
|
|
f"rounded=1;whiteSpace=wrap;html=1;fillColor={fill};"
|
|
f"strokeColor={stroke};strokeWidth=1;fontColor={fc};"
|
|
f"fontSize=11;fontFamily=Oracle Sans;align=left;"
|
|
f"verticalAlign=middle;spacingLeft=14;spacingTop=6;"
|
|
f"spacingRight=12;spacingBottom=6;arcSize=4;"
|
|
)
|
|
self._create_object(cell_id, text, style, None, x, y, w, h)
|
|
return cell_id
|
|
|
|
# ================================================================
|
|
# Output — merge drawpyo XML + injected raw icon cells
|
|
# ================================================================
|
|
|
|
def to_xml(self) -> str:
|
|
"""Generate the complete .drawio XML.
|
|
|
|
1. Get drawpyo's XML via file.xml property
|
|
2. Replace drawpyo's default mxfile/diagram attributes with OCI ones
|
|
3. Inject extra style attributes into edge cells
|
|
4. Inject raw underlay/context stencils behind drawpyo objects
|
|
5. Inject raw icon stencil cells before </root>
|
|
6. Replace group parent placeholders with actual drawpyo IDs
|
|
7. Sanitize: replace #ffffff with #FCFBFA (no pure white in OCI palette)
|
|
"""
|
|
xml = self.file.xml
|
|
|
|
# Replace drawpyo's default mxfile attributes with OCI-branded ones
|
|
xml = re.sub(
|
|
r'<mxfile[^>]*>',
|
|
'<mxfile host="app.diagrams.net" '
|
|
'agent="OCI Deal Accelerator" version="24.0.0" type="device">',
|
|
xml,
|
|
)
|
|
|
|
# Replace drawpyo's default diagram attributes
|
|
xml = re.sub(
|
|
r'<diagram[^>]*>',
|
|
'<diagram name="OCI Architecture" id="oci-arch">',
|
|
xml,
|
|
)
|
|
|
|
# Canvas: white background so region fill (#F0EDE9) is clearly visible.
|
|
# In absolute_layout mode we honor the spec's canvas so the page
|
|
# exports at the same dimensions as Oracle's reference (anything
|
|
# larger leaves empty whitespace that breaks visual fidelity diffs).
|
|
page_w = getattr(self, "_canvas_width", None) or 1900
|
|
page_h = getattr(self, "_canvas_height", None) or 1200
|
|
xml = re.sub(
|
|
r'<mxGraphModel[^>]*>',
|
|
f'<mxGraphModel dx="{page_w}" dy="{page_h}" grid="1" gridSize="10" '
|
|
f'guides="1" tooltips="1" connect="1" arrows="1" fold="1" '
|
|
f'page="1" pageScale="1" pageWidth="{page_w}" pageHeight="{page_h}" '
|
|
f'background="#FFFFFF" math="0" shadow="0">',
|
|
xml,
|
|
)
|
|
|
|
# Inject extra style attributes into edge cells (e.g., elbow, dashPattern)
|
|
for edge_id, extra_style in self._edge_extras.items():
|
|
# Find the edge cell by its drawpyo ID and append extra styles
|
|
pattern = f'id="{edge_id}" style="'
|
|
if pattern in xml:
|
|
xml = xml.replace(pattern, f'id="{edge_id}" style="{extra_style}')
|
|
|
|
# ── Edge label offset injection ──
|
|
# Push labels away from the edge midpoint to avoid overlapping with
|
|
# source/target icons. Uses mxGeometry relative positioning:
|
|
# x: position along edge (-1=source, 0=center, 1=target)
|
|
# <mxPoint as="offset">: absolute pixel offset from that position
|
|
# Horizontal edges: push label 15px above the line (offset y=-15)
|
|
# Vertical edges: push label 15px to the right (offset x=15)
|
|
for edge_id, (src_id, tgt_id) in self._edge_objects.items():
|
|
src_pos = self._abs_positions.get(src_id)
|
|
tgt_pos = self._abs_positions.get(tgt_id)
|
|
if not src_pos or not tgt_pos:
|
|
continue
|
|
src_obj = self._objects.get(src_id)
|
|
tgt_obj = self._objects.get(tgt_id)
|
|
if not src_obj or not tgt_obj:
|
|
continue
|
|
sx = src_pos[0] + (getattr(src_obj, 'width', 100) or 100) / 2
|
|
sy = src_pos[1] + (getattr(src_obj, 'height', 60) or 60) / 2
|
|
tx = tgt_pos[0] + (getattr(tgt_obj, 'width', 100) or 100) / 2
|
|
ty = tgt_pos[1] + (getattr(tgt_obj, 'height', 60) or 60) / 2
|
|
dx = tx - sx
|
|
dy = ty - sy
|
|
dist = max((dx**2 + dy**2) ** 0.5, 1)
|
|
# Larger offset for short connections to avoid icon overlap
|
|
base_offset = 20 if dist < 300 else 15
|
|
is_horiz = abs(dx) > abs(dy) * 1.5
|
|
if is_horiz:
|
|
offset_x, offset_y = 0, -base_offset
|
|
else:
|
|
offset_x, offset_y = base_offset, 0
|
|
# Find the edge's mxGeometry and inject label offset.
|
|
# drawpyo emits: <mxGeometry relative="1" as="geometry" />
|
|
# We replace with expanded form containing offset point.
|
|
new_geo = (
|
|
f'<mxGeometry x="-0.2" y="0" relative="1" as="geometry">'
|
|
f'<mxPoint x="{offset_x}" y="{offset_y}" as="offset" />'
|
|
f'</mxGeometry>'
|
|
)
|
|
# Match both self-closing variants that drawpyo may produce
|
|
for old_geo in [
|
|
'<mxGeometry relative="1" as="geometry" />',
|
|
'<mxGeometry relative="1" as="geometry"/>',
|
|
]:
|
|
marker = f'id="{edge_id}"'
|
|
if marker in xml and old_geo in xml:
|
|
# Only replace the mxGeometry that belongs to THIS edge
|
|
# Find the edge cell, then replace its geometry
|
|
idx = xml.find(marker)
|
|
if idx >= 0:
|
|
geo_idx = xml.find(old_geo, idx)
|
|
if geo_idx >= 0 and geo_idx - idx < 2000: # sanity: within same cell
|
|
xml = xml[:geo_idx] + new_geo + xml[geo_idx + len(old_geo):]
|
|
break
|
|
|
|
# Inject raw underlay/context cells immediately after the root
|
|
# scaffold so they sit behind drawpyo-authored objects.
|
|
if self._raw_underlay_cells:
|
|
inject = "\n".join(f" {c}" for c in self._raw_underlay_cells)
|
|
xml, replaced = re.subn(
|
|
r'(<mxCell id="1" parent="0"\s*/>)',
|
|
r"\1\n" + inject,
|
|
xml,
|
|
count=1,
|
|
)
|
|
if not replaced:
|
|
xml, replaced = re.subn(
|
|
r'(<mxCell parent="0" id="1"\s*/>)',
|
|
r"\1\n" + inject,
|
|
xml,
|
|
count=1,
|
|
)
|
|
if not replaced:
|
|
xml = xml.replace("</root>", inject + "\n </root>", 1)
|
|
|
|
# Inject raw icon stencil cells before </root>
|
|
if self._raw_cells:
|
|
inject = "\n".join(f" {c}" for c in self._raw_cells)
|
|
xml = xml.replace("</root>", inject + "\n </root>", 1)
|
|
|
|
# Replace placeholder parent references with actual drawpyo IDs
|
|
for cell_id, obj in self._objects.items():
|
|
placeholder = f"__DRAWPYO_{cell_id}__"
|
|
if placeholder in xml:
|
|
xml = xml.replace(placeholder, str(obj.id))
|
|
|
|
# Inject container=1;collapsible=0 into container styles via XML.
|
|
# drawpyo doesn't support 'container' as a settable property, so we
|
|
# append it to the style string in the final XML.
|
|
for container_marker in ["arcSize=1;", "arcSize=5;", "arcSize=3;",
|
|
"perimeterSpacing=0;", "spacingTop=4;"]:
|
|
# Only add to styles that already contain these OCI container markers
|
|
# and don't already have container=1
|
|
old = f'{container_marker}"'
|
|
new = f'{container_marker}container=1;collapsible=0;"'
|
|
xml = xml.replace(old, new)
|
|
|
|
# Sanitize: no pure white in OCI palette
|
|
xml = xml.replace('fill="#ffffff"', 'fill="#FCFBFA"')
|
|
xml = xml.replace('fill="#FFFFFF"', 'fill="#FCFBFA"')
|
|
xml = xml.replace("fillColor=#ffffff", "fillColor=#FCFBFA")
|
|
xml = xml.replace("fillColor=#FFFFFF", "fillColor=#FCFBFA")
|
|
|
|
# Fix drawpyo's default dash patterns to match OCI toolkit
|
|
# drawpyo emits dashed=1 without dashPattern → draw.io defaults to 3 3
|
|
# We ensure all dashed containers get 6 4 and dashed edges get 8 4
|
|
# (Edge dashPatterns are already injected via _edge_extras above)
|
|
|
|
return xml
|
|
|
|
def save(self, filepath: str, validate: bool = True,
|
|
reference_png: Optional[str] = None,
|
|
reference_svg: Optional[str] = None,
|
|
fidelity_threshold: float = 0.90,
|
|
strict: bool = False) -> dict:
|
|
"""Save to a .drawio file and run pre-delivery checks.
|
|
|
|
``validate`` (default True): runs ``drawio_visual_validator`` —
|
|
catches fontSize unit confusion, off-canvas geometry, dangling
|
|
edges, duplicate ids, compressed payloads. Errors are surfaced;
|
|
with ``strict=True`` they raise instead of warning.
|
|
|
|
``reference_png`` / ``reference_svg`` (optional): when provided
|
|
(typically from a fidelity-mode spec referencing an Oracle
|
|
Architecture Center asset), runs ``drawio_fidelity_eval`` to
|
|
pixel-diff the rebuilt drawio against the canonical PNG and
|
|
prints PASS/FAIL at ``fidelity_threshold``.
|
|
|
|
Returns a dict with the validation + fidelity payloads so callers
|
|
can inspect them programmatically.
|
|
"""
|
|
if self._flow_badges:
|
|
self._create_flow_badges()
|
|
xml = self.to_xml()
|
|
with open(filepath, 'w', encoding='utf-8') as f:
|
|
f.write(xml)
|
|
|
|
report: dict = {"path": filepath}
|
|
if validate:
|
|
report["validation"] = self._run_visual_validator(filepath, strict=strict)
|
|
if reference_png:
|
|
report["fidelity"] = self._run_fidelity_eval(
|
|
filepath, reference_png, reference_svg, fidelity_threshold,
|
|
)
|
|
return report
|
|
|
|
@staticmethod
|
|
def _run_visual_validator(filepath: str, strict: bool = False) -> dict:
|
|
"""Run drawio_visual_validator; print warnings/errors to stderr.
|
|
|
|
Returns the validator's report dict. Always non-fatal unless
|
|
``strict=True``.
|
|
"""
|
|
try:
|
|
try:
|
|
from drawio_visual_validator import validate_drawio
|
|
except ModuleNotFoundError:
|
|
from tools.drawio_visual_validator import validate_drawio
|
|
from pathlib import Path as _P
|
|
report = validate_drawio(_P(filepath))
|
|
except Exception as exc:
|
|
print(f"[validator] WARN: could not run validator: {exc}", file=sys.stderr)
|
|
return {"status": "error", "error": str(exc)}
|
|
if report.get("status") == "fail":
|
|
errors = [i for i in report.get("issues", []) if i.get("severity") == "error"]
|
|
print(f"[validator] FAIL on {filepath}: {len(errors)} error(s)", file=sys.stderr)
|
|
for issue in errors[:8]:
|
|
print(f" - {issue.get('code')}: {issue.get('message')}", file=sys.stderr)
|
|
if strict:
|
|
raise SystemExit(2)
|
|
else:
|
|
warnings = [i for i in report.get("issues", []) if i.get("severity") == "warn"]
|
|
if warnings:
|
|
print(f"[validator] OK on {filepath} ({len(warnings)} warning(s))", file=sys.stderr)
|
|
else:
|
|
print(f"[validator] OK on {filepath}", file=sys.stderr)
|
|
return report
|
|
|
|
@staticmethod
|
|
def _run_fidelity_eval(filepath: str, reference_png: str,
|
|
reference_svg: Optional[str],
|
|
threshold: float) -> dict:
|
|
"""Compare the rebuilt drawio against an official PNG.
|
|
|
|
Tries the official SVG companion path (cairosvg) first because it
|
|
works in any environment. The drawio.exe binary path is opt-in
|
|
via the DRAWIO_EXE env var so the skill doesn't depend on a
|
|
Windows-specific binary by default.
|
|
"""
|
|
from pathlib import Path as _P
|
|
out_png = _P(filepath).with_suffix(".rendered.png")
|
|
diff_png = _P(filepath).with_suffix(".diff.png")
|
|
try:
|
|
try:
|
|
from drawio_fidelity_eval import compare as _compare, render_svg, render_drawio
|
|
except ModuleNotFoundError:
|
|
from tools.drawio_fidelity_eval import compare as _compare, render_svg, render_drawio
|
|
method = None
|
|
if os.environ.get("DRAWIO_EXE"):
|
|
# Power-user opt-in: render the rebuilt drawio with the
|
|
# actual draw.io binary. Stricter than the SVG path but
|
|
# requires the binary to be installed.
|
|
try:
|
|
render_drawio(_P(filepath), out_png)
|
|
method = "drawio-binary"
|
|
except Exception as exc:
|
|
print(f"[fidelity] DRAWIO_EXE set but render failed ({exc}); falling back to SVG", file=sys.stderr)
|
|
if method is None and reference_svg and _P(reference_svg).exists():
|
|
render_svg(_P(reference_svg), out_png)
|
|
method = "svg-companion"
|
|
if method is None:
|
|
print("[fidelity] no rendering path available — set DRAWIO_EXE or pass reference_svg", file=sys.stderr)
|
|
return {"status": "skipped"}
|
|
metrics = _compare(_P(reference_png), out_png, diff_png)
|
|
sim = metrics["pixel_similarity"]
|
|
status = "pass" if sim >= threshold else "fail"
|
|
print(f"[fidelity] {status.upper()} sim={sim:.4f} (threshold {threshold}) method={method}", file=sys.stderr)
|
|
return {"status": status, "metrics": metrics, "method": method,
|
|
"rendered_path": str(out_png), "diff_path": str(diff_png)}
|
|
except Exception as exc:
|
|
print(f"[fidelity] WARN: could not run fidelity eval: {exc}", file=sys.stderr)
|
|
return {"status": "error", "error": str(exc)}
|
|
|
|
# ================================================================
|
|
# High-level: build from YAML spec
|
|
# ================================================================
|
|
|
|
@classmethod
|
|
def from_spec(cls, spec: dict) -> "OCIDiagramGenerator":
|
|
"""Build a diagram from a YAML specification.
|
|
|
|
The spec follows the OCI visual hierarchy:
|
|
|
|
tenancy -> region(s) -> vcn(s) -> subnet(s) -> service(s)
|
|
|
|
Plus optional on-premises, external actors, and connections.
|
|
See examples/diagram-spec.yaml for the full format.
|
|
"""
|
|
spec = _normalize_diagram_spec(spec)
|
|
gen = cls()
|
|
|
|
if spec.get("absolute_layout"):
|
|
# Spec-level geometry validation runs BEFORE rendering so the
|
|
# same regressions are caught for both the .drawio and .pptx
|
|
# paths (the post-render drawio validator only catches a
|
|
# subset). See tools/diagram_spec_validator.py.
|
|
try:
|
|
from diagram_spec_validator import validate_spec # type: ignore
|
|
except ImportError:
|
|
from tools.diagram_spec_validator import validate_spec # type: ignore
|
|
validate_spec(spec, source="absolute_layout (drawio)")
|
|
gen._render_absolute_layout(spec)
|
|
return gen
|
|
|
|
# Legacy workload-driven shape (tenancy → regions → compartments
|
|
# → services). Does NOT resolve OCI stencils — services render
|
|
# as colored rectangles with text labels — does NOT run through
|
|
# the spec validator, and bakes connector text into edge values
|
|
# (forbidden by the connector-labels rule). Output looks like a
|
|
# wireframe placeholder, not an architecture diagram. Loud
|
|
# warning so any agent that takes this path against the
|
|
# procedure docs sees the problem immediately, even if it
|
|
# skipped reading SKILL.md option 2.
|
|
import sys as _sys
|
|
if spec.get("tenancy") or spec.get("regions") or spec.get("clouds"):
|
|
print(
|
|
"[oci_diagram_gen] WARNING: spec uses the legacy "
|
|
"workload-driven shape (tenancy → regions → "
|
|
"compartments → services). This mode does NOT resolve "
|
|
"OCI icon stencils, does NOT run the spec validator, "
|
|
"and produces wireframe-looking output. The standard "
|
|
"procedure (SKILL.md option 2 / docs/skill/output-formats.md) "
|
|
"requires the `absolute_layout` shape. Re-author the "
|
|
"spec under `absolute_layout:` for any customer-facing "
|
|
"diagram.",
|
|
file=_sys.stderr,
|
|
)
|
|
|
|
# External actors (users, internet, third-party)
|
|
for ext in spec.get("external", []):
|
|
ext_id = ext.get("id", gen._next_id())
|
|
gen.add_external(
|
|
ext_id,
|
|
ext["label"].replace("\\n", "\n"),
|
|
x=ext.get("x", 30), y=ext.get("y", 30),
|
|
w=ext.get("w", 50), h=ext.get("h", 60),
|
|
icon=ext.get("icon", "user"),
|
|
)
|
|
|
|
# External cloud containers (AWS, Azure, GCP)
|
|
for cloud in spec.get("clouds", []):
|
|
cloud_id = cloud.get("id", gen._next_id())
|
|
gen.add_cloud(
|
|
cloud_id,
|
|
cloud["label"].replace("\\n", "\n"),
|
|
x=cloud.get("x", 10), y=cloud.get("y", 80),
|
|
w=cloud.get("w", 200), h=cloud.get("h", 300),
|
|
provider=cloud.get("provider", "generic"),
|
|
)
|
|
# Services inside the cloud container.
|
|
# cy=60: enough vertical room for 2-line cloud container titles at 12pt.
|
|
# Services are centered horizontally within the cloud container.
|
|
cloud_w = cloud.get("w", 200)
|
|
cy = 60
|
|
for svc in cloud.get("services", []):
|
|
svc_id = svc.get("id", gen._next_id())
|
|
svc_w = svc.get("w", 100)
|
|
svc_h = svc.get("h", 80)
|
|
# Center each service horizontally in the cloud container
|
|
cx = max(10, (cloud_w - svc_w) // 2)
|
|
label = svc["label"].replace("\\n", "\n")
|
|
cloud_icon = svc.get("cloud_icon")
|
|
if cloud_icon:
|
|
# Use cloud provider icon (AWS/GCP) via stencil style
|
|
style = _aws_icon_style(cloud_icon) or _gcp_icon_style(cloud_icon)
|
|
if style:
|
|
gen._create_object(
|
|
svc_id, label, style, cloud_id,
|
|
cx, cy, svc_w, svc_h,
|
|
)
|
|
gen._service_count += 1
|
|
else:
|
|
gen.add_service(svc_id, label, svc.get("type", "compute"),
|
|
cloud_id, cx, cy, svc_w, svc_h)
|
|
else:
|
|
gen.add_service(svc_id, label, svc.get("type", "compute"),
|
|
cloud_id, cx, cy, svc_w, svc_h)
|
|
actual_h = gen._calc_service_block_h(label, svc.get("type", "compute"))
|
|
cy += max(svc_h, actual_h) + 20
|
|
|
|
# Tenancy — auto-size from regions if w/h not specified in spec
|
|
tenancy_spec = spec.get("tenancy", {})
|
|
tenancy_x = tenancy_spec.get("x", 30)
|
|
tenancy_y = tenancy_spec.get("y", 80)
|
|
|
|
# Pre-calculate tenancy size from region content if not explicit
|
|
# Always pre-calculate auto-size even if spec has dimensions (use max)
|
|
if True:
|
|
regions = tenancy_spec.get("regions", [])
|
|
max_region_right = 0
|
|
max_region_bottom = 0
|
|
region_x_cursor = 15
|
|
for r in regions:
|
|
# Estimate region dimensions (will be refined when regions are created)
|
|
r_vcns = r.get("vcns", [])
|
|
r_has_drg = any(
|
|
gw.get("type") in ("drg", "dynamic_routing_gateway")
|
|
for v in r_vcns for gw in v.get("gateways", [])
|
|
)
|
|
drg_w = 120 if r_has_drg else 0
|
|
# Subnet height calc (mirrors actual rendering)
|
|
total_sub_h = 0
|
|
max_sub_w = 300
|
|
for v in r_vcns:
|
|
for s in v.get("subnets", []):
|
|
auto_sh = cls._calc_subnet_h(s)
|
|
total_sub_h += max(s.get("h", auto_sh), auto_sh) + cls.SUBNET_GAP
|
|
svcs = s.get("services", [])
|
|
row_w = sum(sv.get("w", 150) for sv in svcs) + cls.SVC_SPACING * max(0, len(svcs) - 1) + 30
|
|
max_sub_w = max(max_sub_w, row_w)
|
|
vcn_gws = [gw for gw in v.get("gateways", [])
|
|
if gw.get("type") not in ("drg", "dynamic_routing_gateway")]
|
|
if vcn_gws:
|
|
max_sub_w += max(gw.get("w", 110) for gw in vcn_gws) + 20
|
|
|
|
est_rw = max_sub_w + drg_w + 80
|
|
est_rh = total_sub_h + 150 # 45 top + VCN_TOP_PAD(50) + bottom padding + margins
|
|
|
|
rx_val = r.get("x", region_x_cursor)
|
|
ry_val = r.get("y", 40)
|
|
max_region_right = max(max_region_right, rx_val + est_rw)
|
|
max_region_bottom = max(max_region_bottom, ry_val + est_rh)
|
|
region_x_cursor = max_region_right + 20
|
|
|
|
auto_tw = max(max_region_right + 30, 600)
|
|
auto_th = max(max_region_bottom + 30, 400)
|
|
tenancy_w = max(tenancy_spec.get("w", auto_tw), auto_tw)
|
|
tenancy_h = max(tenancy_spec.get("h", auto_th), auto_th)
|
|
|
|
gen.add_tenancy(
|
|
"tenancy",
|
|
tenancy_spec.get("label", "Oracle Cloud Infrastructure"),
|
|
x=tenancy_x, y=tenancy_y, w=tenancy_w, h=tenancy_h,
|
|
)
|
|
|
|
# Regions — auto-size containers from content when spec doesn't override
|
|
region_x_cursor = 15 # tracks right edge for auto-positioning next region
|
|
drg_by_region = [] # list of (region_id, drg_id) for cross-region RPC auto-wiring
|
|
for region in tenancy_spec.get("regions", []):
|
|
is_primary = region.get("primary", False)
|
|
rx = region.get("x", region_x_cursor)
|
|
ry = region.get("y", 40)
|
|
|
|
# ── Pre-calculate content size for auto-sizing ──
|
|
# Walk all VCNs → subnets → services to compute minimum dimensions
|
|
region_vcns = region.get("vcns", [])
|
|
auto_rw, auto_rh = 640 if not is_primary else 1140, 480 if not is_primary else 670
|
|
|
|
if region_vcns:
|
|
# Calculate total height needed from subnets + gateways
|
|
for vcn in region_vcns:
|
|
gateways = vcn.get("gateways", [])
|
|
# Separate DRG from VCN-internal gateways (IGW, NAT, SGW)
|
|
drg_gateways = [gw for gw in gateways if gw.get("type") in ("drg", "dynamic_routing_gateway")]
|
|
vcn_gateways = [gw for gw in gateways if gw.get("type") not in ("drg", "dynamic_routing_gateway")]
|
|
|
|
gw_lane_w = 0
|
|
if vcn_gateways:
|
|
gw_lane_w = max(gw.get("w", 110) for gw in vcn_gateways) + 20
|
|
|
|
subnets = vcn.get("subnets", [])
|
|
|
|
# Calculate actual subnet heights (same logic as rendering)
|
|
total_subnet_h = 0
|
|
max_svc_row_w = 0
|
|
for subnet in subnets:
|
|
auto_sh = cls._calc_subnet_h(subnet)
|
|
sh = max(subnet.get("h", auto_sh), auto_sh)
|
|
total_subnet_h += sh + cls.SUBNET_GAP
|
|
|
|
svcs = subnet.get("services", [])
|
|
row_w = sum(svc.get("w", 150) for svc in svcs) + cls.SVC_SPACING * max(0, len(svcs) - 1) + 30
|
|
max_svc_row_w = max(max_svc_row_w, row_w)
|
|
|
|
# VCN = gw_lane + content + margins
|
|
content_w = gw_lane_w + max(max_svc_row_w, 300) + 40
|
|
content_h = cls.VCN_TOP_PAD + total_subnet_h + 20
|
|
|
|
# Also account for gateway column height
|
|
if vcn_gateways:
|
|
total_gw_h = sum(gw.get("h", cls.GW_DEFAULT_H) for gw in vcn_gateways) + cls.GW_SPACING * max(0, len(vcn_gateways) - 1) + 64
|
|
content_h = max(content_h, total_gw_h)
|
|
|
|
# Store calculated VCN dimensions for use below
|
|
vcn["_auto_w"] = content_w
|
|
vcn["_auto_h"] = content_h
|
|
vcn["_drg_gateways"] = drg_gateways
|
|
vcn["_vcn_gateways"] = vcn_gateways
|
|
|
|
# Region size = VCN size + margins + DRG lane
|
|
total_vcn_w = max(v.get("_auto_w", 600) for v in region_vcns)
|
|
total_vcn_h = max(v.get("_auto_h", 400) for v in region_vcns)
|
|
# DRG lane on left edge of region (outside VCN)
|
|
has_drg = any(v.get("_drg_gateways") for v in region_vcns)
|
|
drg_lane_w = 120 if has_drg else 0
|
|
auto_rw = total_vcn_w + drg_lane_w + 50 # margins
|
|
auto_rh = total_vcn_h + 70 # 45 top (label) + 25 bottom
|
|
|
|
# Local DR standby (same region as primary) — adds a dormant
|
|
# node below the VCNs. Bump auto-height to make room.
|
|
local_dr = bool(region.get("local_dr") or region.get("local_dr_standby"))
|
|
if not local_dr:
|
|
for v in region_vcns:
|
|
if v.get("local_dr") or v.get("local_dr_standby"):
|
|
local_dr = True
|
|
break
|
|
local_dr_h = 90 if local_dr else 0
|
|
if local_dr:
|
|
auto_rh += local_dr_h
|
|
|
|
# Always use at least auto-calculated size even if spec is smaller
|
|
rw = max(region.get("w", auto_rw), auto_rw)
|
|
rh = max(region.get("h", auto_rh), auto_rh)
|
|
|
|
gen.add_region(
|
|
region["id"],
|
|
region["label"],
|
|
"tenancy", rx, ry, rw, rh,
|
|
)
|
|
# Advance cursor for next region positioning
|
|
region_x_cursor = rx + rw + 20
|
|
|
|
# Availability Domains (optional)
|
|
for ad in region.get("availability_domains", []):
|
|
gen.add_ad(
|
|
ad["id"], ad["label"], region["id"],
|
|
x=ad.get("x", 15), y=ad.get("y", 30),
|
|
w=ad.get("w", rw - 30), h=ad.get("h", rh - 70),
|
|
)
|
|
|
|
# ── DRG placement: OUTSIDE VCN, INSIDE region ──
|
|
# Oracle ref arch pattern: DRG sits in the region between external
|
|
# actors and VCNs, not inside any VCN.
|
|
has_drg = any(v.get("_drg_gateways") for v in region_vcns)
|
|
drg_lane_w = 120 if has_drg else 0
|
|
for vcn in region_vcns:
|
|
for drg in vcn.get("_drg_gateways", []):
|
|
drg_id = drg.get("id", gen._next_id())
|
|
drg_w = drg.get("w", 90)
|
|
drg_h = drg.get("h", 60)
|
|
# Position: left edge of region, vertically centered
|
|
drg_x = 15
|
|
drg_y = max(45, (rh - drg_h) // 2)
|
|
label = drg["label"].replace("\\n", "\n")
|
|
gen.add_service(
|
|
drg_id, label, drg["type"],
|
|
region["id"], drg_x, drg_y, drg_w, drg_h,
|
|
)
|
|
drg_by_region.append((region["id"], drg_id))
|
|
|
|
# VCNs — offset right if DRG lane present
|
|
vcn_offset_x = drg_lane_w + 15
|
|
for vcn in region_vcns:
|
|
vcn_parent = vcn.get("parent", region["id"])
|
|
auto_w = vcn.get("_auto_w", rw - vcn_offset_x - 15)
|
|
auto_h = vcn.get("_auto_h", rh - 70)
|
|
# Always use at least auto-calculated size even if spec is smaller
|
|
vcn_w = max(vcn.get("w", auto_w), auto_w)
|
|
vcn_h = max(vcn.get("h", auto_h), auto_h)
|
|
gen.add_vcn(
|
|
vcn["id"],
|
|
f"VCN {vcn['label']}",
|
|
vcn_parent,
|
|
x=vcn.get("x", vcn_offset_x), y=vcn.get("y", 45),
|
|
w=vcn_w, h=vcn_h,
|
|
)
|
|
|
|
# ── VCN-internal gateways (IGW, NAT, SGW — NOT DRG) ──
|
|
vcn_gateways = vcn.get("_vcn_gateways", [])
|
|
gw_lane_w = 0
|
|
|
|
if vcn_gateways:
|
|
GW_DEFAULT_W = 110
|
|
GW_DEFAULT_H = gen.GW_DEFAULT_H
|
|
GW_SPACING = gen.GW_SPACING
|
|
gw_lane_w = max(gw.get("w", GW_DEFAULT_W) for gw in vcn_gateways) + 20
|
|
total_gw_h = sum(gw.get("h", GW_DEFAULT_H) for gw in vcn_gateways) + GW_SPACING * max(0, len(vcn_gateways) - 1)
|
|
gw_start_y = max(32, (vcn_h - total_gw_h) // 2)
|
|
gy = gw_start_y
|
|
for gw in vcn_gateways:
|
|
gw_id = gw.get("id", gen._next_id())
|
|
gw_w = gw.get("w", GW_DEFAULT_W)
|
|
gw_x = gw.get("x", 10)
|
|
gw_y = gw.get("y", gy)
|
|
label = gw["label"].replace("\\n", "\n")
|
|
# Calculate ACTUAL rendered block height for correct stacking
|
|
actual_block_h = gen._calc_service_block_h(label, gw["type"])
|
|
gw_h = max(gw.get("h", GW_DEFAULT_H), GW_DEFAULT_H, actual_block_h)
|
|
gen.add_service(
|
|
gw_id, label, gw["type"],
|
|
vcn["id"], gw_x, gw_y, gw_w, gw_h,
|
|
)
|
|
# Stack using actual rendered height, not spec height
|
|
gy = gw_y + actual_block_h + GW_SPACING
|
|
|
|
# Subnet offset — Oracle ref arch: 42px left indent, 50px top
|
|
VCN_LEFT_PAD = 42 if not vcn_gateways else 8
|
|
VCN_TOP_PAD = gen.VCN_TOP_PAD
|
|
SUBNET_GAP = gen.SUBNET_GAP
|
|
|
|
subnet_x = VCN_LEFT_PAD + gw_lane_w
|
|
subnet_avail_w = vcn_w - subnet_x - 16 # 16px right margin
|
|
|
|
# Stack subnets vertically (top-to-bottom)
|
|
subnet_y = VCN_TOP_PAD
|
|
SVC_SPACING = gen.SVC_SPACING
|
|
for subnet in vcn.get("subnets", []):
|
|
sw = subnet.get("w", subnet_avail_w)
|
|
subnet_svcs = subnet.get("services", [])
|
|
|
|
# ── Auto-calculate subnet height from service content ──
|
|
# Each service = icon group (MIN 60px, grows for tall icons) + label + gap
|
|
# Icon group grows based on aspect ratio (see _add_service_with_icon)
|
|
# Use shared auto-height calculation
|
|
auto_sh = gen._calc_subnet_h(subnet)
|
|
sh = max(subnet.get("h", auto_sh), auto_sh)
|
|
# Calculate max service block height for layout
|
|
max_svc_h = auto_sh - 30 - 15 # reverse: total - top_pad - bottom_pad
|
|
|
|
gen.add_subnet(
|
|
subnet["id"], subnet["label"],
|
|
vcn["id"], subnet_x, subnet_y, sw, sh,
|
|
)
|
|
|
|
# Auto-layout services HORIZONTALLY within subnet, centered.
|
|
svc_top = 30
|
|
svc_widths = [svc.get("w", min(sw - 30, 150)) for svc in subnet_svcs]
|
|
total_svc_w = sum(svc_widths) + SVC_SPACING * max(0, len(svc_widths) - 1)
|
|
svc_x = max(15, (sw - total_svc_w) // 2)
|
|
for svc, svc_w in zip(subnet_svcs, svc_widths):
|
|
svc_id = svc.get("id", gen._next_id())
|
|
# Use auto-calculated height (ensures icons fit at MIN_ICON_H)
|
|
svc_h = max(svc.get("h", 80), max_svc_h)
|
|
label = svc["label"].replace("\\n", "\n")
|
|
gen.add_service(
|
|
svc_id, label, svc["type"],
|
|
subnet["id"], svc_x, svc_top, svc_w, svc_h,
|
|
font_size=svc.get("fontSize"),
|
|
tone=svc.get("tone"),
|
|
badges=svc.get("badges"),
|
|
caption=svc.get("caption"),
|
|
)
|
|
svc_x += svc_w + SVC_SPACING
|
|
|
|
subnet_y += sh + SUBNET_GAP
|
|
|
|
# Local DR standby — render a dormant node inside the region
|
|
# at the bottom-right corner so it's visually clear that the
|
|
# primary has a same-region failover target.
|
|
if local_dr:
|
|
standby_w = 160
|
|
standby_h = 70
|
|
standby_x = max(15, rw - standby_w - 20)
|
|
standby_y = rh - standby_h - 15
|
|
standby_label = (
|
|
region.get("local_dr_label")
|
|
or region.get("local_dr_standby_label")
|
|
or "Local DR\nStandby"
|
|
)
|
|
standby_id = region.get("local_dr_id") or gen._next_id()
|
|
gen.add_service(
|
|
standby_id, standby_label, "standby",
|
|
region["id"], standby_x, standby_y, standby_w, standby_h,
|
|
)
|
|
|
|
# Compartments inside region — can contain VCNs
|
|
for comp in region.get("compartments", []):
|
|
comp_id = comp.get("id", gen._next_id())
|
|
comp_w = comp.get("w", rw - 20)
|
|
comp_h = comp.get("h", 60)
|
|
gen.add_compartment(
|
|
comp_id, comp["label"],
|
|
region["id"],
|
|
x=comp.get("x", 10), y=comp.get("y", rh - 80),
|
|
w=comp_w, h=comp_h,
|
|
tone=comp.get("tone"),
|
|
)
|
|
# VCNs inside compartment
|
|
for vcn in comp.get("vcns", []):
|
|
vcn_parent = comp_id
|
|
vcn_w = vcn.get("w", comp_w - 20)
|
|
vcn_h = vcn.get("h", comp_h - 50)
|
|
gen.add_vcn(
|
|
vcn["id"],
|
|
f"VCN {vcn['label']}",
|
|
vcn_parent,
|
|
x=vcn.get("x", 10), y=vcn.get("y", 45),
|
|
w=vcn_w, h=vcn_h,
|
|
)
|
|
subnet_y = 32
|
|
for subnet in vcn.get("subnets", []):
|
|
sw = subnet.get("w", vcn_w - 16)
|
|
sh = subnet.get("h", 120)
|
|
gen.add_subnet(
|
|
subnet["id"], subnet["label"],
|
|
vcn["id"], 8, subnet_y, sw, sh,
|
|
)
|
|
svc_top = 30
|
|
comp_svcs = subnet.get("services", [])
|
|
comp_svc_ws = [svc.get("w", min(sw - 30, 150)) for svc in comp_svcs]
|
|
total_csvc_w = sum(comp_svc_ws) + 12 * max(0, len(comp_svc_ws) - 1)
|
|
svc_x = max(15, (sw - total_csvc_w) // 2)
|
|
for svc, svc_w in zip(comp_svcs, comp_svc_ws):
|
|
svc_id = svc.get("id", gen._next_id())
|
|
svc_h = svc.get("h", 80)
|
|
label = svc["label"].replace("\\n", "\n")
|
|
gen.add_service(
|
|
svc_id, label, svc["type"],
|
|
subnet["id"], svc_x, svc_top, svc_w, svc_h,
|
|
font_size=svc.get("fontSize"),
|
|
)
|
|
svc_x += svc_w + 16
|
|
subnet_y += sh + 28 # Oracle ref: subnet gap
|
|
# Gateways on left side of compartment VCN, stacked vertically
|
|
gx = 10
|
|
gy = 40
|
|
for gw in vcn.get("gateways", []):
|
|
gw_id = gw.get("id", gen._next_id())
|
|
gw_w = gw.get("w", 95)
|
|
gw_x = gw.get("x", gx)
|
|
gw_y = gw.get("y", gy)
|
|
label = gw["label"].replace("\\n", "\n")
|
|
actual_h = gen._calc_service_block_h(label, gw["type"])
|
|
gw_h = max(gw.get("h", actual_h), actual_h)
|
|
gen.add_service(
|
|
gw_id, label, gw["type"],
|
|
vcn["id"], gw_x, gw_y, gw_w, gw_h,
|
|
)
|
|
gy = gw_y + actual_h + cls.GW_SPACING
|
|
|
|
# Services directly inside a compartment. This avoids forcing
|
|
# artificial VCN/subnet structures when the user only wants a
|
|
# logical database lineage view. Default layout stacks
|
|
# services vertically; `layout: horizontal` lays them out
|
|
# side-by-side instead (useful for workload "rows").
|
|
comp_services = comp.get("services", [])
|
|
if comp_services:
|
|
comp_layout = comp.get("layout", "vertical")
|
|
if comp_layout == "horizontal":
|
|
n = len(comp_services)
|
|
gap = 20
|
|
total_gap = gap * max(0, n - 1)
|
|
default_svc_w = max(150, (comp_w - 30 - total_gap) // n)
|
|
svc_x = 15
|
|
svc_top = 36
|
|
for svc in comp_services:
|
|
svc_id = svc.get("id", gen._next_id())
|
|
svc_w = svc.get("w", default_svc_w)
|
|
label = svc["label"].replace("\\n", "\n")
|
|
svc_h = max(
|
|
svc.get("h", 100),
|
|
gen._calc_service_block_h(label, svc["type"]),
|
|
)
|
|
gen.add_service(
|
|
svc_id, label, svc["type"],
|
|
comp_id, svc_x, svc_top, svc_w, svc_h,
|
|
font_size=svc.get("fontSize"),
|
|
tone=svc.get("tone"),
|
|
badges=svc.get("badges"),
|
|
caption=svc.get("caption"),
|
|
)
|
|
svc_x += svc_w + gap
|
|
else:
|
|
svc_top = 28
|
|
svc_width = max(140, comp_w - 20)
|
|
svc_left = 10
|
|
for svc in comp_services:
|
|
svc_id = svc.get("id", gen._next_id())
|
|
svc_h = max(svc.get("h", 80), gen._calc_service_block_h(
|
|
svc.get("label", "").replace("\\n", "\n"),
|
|
svc.get("type", "compute"),
|
|
))
|
|
label = svc["label"].replace("\\n", "\n")
|
|
gen.add_service(
|
|
svc_id, label, svc["type"],
|
|
comp_id, svc_left, svc_top, svc_width, svc_h,
|
|
font_size=svc.get("fontSize"),
|
|
tone=svc.get("tone"),
|
|
badges=svc.get("badges"),
|
|
caption=svc.get("caption"),
|
|
)
|
|
svc_top += svc_h + 20
|
|
|
|
# Nested compartments — used for AVMC → ACD → PDBs style
|
|
# hierarchies and any other case where a logical container
|
|
# needs to sit inside another. Coordinates are relative to
|
|
# the parent compartment.
|
|
def _render_nested_compartments(parent_id: str, parent_w: int,
|
|
parent_h: int, nested_list: list):
|
|
for nc in nested_list:
|
|
nc_id = nc.get("id", gen._next_id())
|
|
nc_w = nc.get("w", parent_w - 24)
|
|
nc_h = nc.get("h", parent_h - 40)
|
|
nc_x = nc.get("x", 12)
|
|
nc_y = nc.get("y", 36)
|
|
gen.add_compartment(
|
|
nc_id, nc["label"], parent_id,
|
|
x=nc_x, y=nc_y, w=nc_w, h=nc_h,
|
|
tone=nc.get("tone"),
|
|
)
|
|
# Direct services inside the nested compartment.
|
|
nc_services = nc.get("services", []) or []
|
|
if nc_services:
|
|
stop = 30
|
|
svc_width = max(130, nc_w - 20)
|
|
for svc in nc_services:
|
|
svc_id = svc.get("id", gen._next_id())
|
|
svc_lbl = svc.get("label", "").replace("\\n", "\n")
|
|
svc_h = max(svc.get("h", 80),
|
|
gen._calc_service_block_h(svc_lbl, svc.get("type", "compute")))
|
|
gen.add_service(
|
|
svc_id, svc_lbl, svc.get("type", "compute"),
|
|
nc_id, 10, stop, svc_width, svc_h,
|
|
font_size=svc.get("fontSize"),
|
|
tone=svc.get("tone"),
|
|
badges=svc.get("badges"),
|
|
caption=svc.get("caption"),
|
|
)
|
|
stop += svc_h + 18
|
|
# Recurse for deeper nesting (ACD inside AVMC, etc.).
|
|
_render_nested_compartments(
|
|
nc_id, nc_w, nc_h, nc.get("compartments", []) or [],
|
|
)
|
|
|
|
_render_nested_compartments(
|
|
comp_id, comp_w, comp_h, comp.get("compartments", []) or [],
|
|
)
|
|
|
|
# Services directly inside the region when no VCN/compartment
|
|
# structure is needed and the diagram is purely logical.
|
|
region_services = region.get("services", [])
|
|
if region_services:
|
|
svc_top = 65
|
|
svc_left = 24
|
|
svc_width = max(160, rw - 48)
|
|
for svc in region_services:
|
|
svc_id = svc.get("id", gen._next_id())
|
|
svc_h = max(svc.get("h", 80), gen._calc_service_block_h(
|
|
svc.get("label", "").replace("\\n", "\n"),
|
|
svc.get("type", "compute"),
|
|
))
|
|
label = svc["label"].replace("\\n", "\n")
|
|
gen.add_service(
|
|
svc_id, label, svc["type"],
|
|
region["id"], svc_left, svc_top, svc_width, svc_h,
|
|
font_size=svc.get("fontSize"),
|
|
tone=svc.get("tone"),
|
|
badges=svc.get("badges"),
|
|
caption=svc.get("caption"),
|
|
)
|
|
svc_top += svc_h + 20
|
|
|
|
# Observability row at bottom of region — uses icons (only if explicitly requested)
|
|
if region.get("observability"):
|
|
obs_icon_w = 90
|
|
obs_icon_h = 80
|
|
ox = 20
|
|
oy = rh - obs_icon_h - 8
|
|
for obs_label in region["observability"]:
|
|
obs_id = gen._next_id()
|
|
obs_type = OBS_TYPE_MAP.get(obs_label.lower(), None)
|
|
if obs_type:
|
|
gen.add_service(
|
|
obs_id, obs_label, obs_type,
|
|
region["id"], ox, oy, obs_icon_w, obs_icon_h,
|
|
font_size=10,
|
|
)
|
|
else:
|
|
gen.add_obs_bar(
|
|
obs_id, obs_label, region["id"],
|
|
ox, oy + obs_icon_h - 22, 80, 22,
|
|
)
|
|
ox += obs_icon_w + 8
|
|
|
|
# On-premises
|
|
if spec.get("onprem"):
|
|
op = spec["onprem"]
|
|
op_x = op.get("x", 30)
|
|
op_y = op.get("y", tenancy_y + tenancy_h + 20)
|
|
op_w = op.get("w", 750)
|
|
op_h = op.get("h", 120)
|
|
gen.add_onprem("onprem", op.get("label", "On-Premises Data Center"),
|
|
x=op_x, y=op_y, w=op_w, h=op_h)
|
|
opx = 20
|
|
for svc in op.get("services", []):
|
|
svc_id = svc.get("id", gen._next_id())
|
|
svc_w = svc.get("w", 180)
|
|
svc_h = svc.get("h", 60)
|
|
label = svc["label"].replace("\\n", "\n")
|
|
gen.add_service(
|
|
svc_id, label, svc["type"],
|
|
"onprem", opx, 30, svc_w, svc_h,
|
|
)
|
|
opx += svc_w + 12
|
|
|
|
# Cross-region RPC — when 2+ regions own a DRG and the spec didn't
|
|
# already wire a peering link between them, auto-add a "Remote
|
|
# Peering Connection" edge for each pair so the cross-region path
|
|
# is visible in the diagram.
|
|
connections = spec.get("connections", [])
|
|
if len(drg_by_region) >= 2:
|
|
existing_pairs = {
|
|
(c.get("from"), c.get("to")) for c in connections
|
|
if isinstance(c, dict)
|
|
}
|
|
existing_pairs |= {(b, a) for (a, b) in existing_pairs}
|
|
seen_drgs_per_region = {}
|
|
for region_id, drg_id in drg_by_region:
|
|
seen_drgs_per_region.setdefault(region_id, []).append(drg_id)
|
|
region_ids = list(seen_drgs_per_region.keys())
|
|
for i in range(len(region_ids)):
|
|
for j in range(i + 1, len(region_ids)):
|
|
src = seen_drgs_per_region[region_ids[i]][0]
|
|
tgt = seen_drgs_per_region[region_ids[j]][0]
|
|
if (src, tgt) in existing_pairs:
|
|
continue
|
|
connections = list(connections) + [{
|
|
"id": f"rpc_{src}_{tgt}",
|
|
"from": src,
|
|
"to": tgt,
|
|
"type": "network",
|
|
"label": "RPC\n(Remote Peering)",
|
|
}]
|
|
existing_pairs.add((src, tgt))
|
|
existing_pairs.add((tgt, src))
|
|
|
|
# Connections — merge duplicate from/to pairs (e.g., dual FastConnect)
|
|
# into a single edge with combined label
|
|
seen_pairs = {} # (from, to) -> index in deduped list
|
|
deduped = []
|
|
for conn in connections:
|
|
pair = (conn["from"], conn["to"])
|
|
if pair in seen_pairs:
|
|
# Merge: combine labels
|
|
existing = deduped[seen_pairs[pair]]
|
|
old_label = existing.get("label", "")
|
|
new_label = conn.get("label", "")
|
|
if old_label and new_label and old_label != new_label:
|
|
existing["label"] = f"{old_label}\n+ {new_label}"
|
|
elif new_label and not old_label:
|
|
existing["label"] = new_label
|
|
# Upgrade: if either is "Dual", keep it in label
|
|
else:
|
|
seen_pairs[pair] = len(deduped)
|
|
deduped.append(dict(conn))
|
|
|
|
for conn in deduped:
|
|
conn_id = conn.get("id", gen._next_id())
|
|
gen.add_connection(
|
|
conn_id, conn.get("label"), conn["type"],
|
|
conn["from"], conn["to"],
|
|
waypoints=conn.get("waypoints"),
|
|
flow_order=conn.get("flow_order"),
|
|
)
|
|
|
|
# Title — centered across the full diagram width. An optional
|
|
# ``banner`` field at the spec level ("red", "green", ...) renders
|
|
# the title as a coloured strip (used for state headers like the
|
|
# Oracle-red AS IS banner).
|
|
if spec.get("title"):
|
|
total_w = tenancy_x + tenancy_w
|
|
gen.add_title(
|
|
spec["title"],
|
|
x=0, y=8, w=total_w, h=35,
|
|
banner=spec.get("banner"),
|
|
)
|
|
|
|
# Diagram-level callouts — rendered either above (before tenancy) or
|
|
# below (after tenancy) the main topology. Multiple callouts in the
|
|
# same position stack vertically so they don't overlap.
|
|
bottom_cursor_y = tenancy_y + tenancy_h + 18
|
|
top_cursor_y = tenancy_y
|
|
for i, callout in enumerate(spec.get("callouts") or []):
|
|
cid = callout.get("id", f"callout_{i}")
|
|
color = callout.get("color", "red")
|
|
position = callout.get("position", "bottom")
|
|
cw = callout.get("w", tenancy_w)
|
|
ch = callout.get("h", 60)
|
|
cx = callout.get("x", tenancy_x)
|
|
if position == "top":
|
|
cy = callout.get("y", max(55, top_cursor_y - ch - 12))
|
|
top_cursor_y = cy
|
|
else:
|
|
cy = callout.get("y", bottom_cursor_y)
|
|
bottom_cursor_y = cy + ch + 10
|
|
gen.add_callout(cid, callout.get("text", ""), color, cx, cy, cw, ch)
|
|
|
|
return gen
|
|
|
|
def _render_absolute_layout(self, spec: dict) -> None:
|
|
"""Render a coordinate-faithful Architecture Center benchmark layout.
|
|
|
|
This mode is intentionally explicit. Reference diagrams often place
|
|
regional services outside the VCN, use non-grid subnet positions, and
|
|
annotate process flows with numbered badges. Autolayout is useful for
|
|
proposals; benchmark reconstruction needs fixed coordinates.
|
|
|
|
``fontSize`` is shared with the PPTX absolute_layout, which uses
|
|
hundredths of a point (e.g. 720 = 7.2pt). drawio expects raw
|
|
points, so we collapse anything ≥80 by /100 — there is no
|
|
legitimate drawio diagram with a 80pt+ label.
|
|
"""
|
|
layout = spec.get("absolute_layout") or {}
|
|
canvas = layout.get("canvas") or {}
|
|
# Track canvas so save() can size the page to match the spec.
|
|
# Expand to include any item whose bounding box overflows the
|
|
# declared canvas — Oracle's caption boxes routinely sit a few
|
|
# pixels past the visible region, and a clipped page would
|
|
# rasterize to a different aspect than the canonical PNG.
|
|
cw = int(canvas.get("width", 0)) or None
|
|
ch = int(canvas.get("height", 0)) or None
|
|
for bucket in ("containers", "services", "labels", "external"):
|
|
for item in layout.get(bucket) or []:
|
|
try:
|
|
x = int(item.get("x", 0)); y = int(item.get("y", 0))
|
|
w = int(item.get("w", 0)); h = int(item.get("h", 0))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if cw is not None: cw = max(cw, x + w)
|
|
if ch is not None: ch = max(ch, y + h)
|
|
self._canvas_width = cw
|
|
self._canvas_height = ch
|
|
self._raw_underlay_cells = list(layout.get("raw_underlay_cells") or [])
|
|
|
|
def _pt(value):
|
|
if value is None:
|
|
return None
|
|
try:
|
|
v = float(value)
|
|
except (TypeError, ValueError):
|
|
return value
|
|
if v >= 80:
|
|
v = v / 100.0
|
|
return max(6, int(round(v)))
|
|
|
|
for item in layout.get("containers") or []:
|
|
item_id = item.get("id", self._next_id())
|
|
label = str(item.get("label", "")).replace("\\n", "\n")
|
|
kind = item.get("type", "compartment")
|
|
cloud = (item.get("cloud") or "").lower() # aws | gcp | azure | "" (OCI)
|
|
default_parent = {
|
|
"region": "tenancy",
|
|
"ad": "region",
|
|
"vcn": "region",
|
|
"subnet": "vcn",
|
|
}.get(kind, "region")
|
|
parent = item["parent"] if "parent" in item else default_parent
|
|
x, y = int(item.get("x", 0)), int(item.get("y", 0))
|
|
w, h = int(item.get("w", 100)), int(item.get("h", 80))
|
|
# Multi-cloud container styling: when the spec marks a
|
|
# container with `cloud: aws|gcp|azure`, use that cloud's
|
|
# native style (color, dash, font) so a multi-cloud diagram
|
|
# is instantly readable without reading every label. The
|
|
# mapping covers the common kinds (region, vpc/vnet, subnet,
|
|
# az). Falls through to OCI styles otherwise.
|
|
cloud_style_key = None
|
|
if cloud in {"aws", "gcp", "azure"}:
|
|
kind_map = {
|
|
("aws", "region"): "aws_region",
|
|
("aws", "vpc"): "aws_vpc",
|
|
("aws", "subnet"): "aws_subnet_private",
|
|
("aws", "subnet_public"): "aws_subnet_public",
|
|
("aws", "subnet_private"): "aws_subnet_private",
|
|
("aws", "az"): "aws_az",
|
|
("aws", "ad"): "aws_az",
|
|
("gcp", "project"): "gcp_project",
|
|
("gcp", "region"): "gcp_region",
|
|
("gcp", "vpc"): "gcp_vpc",
|
|
("gcp", "subnet"): "gcp_subnet",
|
|
("azure", "subscription"): "azure_subscription",
|
|
("azure", "region"): "azure_region",
|
|
("azure", "vnet"): "azure_vnet",
|
|
("azure", "subnet"): "azure_subnet",
|
|
}
|
|
cloud_style_key = kind_map.get((cloud, kind))
|
|
if cloud_style_key and cloud_style_key in CLOUD_CONTAINER_STYLES:
|
|
self._create_object(item_id, label,
|
|
CLOUD_CONTAINER_STYLES[cloud_style_key],
|
|
parent, x, y, w, h)
|
|
self._container_count += 1
|
|
continue
|
|
if kind == "tenancy":
|
|
self.add_tenancy(item_id, label, x, y, w, h)
|
|
elif kind == "region":
|
|
self.add_region(item_id, label, parent, x, y, w, h)
|
|
elif kind == "ad":
|
|
self.add_ad(item_id, label, parent, x, y, w, h)
|
|
elif kind == "vcn":
|
|
self.add_vcn(item_id, label, parent, x, y, w, h)
|
|
elif kind == "subnet":
|
|
self.add_subnet(item_id, label, parent, x, y, w, h)
|
|
else:
|
|
self.add_compartment(item_id, label, parent, x, y, w, h, tone=item.get("tone"))
|
|
|
|
for item in layout.get("external") or []:
|
|
self.add_external(
|
|
item.get("id", self._next_id()),
|
|
str(item.get("label", "")).replace("\\n", "\n"),
|
|
x=int(item.get("x", 0)), y=int(item.get("y", 0)),
|
|
w=int(item.get("w", 56)), h=int(item.get("h", 72)),
|
|
icon=item.get("icon", "users"),
|
|
)
|
|
|
|
for item in layout.get("services") or []:
|
|
cloud_icon = item.get("cloud_icon")
|
|
sid = item.get("id", self._next_id())
|
|
label = str(item.get("label", "")).replace("\\n", "\n")
|
|
sx_v, sy_v = int(item.get("x", 0)), int(item.get("y", 0))
|
|
sw, sh = int(item.get("w", 110)), int(item.get("h", 88))
|
|
if cloud_icon:
|
|
# AWS / GCP / Azure stencil — multi-cloud branding via
|
|
# the provider's official drawio shape library
|
|
# (mxgraph.aws4.*, mxgraph.gcp2.*, mxgraph.azure2.*).
|
|
# Falls back to OCI stencil if the key isn't recognized.
|
|
style = (_aws_icon_style(cloud_icon)
|
|
or _gcp_icon_style(cloud_icon)
|
|
or _azure_icon_style(cloud_icon))
|
|
if style:
|
|
# AWS/GCP icons are filled-square stencils; OCI
|
|
# icons are line-art with whitespace inside the
|
|
# bbox. At identical w/h the filled square LOOKS
|
|
# bigger. Scale to ~72% of the spec bbox so the
|
|
# visual weight matches the OCI line-art.
|
|
# Diego flagged 2026-04-25: "el tamaño del icono
|
|
# queda muy grande resp al tamaño de oci".
|
|
visual_scale = 0.72
|
|
rw = int(sw * visual_scale)
|
|
rh = int(sh * visual_scale)
|
|
rx = sx_v + (sw - rw) // 2
|
|
ry = sy_v + (sh - rh) // 2
|
|
self._create_object(sid, label, style,
|
|
item.get("parent"),
|
|
rx, ry, rw, rh)
|
|
self._service_count += 1
|
|
continue
|
|
self.add_service(
|
|
sid,
|
|
label,
|
|
item.get("type", "compute"),
|
|
item["parent"] if "parent" in item else None,
|
|
x=sx_v, y=sy_v, w=sw, h=sh,
|
|
font_size=_pt(item.get("fontSize")),
|
|
tone=item.get("tone"),
|
|
badges=item.get("badges"),
|
|
caption=item.get("caption"),
|
|
raw_icon_cells=item.get("raw_icon_cells"),
|
|
raw_icon_w=sw,
|
|
raw_icon_h=sh,
|
|
exact_size=True,
|
|
)
|
|
|
|
# Label style: matches the Oracle Toolkit v24.2 reference
|
|
# diagrams — transparent background, charcoal text, Oracle Sans.
|
|
# The toolkit does NOT use a white labelBackgroundColor; correct
|
|
# technique is to position labels OFF the connector line
|
|
# (typically a few pixels above or below). Spec authors can
|
|
# opt-in to a white background per-label via ``opaque_bg: true``
|
|
# for the rare case where overlap is unavoidable.
|
|
label_style = (
|
|
"text;html=1;whiteSpace=wrap;align=center;verticalAlign=middle;"
|
|
"fontFamily=Oracle Sans;fontSize={font_size};fontColor={color};"
|
|
"strokeColor=none;fillColor=none;{bg}"
|
|
)
|
|
for item in layout.get("labels") or []:
|
|
bg = "labelBackgroundColor=#FFFFFF;" if item.get("opaque_bg") else ""
|
|
style = label_style.format(
|
|
font_size=_pt(item.get("fontSize", 11)) or 11,
|
|
color=item.get("color", "#312D2A"),
|
|
bg=bg,
|
|
)
|
|
if item.get("bold"):
|
|
style += "fontStyle=1;"
|
|
self._create_object(
|
|
item.get("id", self._next_id()),
|
|
str(item.get("text", "")).replace("\\n", "\n"),
|
|
style,
|
|
item["parent"] if "parent" in item else None,
|
|
int(item.get("x", 0)), int(item.get("y", 0)),
|
|
int(item.get("w", 100)), int(item.get("h", 24)),
|
|
)
|
|
|
|
for conn in layout.get("connections") or []:
|
|
# Spec accepts both `points` and `waypoints` (alias). Ports
|
|
# disambiguate parallel edges from the same source.
|
|
self.add_connection(
|
|
conn.get("id", self._next_id()),
|
|
conn.get("label"),
|
|
conn.get("type", "standard"),
|
|
conn["from"],
|
|
conn["to"],
|
|
waypoints=conn.get("waypoints") or conn.get("points"),
|
|
flow_order=conn.get("flow_order"),
|
|
exit_x=conn.get("exit_x"),
|
|
exit_y=conn.get("exit_y"),
|
|
entry_x=conn.get("entry_x"),
|
|
entry_y=conn.get("entry_y"),
|
|
)
|
|
|
|
# In absolute_layout mode the canvas dimensions and the region
|
|
# container come straight from Oracle's reference (which has no
|
|
# title above the diagram). Drawing a 28pt title at y=8 would
|
|
# overlap the OCI Region label at the top-left. Honor an explicit
|
|
# opt-in via spec["render_title_in_layout"]; otherwise keep the
|
|
# canvas faithful to the source.
|
|
if spec.get("title") and spec.get("render_title_in_layout"):
|
|
canvas = layout.get("canvas") or {}
|
|
width = int(canvas.get("width", 850))
|
|
self.add_title(spec["title"], x=0, y=8, w=width, h=28, banner=spec.get("banner"))
|
|
|
|
|
|
# ============================================================
|
|
# CLI
|
|
# ============================================================
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Generate OCI architecture .drawio diagrams from YAML specs"
|
|
)
|
|
parser.add_argument(
|
|
"--spec", required=True,
|
|
help="Path to YAML spec file describing the architecture",
|
|
)
|
|
parser.add_argument(
|
|
"--output", default="architecture.drawio",
|
|
help="Output .drawio file path (default: architecture.drawio)",
|
|
)
|
|
parser.add_argument(
|
|
"--no-validate", action="store_true",
|
|
help="Skip the structural drawio_visual_validator pre-delivery check.",
|
|
)
|
|
parser.add_argument(
|
|
"--strict", action="store_true",
|
|
help="Fail (exit 2) on validator errors instead of warning.",
|
|
)
|
|
parser.add_argument(
|
|
"--reference-png",
|
|
help="Optional canonical Architecture Center PNG. When provided, "
|
|
"fidelity_eval pixel-diffs the rebuilt drawio against it.",
|
|
)
|
|
parser.add_argument(
|
|
"--reference-svg",
|
|
help="Optional companion SVG used by fidelity_eval (preferred path "
|
|
"since cairosvg works in any environment; the drawio binary "
|
|
"path is opt-in via the DRAWIO_EXE env var).",
|
|
)
|
|
parser.add_argument(
|
|
"--fidelity-threshold", type=float, default=0.90,
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
with open(args.spec, 'r') as f:
|
|
spec = yaml.safe_load(f)
|
|
|
|
gen = OCIDiagramGenerator.from_spec(spec)
|
|
# The spec may itself reference Oracle assets (fidelity-mode specs
|
|
# have spec.source.diagram_asset). Honor those if no CLI override.
|
|
src = spec.get("source") or {}
|
|
ref_png = args.reference_png or src.get("diagram_asset") if isinstance(src.get("diagram_asset"), str) and src.get("diagram_asset","").endswith(".png") else args.reference_png
|
|
ref_svg = args.reference_svg or src.get("diagram_svg")
|
|
gen.save(
|
|
args.output,
|
|
validate=not args.no_validate,
|
|
reference_png=ref_png,
|
|
reference_svg=ref_svg,
|
|
fidelity_threshold=args.fidelity_threshold,
|
|
strict=args.strict,
|
|
)
|
|
|
|
# Print summary
|
|
print(f"Generated: {args.output}")
|
|
print(f" Containers: {gen._container_count}")
|
|
print(f" Service blocks: {gen._service_count}")
|
|
print(f" Connections: {gen._connection_count}")
|
|
print(f" Flow badges: {len(gen._flow_badges)}")
|
|
print(f" Raw underlay cells: {len(gen._raw_underlay_cells)}")
|
|
print(f" Raw icon cells: {len(gen._raw_cells)}")
|
|
print(f" Registered objects: {len(gen._objects)}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|