Files
oci-deal-accelerator/tools/oci_diagram_gen.py
2026-03-18 18:03:44 -03:00

1432 lines
60 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=6 4;align=left;"
"fontFamily=Oracle Sans;verticalAlign=top;fillColor=none;"
"fontColor=#AE562C;strokeColor=#AE562C;perimeterSpacing=0;"
"fontSize=12;fontStyle=1;spacingLeft=8;spacingTop=6;"
),
"subnet": (
"whiteSpace=wrap;html=1;strokeWidth=1;dashed=1;dashPattern=6 4;align=left;"
"fontFamily=Oracle Sans;verticalAlign=top;fillColor=none;"
"fontColor=#AE562C;strokeColor=#AE562C;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("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
.replace("\n", "&#xa;"))
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'<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,
) -> 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).
# ~15px per line at 11pt Oracle Sans, minimum 20px for single-line labels.
n_lines = label.count('\n') + 1
label_h = max(n_lines * 16 + 4, 20)
# Icon area = full spec height minus label height
icon_area_h = max(h - label_h, 20)
# Scale icon to fit the icon area (never upscale beyond original size)
scale = min(w / icon_w, icon_area_h / icon_h, 1.0)
scaled_w = icon_w * scale
scaled_h = icon_h * scale
# Icon group height = scaled icon height only.
# Connections terminate here — at the visible icon boundary.
icon_group_h = max(int(scaled_h), 20)
group_style = (
"group;fillColor=none;strokeColor=none;pointerEvents=1;"
"connectable=1;"
)
self._create_object(cell_id, "", group_style, parent, x, y, 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 group
icon_offset_x = (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: 11pt, charcoal (#312D2A), centered below icon.
fs = font_size or 11
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, orthogonal routing
edge.line_end_target = "open"
edge.line_end_source = "none"
edge.strokeColor = "#312D2A"
edge.strokeWidth = 1
edge.rounded = True
edge.endSize = 6
edge.waypoints = "orthogonal"
# 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.
# labelBackgroundColor: white so edge labels are readable and the line
# is hidden behind the text (confirmed in Oracle ref arch diagrams).
# labelBorderColor=none: no border box around the label.
extra_style = (
"elbow=vertical;endFill=0;startFill=0;"
"labelBackgroundColor=#FFFFFF;labelBorderColor=none;"
"fontFamily=Oracle Sans;fontSize=10;fontColor=#312D2A;"
)
# Customize per connection type
if conn_type == "fastconnect":
# Bidirectional
edge.line_end_source = "open"
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;"
# "standard" and "db" use solid line defaults — no changes needed
# 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 </root>
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'<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
xml = re.sub(
r'<mxGraphModel[^>]*>',
'<mxGraphModel dx="1900" dy="1200" grid="1" gridSize="10" '
'guides="1" tooltips="1" connect="1" arrows="1" fold="1" '
'page="1" pageScale="1" pageWidth="1900" pageHeight="1200" '
'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}')
# 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>")
# 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))
# 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)
cy += svc_h + 20
# Tenancy
tenancy_spec = spec.get("tenancy", {})
tenancy_x = tenancy_spec.get("x", 30)
tenancy_y = tenancy_spec.get("y", 80)
tenancy_w = tenancy_spec.get("w", 1850)
tenancy_h = tenancy_spec.get("h", 720)
gen.add_tenancy(
"tenancy",
tenancy_spec.get("label", "Oracle Cloud Infrastructure"),
x=tenancy_x, y=tenancy_y, w=tenancy_w, h=tenancy_h,
)
# Regions
for region in tenancy_spec.get("regions", []):
is_primary = region.get("primary", False)
rx = region.get("x", 15 if is_primary else 1195)
# ry default 40: ~18px below tenancy label (12pt bold) + comfortable padding
ry = region.get("y", 40)
rw = region.get("w", 1140 if is_primary else 640)
rh = region.get("h", 670 if is_primary else 480)
gen.add_region(
region["id"],
region["label"],
"tenancy", rx, ry, rw, rh,
)
# 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),
)
# VCNs
for vcn in region.get("vcns", []):
vcn_parent = vcn.get("parent", region["id"])
vcn_w = vcn.get("w", rw - 30)
vcn_h = vcn.get("h", rh - 70)
gen.add_vcn(
vcn["id"],
f"VCN {vcn['label']}",
vcn_parent,
# y default 45: leaves proper gap below the region label (12pt ≈22px + padding)
x=vcn.get("x", 15), y=vcn.get("y", 45),
w=vcn_w, h=vcn_h,
)
# Auto-layout subnets VERTICALLY (stacked top-to-bottom).
# Matches Oracle reference architecture style: subnets span the
# full VCN width and stack vertically, one on top of another.
# (Confirmed from 11 Oracle Architecture Center reference diagrams)
# subnet_y=32: leaves ~10px gap below the VCN label (12pt bold ≈22px).
subnet_y = 32
for subnet in vcn.get("subnets", []):
# Default width = full VCN width minus 16px padding (8px each side)
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,
)
# Auto-layout services HORIZONTALLY within subnet, centered.
# Oracle style: services are centered left-to-right within each
# subnet row, not left-aligned. Single service → centered in subnet.
svc_top = 30 # vertical offset below subnet label
subnet_svcs = subnet.get("services", [])
# Compute each service width upfront to center the group
svc_widths = [svc.get("w", min(sw - 30, 150)) for svc in subnet_svcs]
total_svc_w = sum(svc_widths) + 16 * max(0, len(svc_widths) - 1)
# Center the service group; minimum 15px left padding
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())
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 + 14
# Gateways on left side of 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", 110)
gw_h = gw.get("h", 70)
gw_x = gw.get("x", gx)
gw_y = gw.get("y", gy)
label = gw["label"].replace("\\n", "\n")
gen.add_service(
gw_id, label, gw["type"],
vcn["id"], gw_x, gw_y, gw_w, gw_h,
)
gy = gw_y + gw_h + 8
# 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 + 14
# 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_h = gw.get("h", 60)
gw_x = gw.get("x", gx)
gw_y = gw.get("y", gy)
label = gw["label"].replace("\\n", "\n")
gen.add_service(
gw_id, label, gw["type"],
vcn["id"], gw_x, gw_y, gw_w, gw_h,
)
gy = gw_y + gw_h + 8
# 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
for conn in spec.get("connections", []):
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()