#!/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", "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.google_kubernetes_engine", "#4285F4"), "cloud_sql": ("mxgraph.gcp2.cloud_sql", "#4285F4"), "bigquery": ("mxgraph.gcp2.bigquery", "#4285F4"), "cloud_storage": ("mxgraph.gcp2.cloud_storage", "#4285F4"), "cloud_run": ("mxgraph.gcp2.cloud_run", "#4285F4"), "cloud_functions":("mxgraph.gcp2.cloud_functions", "#4285F4"), "pub_sub": ("mxgraph.gcp2.cloud_pubsub", "#4285F4"), } 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};" ) # 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 } # 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 @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 = {} 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_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. """ n_lines = label.count('\n') + 1 label_h = max(n_lines * 16 + 4, 20) # Estimate icon height using Oracle ref targets (63px height, 45px min width) icon_entry = cls.ICON_CACHE.get(service_type) if cls.ICON_CACHE else None 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) else: icon_h = 63 return icon_h + 2 + label_h # icon + gap + label @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 def _get_svc_style(self, service_type: str, font_size: Optional[int] = None) -> str: """Get the style string for a service type.""" 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) 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 def add_compartment( self, cell_id: str, label: str, parent: str, x: int = 15, y: int = 30, w: int = 400, h: int = 300, ) -> str: """Add a Compartment / Fault Domain / Tier container (dashed gray).""" self._create_object(cell_id, label, STYLES["compartment"], 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'' f'' f'' ) self._raw_cells.append( f'' f'' f'' ) # Right person (foreground, offset right) cx_r = w // 2 + 10 self._raw_cells.append( f'' f'' f'' ) self._raw_cells.append( f'' f'' f'' ) else: # Single person silhouette cx = w // 2 self._raw_cells.append( f'' f'' f'' ) self._raw_cells.append( f'' f'' f'' ) # 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, ) -> str: """Add a service block with icon (if available) or colored rectangle fallback.""" icon_entry = self.ICON_CACHE.get(service_type) if self.ICON_CACHE else None if icon_entry: return self._add_service_with_icon( cell_id, label, service_type, parent, icon_entry, x, y, w, h, font_size, ) # Fallback: colored rectangle via drawpyo Object style = self._get_svc_style(service_type, font_size) self._create_object(cell_id, label, style, parent, x, y, w, h) self._service_count += 1 return cell_id 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, ) -> 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) # 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 original_ids = set() for cell_xml in icon_cells: cell_elem = ET.fromstring(cell_xml) original_ids.add(cell_elem.get("id")) id_map = {} for orig_id in sorted(original_ids, key=lambda x_: int(x_) if x_.isdigit() else 0): id_map[orig_id] = f"{cell_id}_i{orig_id}" # 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 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 geo = cell_elem.find("mxGeometry") if geo is not None: for attr in ("x", "y", "width", "height"): val = geo.get(attr) if val is not None: try: geo.set(attr, str(float(val) * scale)) except ValueError: pass # Offset x for centering (only top-level icon cells parented to group) if orig_parent == "1": cur_x = float(geo.get("x", "0")) geo.set("x", str(cur_x + icon_offset_x)) 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. fs = font_size or 12 label_style = ( f"text;html=1;whiteSpace=wrap;overflow=visible;align=center;verticalAlign=top;" f"fontFamily=Oracle Sans;fontSize={fs};fontColor=#312D2A;" 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, ) 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, ) -> 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. """ 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 via text_format (drawpyo's supported approach) edge.text_format.fontFamily = "Oracle Sans" edge.text_format.fontSize = 10 edge.text_format.fontColor = "#312D2A" edge.text_format.labelBackgroundColor = "#FFFFFF" # 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 # - labelBackgroundColor: white so labels are readable over the line # - NO elbow=vertical (it conflicts with orthogonal + port constraints) extra_style = ( "edgeStyle=orthogonalEdgeStyle;orthogonalLoop=1;" "jettySize=auto;" "jumpStyle=arc;jumpSize=8;" "endFill=0;startFill=0;" "labelBackgroundColor=#FFFFFF;labelBorderColor=none;" "fontFamily=Oracle Sans;fontSize=10;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 # ── 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. src_pos = self._abs_positions.get(source) tgt_pos = self._abs_positions.get(target) if 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, ) -> str: """Add an italic title label.""" self._create_object("title", text, STYLES["title"], None, x, y, w, h) return "title" # ================================================================ # 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 icon stencil cells before 5. Replace group parent placeholders with actual drawpyo IDs 6. 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']*>', '', xml, ) # Replace drawpyo's default diagram attributes xml = re.sub( r']*>', '', xml, ) # Canvas: white background so region fill (#F0EDE9) is clearly visible xml = re.sub( r']*>', '', 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) # : 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: # We replace with expanded form containing offset point. new_geo = ( f'' f'' f'' ) # Match both self-closing variants that drawpyo may produce for old_geo in [ '', '', ]: 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 icon stencil cells before if self._raw_cells: inject = "\n".join(f" {c}" for c in self._raw_cells) xml = xml.replace("", inject + "\n ") # 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): """Save to a .drawio file.""" # Create flow badges before generating XML # self._create_flow_badges() # disabled — Oracle style uses dash patterns, not numbered badges xml = self.to_xml() with open(filepath, 'w', encoding='utf-8') as f: f.write(xml) # ================================================================ # 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. """ gen = cls() # 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 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 # 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, ) # 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"), ) svc_x += svc_w + SVC_SPACING subnet_y += sh + SUBNET_GAP # 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, ) # 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 # 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 # Connections — merge duplicate from/to pairs (e.g., dual FastConnect) # into a single edge with combined label connections = spec.get("connections", []) 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 if spec.get("title"): total_w = tenancy_x + tenancy_w gen.add_title(spec["title"], x=0, y=8, w=total_w, h=35) return gen # ============================================================ # 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)", ) args = parser.parse_args() with open(args.spec, 'r') as f: spec = yaml.safe_load(f) gen = OCIDiagramGenerator.from_spec(spec) gen.save(args.output) # 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 icon cells: {len(gen._raw_cells)}") print(f" Registered objects: {len(gen._objects)}") if __name__ == "__main__": main()