Compare commits
2 Commits
4660b93a98
...
20be7c297e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20be7c297e | ||
|
|
886c09ad10 |
77
.gitea/workflows/kb-health.yaml
Normal file
77
.gitea/workflows/kb-health.yaml
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# Weekly KB health check — validates catalog links and reports broken URLs.
|
||||||
|
# Runs Monday 8am UTC. Does NOT block deployments or affect user experience.
|
||||||
|
# If broken links found, creates a Gitea issue for manual review.
|
||||||
|
name: KB Health Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 8 * * 1'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-links:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pip install requests beautifulsoup4 pyyaml
|
||||||
|
|
||||||
|
- name: Check Architecture Center links
|
||||||
|
id: check
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
python tools/refresh_arch_catalog.py --check-links 2>&1 | tee /tmp/link-check.txt
|
||||||
|
echo "exit_code=$?" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Check SKU catalog freshness
|
||||||
|
id: sku
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
python tools/refresh_sku_catalog.py --validate 2>&1 | tee -a /tmp/link-check.txt
|
||||||
|
|
||||||
|
- name: Create issue if problems found
|
||||||
|
if: steps.check.outcome == 'failure'
|
||||||
|
run: |
|
||||||
|
BROKEN=$(grep -c "❌" /tmp/link-check.txt || echo "0")
|
||||||
|
REDIRECTED=$(grep -c "↪️" /tmp/link-check.txt || echo "0")
|
||||||
|
BODY=$(cat <<'ISSUE_EOF'
|
||||||
|
## KB Health Check — Broken Links Found
|
||||||
|
|
||||||
|
**Run date:** $(date -u +%Y-%m-%d)
|
||||||
|
**Broken:** $BROKEN URLs
|
||||||
|
**Redirected:** $REDIRECTED URLs
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Full report</summary>
|
||||||
|
|
||||||
|
```
|
||||||
|
$(cat /tmp/link-check.txt)
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### Action needed
|
||||||
|
- For 404s: remove entry or find new URL via `python tools/refresh_arch_catalog.py --whats-new`
|
||||||
|
- For redirects: update URL in `kb/architecture-center/catalog.yaml`
|
||||||
|
ISSUE_EOF
|
||||||
|
)
|
||||||
|
# Note: Gitea issue creation requires gitea CLI or API call
|
||||||
|
# For now, save report as artifact
|
||||||
|
echo "$BODY" > /tmp/kb-health-report.md
|
||||||
|
echo "::warning::$BROKEN broken links found in KB catalog"
|
||||||
|
|
||||||
|
- name: Upload report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: kb-health-report
|
||||||
|
path: /tmp/link-check.txt
|
||||||
|
retention-days: 30
|
||||||
32
README.md
32
README.md
@@ -131,6 +131,34 @@ During **Phase 2 (DESIGN)**, the skill automatically matches the proposed archit
|
|||||||
python tools/refresh_arch_catalog.py --whats-new # crawl What's New pages
|
python tools/refresh_arch_catalog.py --whats-new # crawl What's New pages
|
||||||
python tools/refresh_arch_catalog.py --url <url> # add a single entry
|
python tools/refresh_arch_catalog.py --url <url> # add a single entry
|
||||||
python tools/refresh_arch_catalog.py --validate # validate catalog integrity
|
python tools/refresh_arch_catalog.py --validate # validate catalog integrity
|
||||||
|
python tools/refresh_arch_catalog.py --check-links # check for broken URLs (404s, redirects)
|
||||||
|
```
|
||||||
|
|
||||||
|
### KB Health & Freshness
|
||||||
|
|
||||||
|
The KB is automatically monitored for staleness and broken links:
|
||||||
|
|
||||||
|
| Check | How | When |
|
||||||
|
|---|---|---|
|
||||||
|
| **Broken links** | `python tools/refresh_arch_catalog.py --check-links` | Weekly (CI), or on demand |
|
||||||
|
| **Stale prices** | `python tools/refresh_sku_catalog.py --validate` | Weekly (CI), or on demand |
|
||||||
|
| **KB freshness** | `python tools/kb_freshness.py --check` | On skill startup (pre-flight) |
|
||||||
|
| **Diagram quality** | `python scripts/validate-diagram.py <file.drawio>` | After every diagram generation |
|
||||||
|
|
||||||
|
**Automated CI/CD:**
|
||||||
|
- **Deploy workflow** (`.gitea/workflows/deploy.yaml`) — auto-deploys to MCP server on every push to `main`
|
||||||
|
- **KB health workflow** (`.gitea/workflows/kb-health.yaml`) — runs every Monday 8am UTC, checks all catalog URLs and SKU freshness, reports broken links as artifacts
|
||||||
|
|
||||||
|
To run KB health manually:
|
||||||
|
```bash
|
||||||
|
# Check all 123 Architecture Center URLs for 404s
|
||||||
|
python tools/refresh_arch_catalog.py --check-links
|
||||||
|
|
||||||
|
# Fix broken links: re-crawl What's New for updated URLs
|
||||||
|
python tools/refresh_arch_catalog.py --whats-new
|
||||||
|
|
||||||
|
# Refresh stale pricing from Oracle API
|
||||||
|
python tools/refresh_sku_catalog.py --refresh --diff
|
||||||
```
|
```
|
||||||
|
|
||||||
### Feature Compatibility Matrix
|
### Feature Compatibility Matrix
|
||||||
@@ -296,12 +324,16 @@ python tools/oci_bizcase_gen.py --spec business-case.yaml --output business-case
|
|||||||
# Architecture diagram
|
# Architecture diagram
|
||||||
python tools/oci_diagram_gen.py --spec examples/diagram-spec.yaml --output arch.drawio
|
python tools/oci_diagram_gen.py --spec examples/diagram-spec.yaml --output arch.drawio
|
||||||
|
|
||||||
|
# Validate diagram quality (icon sizes, overlaps, container overflow)
|
||||||
|
python scripts/validate-diagram.py arch.drawio
|
||||||
|
|
||||||
# Output orchestrator (multiple formats at once)
|
# Output orchestrator (multiple formats at once)
|
||||||
python tools/oci_output.py --spec examples/proposal-spec.yaml --format full --output-dir output/
|
python tools/oci_output.py --spec examples/proposal-spec.yaml --format full --output-dir output/
|
||||||
|
|
||||||
# Architecture Center catalog
|
# Architecture Center catalog
|
||||||
python tools/refresh_arch_catalog.py --validate
|
python tools/refresh_arch_catalog.py --validate
|
||||||
python tools/refresh_arch_catalog.py --whats-new
|
python tools/refresh_arch_catalog.py --whats-new
|
||||||
|
python tools/refresh_arch_catalog.py --check-links
|
||||||
|
|
||||||
# Feature compatibility
|
# Feature compatibility
|
||||||
python tools/feature_matrix_cli.py check "Auto Scaling" adb_s 23ai
|
python tools/feature_matrix_cli.py check "Auto Scaling" adb_s 23ai
|
||||||
|
|||||||
48
SKILL.md
48
SKILL.md
@@ -15,32 +15,54 @@ You follow the **Oracle ECAL framework** (Define → Design → Deliver) to prod
|
|||||||
|
|
||||||
When the user starts a conversation without providing discovery notes or a specific request, present the welcome message and capability menu.
|
When the user starts a conversation without providing discovery notes or a specific request, present the welcome message and capability menu.
|
||||||
|
|
||||||
### Pre-flight: KB freshness check
|
### Pre-flight checks
|
||||||
|
|
||||||
**Before showing the welcome message**, run `python tools/kb_freshness.py --check --json` and parse the JSON output. Behavior based on the result:
|
Run these checks silently **before** showing the welcome message. **CRITICAL:** Never show command execution, tool output, or errors to the user. If any check fails, silently skip it and proceed to the welcome message. These checks are informational — they NEVER block the user.
|
||||||
|
|
||||||
- **`stale_count == 0`** → proceed directly to the welcome message. No banner.
|
#### Check 1: KB changelog banner
|
||||||
- **`stale_count > 0` and at least one file has `refreshable: true`** → prepend this banner above the menu and ask the user inline:
|
|
||||||
|
Read the file `kb/CHANGELOG.md` and extract the **most recent date and first bullet point**. If the file exists and has entries, prepend a one-line banner above the welcome message:
|
||||||
|
|
||||||
|
```
|
||||||
|
📢 KB updated (<date>): <first bullet summary>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example: `📢 KB updated (Apr 14): Diagram generator calibrated from 37 Oracle ref architectures`
|
||||||
|
|
||||||
|
If the file is missing or empty, skip — no banner.
|
||||||
|
|
||||||
|
#### Check 2: Local repo updates (git users only)
|
||||||
|
|
||||||
|
Run `git fetch --dry-run origin main 2>/dev/null`. If the output contains any text (meaning there are remote commits not yet pulled), prepend this banner:
|
||||||
|
|
||||||
|
```
|
||||||
|
📢 KB updates available — run `git pull` to get latest prices and fixes.
|
||||||
|
```
|
||||||
|
|
||||||
|
If the command fails (not a git repo, no network, MCP deployment), silently skip. This check is only relevant for users running the skill from a local git clone.
|
||||||
|
|
||||||
|
#### Check 3: KB freshness
|
||||||
|
|
||||||
|
Run `python3 tools/kb_freshness.py --check --json 2>/dev/null` and parse the JSON output. Behavior:
|
||||||
|
|
||||||
|
- **`stale_count == 0`** → no banner.
|
||||||
|
- **`stale_count > 0` and at least one file has `refreshable: true`** → prepend banner and ask inline:
|
||||||
|
|
||||||
```
|
```
|
||||||
⚠️ KB freshness: <N> file(s) outdated (oldest: <file> — <age_days>d).
|
⚠️ KB freshness: <N> file(s) outdated (oldest: <file> — <age_days>d).
|
||||||
<M> can be auto-refreshed (SKU catalog, Architecture Center).
|
<M> can be auto-refreshed. Refresh now? [y/N]
|
||||||
Refresh now before showing the menu? [y/N]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- If the user replies `y` / `yes` / `sí` → run `python tools/kb_freshness.py --auto-refresh`, wait for completion, then show the menu.
|
- `y` / `yes` / `sí` → run `python3 tools/kb_freshness.py --auto-refresh`, then show menu.
|
||||||
- If the user replies `n` / anything else → show the menu immediately, with a one-line compact reminder above it: `⚠️ <N> KB file(s) stale — run /freshness or python tools/kb_freshness.py --auto-refresh later.`
|
- Anything else → show menu with one-line reminder: `⚠️ <N> KB file(s) stale — run python3 tools/kb_freshness.py --auto-refresh later.`
|
||||||
|
|
||||||
- **`stale_count > 0` but no file has `refreshable: true`** (only manual files stale) → prepend a non-blocking informational banner above the menu, do NOT ask:
|
- **`stale_count > 0` but no `refreshable: true`** → non-blocking info banner, then show menu directly:
|
||||||
|
|
||||||
```
|
```
|
||||||
⚠️ KB freshness: <N> file(s) need manual review (oldest: <file> — <age_days>d).
|
⚠️ KB freshness: <N> file(s) need manual review (oldest: <file> — <age_days>d).
|
||||||
No automated refresh available — see kb/README.md for review process.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then show the menu directly.
|
If `kb_freshness.py` errors out (exit ≠ 0, missing python, missing tool), **silently skip** — no error output, no banner, no mention of failure.
|
||||||
|
|
||||||
**Important**: this check is informational, not gating. If `kb_freshness.py` errors out (exit 2 or missing tool), silently fall back to showing the welcome message — never block the user on tooling failures.
|
|
||||||
|
|
||||||
### Welcome Message
|
### Welcome Message
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
# OCI Deal Accelerator — Architecture Diagram Spec
|
# OCI Deal Accelerator — Architecture Diagram Spec
|
||||||
# Demo: PharmaCorp Mexico — ExaCS dual-region with Data Guard
|
# Demo: PharmaCorp Mexico — ExaCS dual-region with Data Guard
|
||||||
# Layout: Left-to-right flow, ref arch style
|
# Prompt: "ExaCS en Queretaro (prod) + Standby con Data Guard en Sao Paulo,
|
||||||
# Mexico DC → FastConnect → Queretaro (DB subnet) → DR Sao Paulo
|
# Hub-Spoke networking con DRG, FastConnect 10Gbps dual desde Mexico City,
|
||||||
|
# bastion en public subnet, WAF para apps web, Vault para TDE keys."
|
||||||
#
|
#
|
||||||
# This example only includes components the user explicitly requested:
|
# REQUESTED by user: ExaCS (prod+DR), DRG, FastConnect dual, Bastion, WAF, Vault
|
||||||
# ExaCS (prod + DR), Hub-Spoke networking with DRG, FastConnect 10Gbps,
|
# TECHNICAL DEPENDENCIES (whitelist): SGW (ExaCS backup), IGW (public subnet)
|
||||||
# Bastion in public subnet.
|
# NOT included: EBS, Monitoring, Logging, Events, FSDR, Object Storage (not requested)
|
||||||
# DRG and Service Gateway are auto-added per technical dependency whitelist.
|
#
|
||||||
# Monitoring, Logging, Events, WAF, Vault, EBS — NOT included (not requested).
|
# Container sizes: omitted to let auto-sizing calculate from content.
|
||||||
|
# DRG: listed in gateways — generator places it OUTSIDE VCN, INSIDE region.
|
||||||
|
|
||||||
title: "PharmaCorp Mexico — ExaCS Migration Architecture"
|
title: "PharmaCorp Mexico — ExaCS Migration Architecture"
|
||||||
|
|
||||||
@@ -17,7 +19,7 @@ external:
|
|||||||
label: "Mexico City\nData Center"
|
label: "Mexico City\nData Center"
|
||||||
icon: "user"
|
icon: "user"
|
||||||
x: 30
|
x: 30
|
||||||
y: 320
|
y: 250
|
||||||
w: 60
|
w: 60
|
||||||
h: 70
|
h: 70
|
||||||
|
|
||||||
@@ -26,144 +28,102 @@ tenancy:
|
|||||||
label: "Oracle Cloud Infrastructure"
|
label: "Oracle Cloud Infrastructure"
|
||||||
x: 200
|
x: 200
|
||||||
y: 60
|
y: 60
|
||||||
w: 1650
|
|
||||||
h: 780
|
|
||||||
|
|
||||||
regions:
|
regions:
|
||||||
# ── Primary Region: Queretaro ──
|
# ── Primary Region: Queretaro ──
|
||||||
- id: "region_qro"
|
- id: "region_qro"
|
||||||
label: "Region — Queretaro (Primary)"
|
label: "Region — Queretaro (Primary)"
|
||||||
primary: true
|
primary: true
|
||||||
x: 15
|
|
||||||
y: 40
|
|
||||||
w: 1050
|
|
||||||
h: 720
|
|
||||||
|
|
||||||
vcns:
|
vcns:
|
||||||
- id: "vcn_hub"
|
- id: "vcn_hub"
|
||||||
label: "Hub VCN (10.0.0.0/16)"
|
label: "Hub VCN (10.0.0.0/16)"
|
||||||
x: 15
|
|
||||||
y: 45
|
|
||||||
w: 1020
|
|
||||||
h: 650
|
|
||||||
|
|
||||||
subnets:
|
subnets:
|
||||||
# Public subnet — bastion access
|
# Public subnet — WAF + Bastion (both requested)
|
||||||
- id: "subnet_pub"
|
- id: "subnet_pub"
|
||||||
label: "Public Subnet — 10.0.0.0/24"
|
label: "Public Subnet — 10.0.0.0/24"
|
||||||
h: 120
|
h: 120
|
||||||
services:
|
services:
|
||||||
|
- id: "waf"
|
||||||
|
label: "WAF\n(Web Apps)"
|
||||||
|
type: "waf"
|
||||||
|
w: 120
|
||||||
|
h: 70
|
||||||
- id: "bastion"
|
- id: "bastion"
|
||||||
label: "Bastion\nService"
|
label: "Bastion\nService"
|
||||||
type: "bastion"
|
type: "bastion"
|
||||||
w: 120
|
w: 120
|
||||||
h: 70
|
h: 70
|
||||||
|
|
||||||
# DB subnet — main workload
|
# DB subnet — ExaCS + Vault (both requested)
|
||||||
- id: "subnet_db"
|
- id: "subnet_db"
|
||||||
label: "Database Subnet (Private) — 10.0.1.0/24"
|
label: "Database Subnet (Private) — 10.0.1.0/24"
|
||||||
h: 150
|
h: 130
|
||||||
services:
|
services:
|
||||||
- id: "exacs_prod"
|
- id: "exacs_prod"
|
||||||
label: "ExaCS X11M\nQuarter Rack\n(Production)"
|
label: "ExaCS X11M\nQuarter Rack\n(Production)"
|
||||||
type: "exacs"
|
type: "exacs"
|
||||||
w: 200
|
w: 200
|
||||||
h: 90
|
h: 80
|
||||||
|
- id: "vault"
|
||||||
|
label: "OCI Vault\nTDE Keys"
|
||||||
|
type: "vault"
|
||||||
|
w: 140
|
||||||
|
h: 80
|
||||||
|
|
||||||
gateways:
|
gateways:
|
||||||
# Auto-added per whitelist: FastConnect requires DRG
|
# DRG — generator places this OUTSIDE VCN, INSIDE region
|
||||||
- id: "drg"
|
- id: "drg"
|
||||||
label: "DRG"
|
label: "DRG"
|
||||||
type: "drg"
|
type: "drg"
|
||||||
w: 90
|
# IGW — whitelist: public subnet requires it
|
||||||
h: 60
|
- id: "igw"
|
||||||
# Auto-added per whitelist: ExaCS backup requires SGW
|
label: "Internet\nGateway"
|
||||||
|
type: "igw"
|
||||||
|
# SGW — whitelist: ExaCS backup requires it
|
||||||
- id: "sgw"
|
- id: "sgw"
|
||||||
label: "Service\nGateway"
|
label: "Service\nGateway"
|
||||||
type: "service_gateway"
|
type: "service_gateway"
|
||||||
w: 100
|
|
||||||
h: 60
|
|
||||||
|
|
||||||
# ── DR Region: Sao Paulo (right side) ──
|
# ── DR Region: Sao Paulo ──
|
||||||
- id: "region_gru"
|
- id: "region_gru"
|
||||||
label: "Region — São Paulo (DR)"
|
label: "Region — São Paulo (DR)"
|
||||||
x: 1090
|
|
||||||
y: 40
|
|
||||||
w: 540
|
|
||||||
h: 540
|
|
||||||
|
|
||||||
vcns:
|
vcns:
|
||||||
- id: "vcn_dr"
|
- id: "vcn_dr"
|
||||||
label: "DR VCN (10.1.0.0/16)"
|
label: "DR VCN (10.1.0.0/16)"
|
||||||
x: 15
|
|
||||||
y: 45
|
|
||||||
w: 510
|
|
||||||
h: 470
|
|
||||||
|
|
||||||
subnets:
|
subnets:
|
||||||
- id: "subnet_dr_db"
|
- id: "subnet_dr_db"
|
||||||
label: "DB Subnet (Private) — 10.1.1.0/24"
|
label: "DB Subnet (Private) — 10.1.1.0/24"
|
||||||
h: 200
|
h: 130
|
||||||
services:
|
services:
|
||||||
- id: "exacs_dr"
|
- id: "exacs_dr"
|
||||||
label: "ExaCS Standby\n(Data Guard)"
|
label: "ExaCS Standby\n(Data Guard)"
|
||||||
type: "exacs"
|
type: "exacs"
|
||||||
w: 200
|
w: 200
|
||||||
h: 100
|
h: 80
|
||||||
|
|
||||||
# Connections
|
# Connections
|
||||||
connections:
|
connections:
|
||||||
# Entry: DC → DRG (FastConnect)
|
# FastConnect dual from Mexico City → DRG (will be merged into single edge)
|
||||||
- id: "conn_fc"
|
- id: "conn_fc1"
|
||||||
label: "FastConnect 10Gbps\n(Dual Redundant)"
|
label: "FastConnect 10Gbps\n(Dual Redundant)"
|
||||||
type: "network"
|
type: "fastconnect"
|
||||||
from: "mexico_dc"
|
from: "mexico_dc"
|
||||||
to: "drg"
|
to: "drg"
|
||||||
|
|
||||||
# DR: ExaCS Prod → ExaCS Standby (Data Guard)
|
# Vault → ExaCS (TDE keys)
|
||||||
|
- id: "conn_vault"
|
||||||
|
label: "TDE Keys"
|
||||||
|
type: "internal"
|
||||||
|
from: "vault"
|
||||||
|
to: "exacs_prod"
|
||||||
|
|
||||||
|
# Data Guard: Prod → DR
|
||||||
- id: "conn_dg"
|
- id: "conn_dg"
|
||||||
label: "Data Guard\n(Redo Shipping)"
|
label: "Data Guard\n(Redo Shipping)"
|
||||||
type: "data"
|
type: "data"
|
||||||
from: "exacs_prod"
|
from: "exacs_prod"
|
||||||
to: "exacs_dr"
|
to: "exacs_dr"
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# OPTIONAL — add if the user approves during pre-generation review:
|
|
||||||
#
|
|
||||||
# Security:
|
|
||||||
# - id: "vault"
|
|
||||||
# label: "OCI Vault\nTDE Keys"
|
|
||||||
# type: "vault"
|
|
||||||
#
|
|
||||||
# - id: "waf"
|
|
||||||
# label: "WAF"
|
|
||||||
# type: "waf"
|
|
||||||
#
|
|
||||||
# Observability:
|
|
||||||
# - id: "monitoring"
|
|
||||||
# label: "OCI Monitoring"
|
|
||||||
# type: "monitoring"
|
|
||||||
#
|
|
||||||
# - id: "logging"
|
|
||||||
# label: "OCI Audit + Logging"
|
|
||||||
# type: "logging"
|
|
||||||
#
|
|
||||||
# - id: "events"
|
|
||||||
# label: "OCI Events + Notifications"
|
|
||||||
# type: "events"
|
|
||||||
#
|
|
||||||
# Application tier (only if customer has app servers):
|
|
||||||
# - id: "ebs_app"
|
|
||||||
# label: "EBS R12 App Tier"
|
|
||||||
# type: "compute"
|
|
||||||
#
|
|
||||||
# DR orchestration:
|
|
||||||
# - id: "fsdr"
|
|
||||||
# label: "Full Stack DR (FSDR)"
|
|
||||||
# type: "fsdr"
|
|
||||||
#
|
|
||||||
# Backup:
|
|
||||||
# - id: "obj_storage"
|
|
||||||
# label: "Object Storage\nRMAN Backups"
|
|
||||||
# type: "object-storage"
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
19
kb/CHANGELOG.md
Normal file
19
kb/CHANGELOG.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# KB Changelog
|
||||||
|
|
||||||
|
Recent changes to the Knowledge Base. The skill shows the latest entry in the welcome banner.
|
||||||
|
|
||||||
|
## 2026-04-14
|
||||||
|
- Diagram generator: icon sizing calibrated from 37 Oracle Architecture Center .drawio files (63px services, 42px gateways)
|
||||||
|
- Diagram generator: auto-sizing containers, DRG placement outside VCN, edge label offsets
|
||||||
|
- Diagram generator: VCN/subnet dash pattern corrected to "4 2" (Oracle ref standard)
|
||||||
|
- New: `scripts/validate-diagram.py` — automated diagram quality validation
|
||||||
|
- New: `--check-links` in refresh_arch_catalog.py — detects broken URLs in catalog
|
||||||
|
- New: weekly KB health CI workflow (`.gitea/workflows/kb-health.yaml`)
|
||||||
|
- SKILL.md: anti-hallucination guardrails (closed whitelist + mandatory pre-generation review)
|
||||||
|
- SKILL.md: structured data intake (Extraction Receipt + Completeness Gate)
|
||||||
|
|
||||||
|
## 2026-04-01
|
||||||
|
- KB validation: 30 corrections across 16 files (ExaCS storage, ECPU transition, IOPS fixes)
|
||||||
|
|
||||||
|
## 2026-03-16
|
||||||
|
- Architecture Center catalog refreshed: 123 entries verified
|
||||||
295
kb/diagram/oracle-ref-measurements.md
Normal file
295
kb/diagram/oracle-ref-measurements.md
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
# Oracle Architecture Center Reference Diagram Measurements
|
||||||
|
|
||||||
|
Extracted from official Oracle .drawio source files (draw.io/28.0.4, Visio-exported).
|
||||||
|
|
||||||
|
Sources analyzed:
|
||||||
|
1. Hub-and-Spoke DRG (hub-spoke-network-drg) - single-region, 3 VCNs, hub topology
|
||||||
|
2. Essbase Cross-Region DR (cross-region-dr-essbase-oci) - dual-region, 3 ADs each, DR topology
|
||||||
|
|
||||||
|
Tool: draw.io 28.0.4 (Electron), exported from Visio (.vsdx)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Canvas / Page Settings
|
||||||
|
|
||||||
|
| Property | Hub-Spoke DRG | Essbase DR |
|
||||||
|
|----------|--------------|------------|
|
||||||
|
| pageWidth | 850 | 850 |
|
||||||
|
| pageHeight | 1100 | 1100 |
|
||||||
|
| gridSize | 10 | 10 |
|
||||||
|
| Actual content W | 693 | 2339 |
|
||||||
|
| Actual content H | 846 | 1154 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Font Specifications
|
||||||
|
|
||||||
|
| Usage | Font Family | Font Size | Style |
|
||||||
|
|-------|------------|-----------|-------|
|
||||||
|
| Region/VCN/service labels | Oracle Sans | 16.93px (~17px) | Bold for region title |
|
||||||
|
| Annotation text | Oracle Sans | 14.82px (~15px) | Regular |
|
||||||
|
| Label box height | - | 20px | - |
|
||||||
|
| Annotation box height | - | 17px | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Color Palette
|
||||||
|
|
||||||
|
| Element | Hex |
|
||||||
|
|---------|-----|
|
||||||
|
| OCI Region fill | #f5f4f2 |
|
||||||
|
| OCI Region stroke | #9e9892 |
|
||||||
|
| VCN / Subnet border (dashed) | #aa643b |
|
||||||
|
| Connector lines / solid borders | #312d2a |
|
||||||
|
| Service icon primary (teal) | #2d5967 |
|
||||||
|
| Connector lines (Essbase) | #161513 |
|
||||||
|
| AD fill (multi-AD diagrams) | #e4e1dd |
|
||||||
|
| Background | #FFFFFF |
|
||||||
|
| Accent brown | #ae562c |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Container Dimensions
|
||||||
|
|
||||||
|
### 4.1 OCI Region
|
||||||
|
- Hub-Spoke: 456x845 at (236,0), fill=#f5f4f2, stroke=#9e9892
|
||||||
|
- Essbase Left: 995x849 at (0,305)
|
||||||
|
- Essbase Right: 1080x849 at (1258,304)
|
||||||
|
- Pattern: Double-rendered (fill layer + stroke layer as separate shapes)
|
||||||
|
|
||||||
|
### 4.2 VCN
|
||||||
|
- DMZ VCN (Hub-Spoke): 226x354 at (456,26)
|
||||||
|
- Spoke VCN 1: 226x205 at (456,406)
|
||||||
|
- Spoke VCN 2: 226x202 at (457,634)
|
||||||
|
- Essbase left VCN: 894x766 at (30,355)
|
||||||
|
- Essbase right VCN: 872x766 at (1328,354)
|
||||||
|
- Style: strokeColor=#aa643b, strokeWidth=2, dashed=1, dashPattern=4.00 2.00
|
||||||
|
|
||||||
|
### 4.3 Subnet
|
||||||
|
- Management Subnet: 176x134 at (498,76)
|
||||||
|
- Services Subnet: 176x134 at (498,238)
|
||||||
|
- Workload Subnet 1: 175x143 at (500,458)
|
||||||
|
- Workload Subnet 2: 175x142 at (500,686)
|
||||||
|
- Style: Same as VCN (strokeColor=#aa643b, strokeWidth=2, dashed)
|
||||||
|
|
||||||
|
### 4.4 Availability Domain (Essbase)
|
||||||
|
- Width: 188, Height: 815
|
||||||
|
- Positions: x=211, 425, 632 (left region), x=1508, 1724, 1930 (right region)
|
||||||
|
- AD-to-AD gap: 18-28px (~25px avg)
|
||||||
|
- Style: fill=#e4e1dd, stroke=#9e9892
|
||||||
|
|
||||||
|
### 4.5 On-Premises Box (Essbase)
|
||||||
|
- 260x251 at (997,0) - centered between two regions
|
||||||
|
- Style: fill=#f5f4f2, stroke=#9e9892
|
||||||
|
|
||||||
|
### 4.6 Workload Rows (Essbase)
|
||||||
|
- Standard row: 807x111
|
||||||
|
- Bottom row (expanded): 807x149
|
||||||
|
- Y positions: 415, 552, 690, 828, 962
|
||||||
|
- Row-to-row gap: 23-27px (~25px)
|
||||||
|
|
||||||
|
### 4.7 Compartment Boxes (Essbase)
|
||||||
|
- 189x49 - placed at AD/row intersections
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Icon Dimensions
|
||||||
|
|
||||||
|
### 5.1 Service Icons (VM, Bastion, Database, etc.)
|
||||||
|
- 3-layer nesting: 63x63 outer > 61x61 middle > 60x60 inner
|
||||||
|
- Standard service icon = 60px rendered in 63px box
|
||||||
|
- Internal details: 13x13 grid cells, 43x13 bottom bar, 3x3 dots
|
||||||
|
|
||||||
|
### 5.2 Gateway Icons (IGW, NAT, SGW, DRG attachments)
|
||||||
|
- 3-layer nesting: 42x42 outer > 41x41 middle > 40x40 inner
|
||||||
|
- Central symbol: 29x26, internal detail: 22x29
|
||||||
|
- Status indicators: 4x4 dots, 5x1 bars
|
||||||
|
|
||||||
|
### 5.3 OCI Toolkit Icons (Bastion-type)
|
||||||
|
- 40x63 outer > 39x61 middle > 34x58 inner (portrait orientation)
|
||||||
|
|
||||||
|
### 5.4 Database/Storage Icons
|
||||||
|
- 59x63 outer > 58x61 middle > inner symbol 39x26 + 21x21
|
||||||
|
|
||||||
|
### 5.5 External Icons (Internet, On-Premises)
|
||||||
|
- Same 63x63 as service icons
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Spacing Rules
|
||||||
|
|
||||||
|
### 6.1 Vertical Spacing
|
||||||
|
| Measurement | Value |
|
||||||
|
|-------------|-------|
|
||||||
|
| Region label Y offset | 7px from region top |
|
||||||
|
| VCN/Subnet label Y offset | 8px from container top |
|
||||||
|
| VCN top to first subnet | 50px |
|
||||||
|
| Subnet-to-subnet gap | 28px |
|
||||||
|
| VCN-to-VCN gap | 26px |
|
||||||
|
| Workload row gap | 23-27px (~25px) |
|
||||||
|
| Gateway Y within VCN | 29px from VCN top |
|
||||||
|
|
||||||
|
### 6.2 Horizontal Spacing
|
||||||
|
| Measurement | Value |
|
||||||
|
|-------------|-------|
|
||||||
|
| VCN to subnet left indent | 42-44px |
|
||||||
|
| Service icon X within subnet | 80px from subnet left |
|
||||||
|
| Gateway X from VCN left | 168px |
|
||||||
|
| AD-to-AD gap | 18-28px (~25px) |
|
||||||
|
| Inter-region gap (Essbase) | 263px |
|
||||||
|
|
||||||
|
### 6.3 Label Spacing
|
||||||
|
| Measurement | Value |
|
||||||
|
|-------------|-------|
|
||||||
|
| Label box height | 20px (standard) |
|
||||||
|
| Annotation box height | 17px |
|
||||||
|
| Line-to-line spacing | 17px |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Connector Styles
|
||||||
|
|
||||||
|
Oracle Visio-exported .drawio files do NOT use draw.io native edges. Connectors are thin rectangles:
|
||||||
|
- Horizontal: Wx1 rectangles, stroke=#312d2a
|
||||||
|
- Vertical: 2xH or 1xH rectangles
|
||||||
|
- Cross-region (Essbase): stroke=#161513
|
||||||
|
- Examples:
|
||||||
|
- Internet-to-Gateway: 350x1 at (80,153)
|
||||||
|
- Mid-connections: 156x1 at (98,524)
|
||||||
|
- DR replication: 549x1 at (391,1045)
|
||||||
|
- Vertical link: 1x65 at (1128,365)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Border/Stroke Styles
|
||||||
|
|
||||||
|
| Element | strokeColor | strokeWidth | dashed | dashPattern |
|
||||||
|
|---------|------------|-------------|--------|-------------|
|
||||||
|
| Region | #9e9892 | 1 | No | - |
|
||||||
|
| VCN | #aa643b | 2 | Yes | 4.00 2.00 |
|
||||||
|
| Subnet | #aa643b | 2 | Yes | 4.00 2.00 |
|
||||||
|
| AD | #9e9892 | 1 | No | - |
|
||||||
|
| Solid grouping | #312d2a | 2 | No | - |
|
||||||
|
| Connector | #312d2a/#161513 | 1 | No | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Double-Layer Container Rendering
|
||||||
|
|
||||||
|
Oracle renders each container as TWO overlapping shapes:
|
||||||
|
1. Fill layer: fillColor=<color>, strokeColor=none
|
||||||
|
2. Stroke layer: fillColor=none, strokeColor=<color>
|
||||||
|
|
||||||
|
This pattern applies to Region, VCN, Subnet, and AD containers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Key Proportional Ratios
|
||||||
|
|
||||||
|
| Ratio | Value | Description |
|
||||||
|
|-------|-------|-------------|
|
||||||
|
| Gateway:Service icon | 42:63 = 0.67 | Gateways 2/3 size of service icons |
|
||||||
|
| VCN:Subnet width | 226:176 = 1.28 | VCN ~28% wider than subnet |
|
||||||
|
| VCN left padding | 42-44px | Subnet indent |
|
||||||
|
| VCN top padding | 50px | To first subnet |
|
||||||
|
| VCN bottom padding | ~22-26px | After last subnet |
|
||||||
|
| Region aspect (hub-spoke) | 456:845 = 0.54 | Portrait |
|
||||||
|
| AD aspect (multi-AD) | 188:815 = 0.23 | Very tall/narrow |
|
||||||
|
| Workload row aspect | 807:111 = 7.27 | Very wide/short |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Recommended Calibration for oci_diagram_gen.py
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
region:
|
||||||
|
min_width: 456
|
||||||
|
min_height: 845
|
||||||
|
fill: "#f5f4f2"
|
||||||
|
stroke: "#9e9892"
|
||||||
|
stroke_width: 1
|
||||||
|
corner_radius: 0
|
||||||
|
|
||||||
|
vcn:
|
||||||
|
min_width: 226
|
||||||
|
typical_height_hub: 205
|
||||||
|
typical_height_dmz: 354
|
||||||
|
stroke: "#aa643b"
|
||||||
|
stroke_width: 2
|
||||||
|
dashed: true
|
||||||
|
dash_pattern: "4 2"
|
||||||
|
|
||||||
|
subnet:
|
||||||
|
width: 176
|
||||||
|
typical_height: 134-143
|
||||||
|
stroke: "#aa643b"
|
||||||
|
stroke_width: 2
|
||||||
|
dashed: true
|
||||||
|
dash_pattern: "4 2"
|
||||||
|
|
||||||
|
availability_domain:
|
||||||
|
width: 188
|
||||||
|
height: 815
|
||||||
|
fill: "#e4e1dd"
|
||||||
|
stroke: "#9e9892"
|
||||||
|
|
||||||
|
service_icon:
|
||||||
|
outer: 63
|
||||||
|
rendered: 60
|
||||||
|
|
||||||
|
gateway_icon:
|
||||||
|
outer: 42
|
||||||
|
rendered: 40
|
||||||
|
inner_symbol: 29x26
|
||||||
|
|
||||||
|
spacing:
|
||||||
|
vcn_to_vcn_gap: 26
|
||||||
|
subnet_to_subnet_gap: 28
|
||||||
|
vcn_top_to_first_subnet: 50
|
||||||
|
vcn_left_to_subnet: 42
|
||||||
|
subnet_to_service_icon: 80
|
||||||
|
gateway_x_from_vcn_left: 168
|
||||||
|
gateway_y_from_vcn_top: 29
|
||||||
|
label_y_offset: 8
|
||||||
|
region_label_y: 7
|
||||||
|
workload_row_gap: 25
|
||||||
|
ad_to_ad_gap: 25
|
||||||
|
inter_region_gap: 263
|
||||||
|
|
||||||
|
fonts:
|
||||||
|
family: "Oracle Sans"
|
||||||
|
label_size: 17
|
||||||
|
annotation_size: 15
|
||||||
|
label_box_height: 20
|
||||||
|
annotation_box_height: 17
|
||||||
|
|
||||||
|
colors:
|
||||||
|
region_fill: "#f5f4f2"
|
||||||
|
region_stroke: "#9e9892"
|
||||||
|
vcn_stroke: "#aa643b"
|
||||||
|
subnet_stroke: "#aa643b"
|
||||||
|
ad_fill: "#e4e1dd"
|
||||||
|
icon_primary: "#2d5967"
|
||||||
|
text_dark: "#312d2a"
|
||||||
|
connector: "#312d2a"
|
||||||
|
background: "#FFFFFF"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Layout Patterns
|
||||||
|
|
||||||
|
### Single-Region Hub-Spoke
|
||||||
|
- VCNs stacked vertically
|
||||||
|
- External icons (Internet, On-Prem) at far left
|
||||||
|
- Gateways between external and VCN
|
||||||
|
- DRG centered with route table
|
||||||
|
- Service icons right-aligned within subnets
|
||||||
|
|
||||||
|
### Dual-Region DR
|
||||||
|
- Regions side by side horizontally
|
||||||
|
- On-Premises box centered above/between regions
|
||||||
|
- ADs as vertical columns within each region
|
||||||
|
- Workload tiers as horizontal rows crossing all ADs
|
||||||
|
- Compartment boxes at AD-row intersections
|
||||||
|
- DR arrows as horizontal lines between matching rows
|
||||||
184
scripts/validate-diagram.py
Normal file
184
scripts/validate-diagram.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Diagram quality validator — checks generated .drawio files against
|
||||||
|
Oracle Architecture Center visual standards.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/validate-diagram.py examples/output-demo-pharma-mx/opt02-architecture.drawio
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
MIN_ICON_H = 45 # Minimum icon height (px) — Oracle refs use 50-60
|
||||||
|
MIN_ICON_W = 30 # Minimum icon width (px)
|
||||||
|
MAX_EMPTY_RATIO = 0.7 # Max empty space ratio in a container
|
||||||
|
MIN_LABEL_FONT = 10 # Minimum label font size
|
||||||
|
MIN_GAP_PX = 5 # Minimum gap between sibling elements
|
||||||
|
|
||||||
|
|
||||||
|
def validate(filepath: str) -> list:
|
||||||
|
"""Return list of (severity, message) tuples."""
|
||||||
|
issues = []
|
||||||
|
tree = ET.parse(filepath)
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
icon_groups = []
|
||||||
|
containers = []
|
||||||
|
all_cells = {}
|
||||||
|
|
||||||
|
for cell in root.iter('mxCell'):
|
||||||
|
cid = cell.get('id', '')
|
||||||
|
style = cell.get('style', '')
|
||||||
|
value = cell.get('value', '')
|
||||||
|
geo = cell.find('mxGeometry')
|
||||||
|
all_cells[cid] = cell
|
||||||
|
|
||||||
|
if geo is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
w = float(geo.get('width', 0))
|
||||||
|
h = float(geo.get('height', 0))
|
||||||
|
x = float(geo.get('x', 0))
|
||||||
|
y = float(geo.get('y', 0))
|
||||||
|
|
||||||
|
# Icon groups
|
||||||
|
if 'group' in style and 'connectable=1' in style:
|
||||||
|
icon_groups.append({
|
||||||
|
'id': cid, 'x': x, 'y': y, 'w': w, 'h': h,
|
||||||
|
'parent': cell.get('parent', ''),
|
||||||
|
})
|
||||||
|
if h < MIN_ICON_H:
|
||||||
|
issues.append(('WARN', f'Icon {cid} too short: {h:.0f}px (min {MIN_ICON_H})'))
|
||||||
|
if w < MIN_ICON_W:
|
||||||
|
issues.append(('WARN', f'Icon {cid} too narrow: {w:.0f}px (min {MIN_ICON_W})'))
|
||||||
|
|
||||||
|
# Containers (regions, VCNs, subnets)
|
||||||
|
if value and any(kw in value for kw in ['Region', 'VCN', 'Subnet', 'Oracle Cloud']):
|
||||||
|
containers.append({
|
||||||
|
'id': cid, 'name': value[:40], 'x': x, 'y': y, 'w': w, 'h': h,
|
||||||
|
'parent': cell.get('parent', ''),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check children overflow parents
|
||||||
|
container_map = {}
|
||||||
|
for cell in root.iter('mxCell'):
|
||||||
|
cid = cell.get('id', '')
|
||||||
|
val = cell.get('value', '')
|
||||||
|
geo = cell.find('mxGeometry')
|
||||||
|
if geo is not None and val:
|
||||||
|
container_map[cid] = {
|
||||||
|
'name': val[:50], 'x': float(geo.get('x', 0)),
|
||||||
|
'y': float(geo.get('y', 0)), 'w': float(geo.get('width', 0)),
|
||||||
|
'h': float(geo.get('height', 0)), 'parent': cell.get('parent', ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
for cid, c in container_map.items():
|
||||||
|
parent = container_map.get(c['parent'])
|
||||||
|
if parent:
|
||||||
|
child_right = c['x'] + c['w']
|
||||||
|
child_bottom = c['y'] + c['h']
|
||||||
|
overflow_r = child_right - parent['w']
|
||||||
|
overflow_b = child_bottom - parent['h']
|
||||||
|
if overflow_r > 2:
|
||||||
|
issues.append(('ERROR', f'{c["name"]} overflows {parent["name"]} RIGHT by {overflow_r:.0f}px'))
|
||||||
|
if overflow_b > 2:
|
||||||
|
issues.append(('ERROR', f'{c["name"]} overflows {parent["name"]} BOTTOM by {overflow_b:.0f}px'))
|
||||||
|
|
||||||
|
# Check for overlapping siblings (same parent)
|
||||||
|
by_parent = {}
|
||||||
|
for ig in icon_groups:
|
||||||
|
by_parent.setdefault(ig['parent'], []).append(ig)
|
||||||
|
|
||||||
|
for parent_id, siblings in by_parent.items():
|
||||||
|
siblings.sort(key=lambda s: (s['y'], s['x']))
|
||||||
|
for i in range(len(siblings) - 1):
|
||||||
|
a = siblings[i]
|
||||||
|
b = siblings[i + 1]
|
||||||
|
# Check vertical overlap (same column, x ranges overlap)
|
||||||
|
x_overlap = not (a['x'] + a['w'] < b['x'] or b['x'] + b['w'] < a['x'])
|
||||||
|
if x_overlap:
|
||||||
|
gap = b['y'] - (a['y'] + a['h'])
|
||||||
|
if gap < MIN_GAP_PX:
|
||||||
|
issues.append(('ERROR', f'Overlap between icons in parent {parent_id}: gap={gap:.0f}px'))
|
||||||
|
|
||||||
|
# Check container utilization (warn on mostly empty containers)
|
||||||
|
for cont in containers:
|
||||||
|
children_area = 0
|
||||||
|
for ig in icon_groups:
|
||||||
|
if ig['parent'] == cont['id']:
|
||||||
|
children_area += ig['w'] * ig['h']
|
||||||
|
for child_cont in containers:
|
||||||
|
if child_cont['parent'] == cont['id']:
|
||||||
|
children_area += child_cont['w'] * child_cont['h']
|
||||||
|
cont_area = cont['w'] * cont['h']
|
||||||
|
if cont_area > 0:
|
||||||
|
fill_ratio = children_area / cont_area
|
||||||
|
if fill_ratio < (1 - MAX_EMPTY_RATIO):
|
||||||
|
issues.append(('INFO', f'{cont["name"]}: {fill_ratio:.0%} filled ({cont["w"]:.0f}x{cont["h"]:.0f})'))
|
||||||
|
|
||||||
|
# Check edge labels have offset (mxPoint as="offset")
|
||||||
|
edges_with_label = 0
|
||||||
|
edges_with_offset = 0
|
||||||
|
for cell in root.iter('mxCell'):
|
||||||
|
if cell.get('edge') == '1' and cell.get('value', '').strip():
|
||||||
|
edges_with_label += 1
|
||||||
|
geo = cell.find('mxGeometry')
|
||||||
|
if geo is not None:
|
||||||
|
offset = geo.find('.//mxPoint[@as="offset"]')
|
||||||
|
if offset is not None:
|
||||||
|
edges_with_offset += 1
|
||||||
|
|
||||||
|
if edges_with_label > 0 and edges_with_offset < edges_with_label:
|
||||||
|
issues.append(('WARN', f'{edges_with_label - edges_with_offset}/{edges_with_label} edge labels missing offset'))
|
||||||
|
|
||||||
|
# Check jettySize=auto present
|
||||||
|
has_jetty = False
|
||||||
|
for cell in root.iter('mxCell'):
|
||||||
|
if 'jettySize=auto' in cell.get('style', ''):
|
||||||
|
has_jetty = True
|
||||||
|
break
|
||||||
|
if not has_jetty:
|
||||||
|
issues.append(('WARN', 'No edges with jettySize=auto'))
|
||||||
|
|
||||||
|
# Check container=1 present
|
||||||
|
has_container = False
|
||||||
|
for cell in root.iter('mxCell'):
|
||||||
|
if 'container=1' in cell.get('style', ''):
|
||||||
|
has_container = True
|
||||||
|
break
|
||||||
|
if not has_container:
|
||||||
|
issues.append(('WARN', 'No containers with explicit container=1 style'))
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(f"Usage: {sys.argv[0]} <diagram.drawio>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
filepath = sys.argv[1]
|
||||||
|
issues = validate(filepath)
|
||||||
|
|
||||||
|
if not issues:
|
||||||
|
print(f"✅ {filepath}: All checks passed")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
errors = [i for i in issues if i[0] == 'ERROR']
|
||||||
|
warns = [i for i in issues if i[0] == 'WARN']
|
||||||
|
infos = [i for i in issues if i[0] == 'INFO']
|
||||||
|
|
||||||
|
print(f"📋 Diagram validation: {filepath}")
|
||||||
|
print(f" {len(errors)} errors, {len(warns)} warnings, {len(infos)} info")
|
||||||
|
print()
|
||||||
|
|
||||||
|
for severity, msg in issues:
|
||||||
|
icon = {'ERROR': '❌', 'WARN': '⚠️', 'INFO': 'ℹ️'}.get(severity, '?')
|
||||||
|
print(f" {icon} [{severity}] {msg}")
|
||||||
|
|
||||||
|
sys.exit(1 if errors else 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -78,15 +78,15 @@ STYLES = {
|
|||||||
"fontSize=11;"
|
"fontSize=11;"
|
||||||
),
|
),
|
||||||
"vcn": (
|
"vcn": (
|
||||||
"whiteSpace=wrap;html=1;strokeWidth=2;dashed=1;dashPattern=6 4;align=left;"
|
"whiteSpace=wrap;html=1;strokeWidth=2;dashed=1;dashPattern=4 2;align=left;"
|
||||||
"fontFamily=Oracle Sans;verticalAlign=top;fillColor=none;"
|
"fontFamily=Oracle Sans;verticalAlign=top;fillColor=none;"
|
||||||
"fontColor=#AE562C;strokeColor=#AE562C;perimeterSpacing=0;"
|
"fontColor=#aa643b;strokeColor=#aa643b;perimeterSpacing=0;"
|
||||||
"fontSize=12;fontStyle=1;spacingLeft=8;spacingTop=6;"
|
"fontSize=12;fontStyle=1;spacingLeft=8;spacingTop=6;"
|
||||||
),
|
),
|
||||||
"subnet": (
|
"subnet": (
|
||||||
"whiteSpace=wrap;html=1;strokeWidth=1;dashed=1;dashPattern=6 4;align=left;"
|
"whiteSpace=wrap;html=1;strokeWidth=2;dashed=1;dashPattern=4 2;align=left;"
|
||||||
"fontFamily=Oracle Sans;verticalAlign=top;fillColor=none;"
|
"fontFamily=Oracle Sans;verticalAlign=top;fillColor=none;"
|
||||||
"fontColor=#AE562C;strokeColor=#AE562C;fontSize=11;fontStyle=1;"
|
"fontColor=#aa643b;strokeColor=#aa643b;fontSize=11;fontStyle=1;"
|
||||||
"spacingLeft=8;spacingTop=4;"
|
"spacingLeft=8;spacingTop=4;"
|
||||||
),
|
),
|
||||||
"compartment": (
|
"compartment": (
|
||||||
@@ -473,6 +473,52 @@ class OCIDiagramGenerator:
|
|||||||
.replace('"', """)
|
.replace('"', """)
|
||||||
.replace("\n", "
"))
|
.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:
|
def _get_svc_style(self, service_type: str, font_size: Optional[int] = None) -> str:
|
||||||
"""Get the style string for a service type."""
|
"""Get the style string for a service type."""
|
||||||
category = SVC_CATEGORY.get(service_type, "svc_infra")
|
category = SVC_CATEGORY.get(service_type, "svc_infra")
|
||||||
@@ -744,27 +790,61 @@ class OCIDiagramGenerator:
|
|||||||
icon_cells = icon_entry["cells"]
|
icon_cells = icon_entry["cells"]
|
||||||
|
|
||||||
# Label height: proportional to actual line count (no over-allocation).
|
# Label height: proportional to actual line count (no over-allocation).
|
||||||
# ~15px per line at 11pt Oracle Sans, minimum 20px for single-line labels.
|
# ~16px per line at 12pt Oracle Sans, minimum 20px for single-line labels.
|
||||||
n_lines = label.count('\n') + 1
|
n_lines = label.count('\n') + 1
|
||||||
label_h = max(n_lines * 16 + 4, 20)
|
label_h = max(n_lines * 16 + 4, 20)
|
||||||
|
|
||||||
# Icon area = full spec height minus label height
|
# ── Icon sizing: Oracle ref arch standard ──
|
||||||
icon_area_h = max(h - label_h, 20)
|
# 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)
|
||||||
|
|
||||||
# Scale icon to fit the icon area (never upscale beyond original size)
|
# For tall/narrow icons (ratio > 1.3), we need more vertical space
|
||||||
scale = min(w / icon_w, icon_area_h / icon_h, 1.0)
|
# 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_w = icon_w * scale
|
||||||
scaled_h = icon_h * scale
|
scaled_h = icon_h * scale
|
||||||
|
|
||||||
# Icon group height = scaled icon height only.
|
# If too narrow, scale up to meet minimum width
|
||||||
# Connections terminate here — at the visible icon boundary.
|
if scaled_w < ICON_MIN_W:
|
||||||
icon_group_h = max(int(scaled_h), 20)
|
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_style = (
|
||||||
"group;fillColor=none;strokeColor=none;pointerEvents=1;"
|
"group;fillColor=none;strokeColor=none;pointerEvents=1;"
|
||||||
"connectable=1;"
|
"connectable=1;"
|
||||||
)
|
)
|
||||||
self._create_object(cell_id, "", group_style, parent, x, y, w, icon_group_h)
|
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
|
# Build ID mapping: original cell id -> unique cell id
|
||||||
original_ids = set()
|
original_ids = set()
|
||||||
@@ -777,8 +857,8 @@ class OCIDiagramGenerator:
|
|||||||
key=lambda x_: int(x_) if x_.isdigit() else 0):
|
key=lambda x_: int(x_) if x_.isdigit() else 0):
|
||||||
id_map[orig_id] = f"{cell_id}_i{orig_id}"
|
id_map[orig_id] = f"{cell_id}_i{orig_id}"
|
||||||
|
|
||||||
# Center the icon horizontally within the group
|
# Center the icon horizontally within the (now tight) group
|
||||||
icon_offset_x = (w - scaled_w) / 2
|
icon_offset_x = (icon_group_w - scaled_w) / 2
|
||||||
|
|
||||||
# Use a placeholder for the group's drawpyo ID — replaced in to_xml()
|
# Use a placeholder for the group's drawpyo ID — replaced in to_xml()
|
||||||
group_placeholder = f"__DRAWPYO_{cell_id}__"
|
group_placeholder = f"__DRAWPYO_{cell_id}__"
|
||||||
@@ -830,8 +910,8 @@ class OCIDiagramGenerator:
|
|||||||
|
|
||||||
# Label is a SIBLING of the icon group (child of `parent`, not of `cell_id`).
|
# 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.
|
# This keeps connection geometry clean: arrows connect to the icon group only.
|
||||||
# Oracle ref arch style: 11pt, charcoal (#312D2A), centered below icon.
|
# Oracle ref arch style: 12pt, charcoal (#312D2A), centered below icon.
|
||||||
fs = font_size or 11
|
fs = font_size or 12
|
||||||
label_style = (
|
label_style = (
|
||||||
f"text;html=1;whiteSpace=wrap;overflow=visible;align=center;verticalAlign=top;"
|
f"text;html=1;whiteSpace=wrap;overflow=visible;align=center;verticalAlign=top;"
|
||||||
f"fontFamily=Oracle Sans;fontSize={fs};fontColor=#312D2A;"
|
f"fontFamily=Oracle Sans;fontSize={fs};fontColor=#312D2A;"
|
||||||
@@ -909,6 +989,7 @@ class OCIDiagramGenerator:
|
|||||||
# - NO elbow=vertical (it conflicts with orthogonal + port constraints)
|
# - NO elbow=vertical (it conflicts with orthogonal + port constraints)
|
||||||
extra_style = (
|
extra_style = (
|
||||||
"edgeStyle=orthogonalEdgeStyle;orthogonalLoop=1;"
|
"edgeStyle=orthogonalEdgeStyle;orthogonalLoop=1;"
|
||||||
|
"jettySize=auto;"
|
||||||
"jumpStyle=arc;jumpSize=8;"
|
"jumpStyle=arc;jumpSize=8;"
|
||||||
"endFill=0;startFill=0;"
|
"endFill=0;startFill=0;"
|
||||||
"labelBackgroundColor=#FFFFFF;labelBorderColor=none;"
|
"labelBackgroundColor=#FFFFFF;labelBorderColor=none;"
|
||||||
@@ -1121,6 +1202,60 @@ class OCIDiagramGenerator:
|
|||||||
if pattern in xml:
|
if pattern in xml:
|
||||||
xml = xml.replace(pattern, f'id="{edge_id}" style="{extra_style}')
|
xml = xml.replace(pattern, f'id="{edge_id}" style="{extra_style}')
|
||||||
|
|
||||||
|
# ── Edge label offset injection ──
|
||||||
|
# Push labels away from the edge midpoint to avoid overlapping with
|
||||||
|
# source/target icons. Uses mxGeometry relative positioning:
|
||||||
|
# x: position along edge (-1=source, 0=center, 1=target)
|
||||||
|
# <mxPoint as="offset">: absolute pixel offset from that position
|
||||||
|
# Horizontal edges: push label 15px above the line (offset y=-15)
|
||||||
|
# Vertical edges: push label 15px to the right (offset x=15)
|
||||||
|
for edge_id, (src_id, tgt_id) in self._edge_objects.items():
|
||||||
|
src_pos = self._abs_positions.get(src_id)
|
||||||
|
tgt_pos = self._abs_positions.get(tgt_id)
|
||||||
|
if not src_pos or not tgt_pos:
|
||||||
|
continue
|
||||||
|
src_obj = self._objects.get(src_id)
|
||||||
|
tgt_obj = self._objects.get(tgt_id)
|
||||||
|
if not src_obj or not tgt_obj:
|
||||||
|
continue
|
||||||
|
sx = src_pos[0] + (getattr(src_obj, 'width', 100) or 100) / 2
|
||||||
|
sy = src_pos[1] + (getattr(src_obj, 'height', 60) or 60) / 2
|
||||||
|
tx = tgt_pos[0] + (getattr(tgt_obj, 'width', 100) or 100) / 2
|
||||||
|
ty = tgt_pos[1] + (getattr(tgt_obj, 'height', 60) or 60) / 2
|
||||||
|
dx = tx - sx
|
||||||
|
dy = ty - sy
|
||||||
|
dist = max((dx**2 + dy**2) ** 0.5, 1)
|
||||||
|
# Larger offset for short connections to avoid icon overlap
|
||||||
|
base_offset = 20 if dist < 300 else 15
|
||||||
|
is_horiz = abs(dx) > abs(dy) * 1.5
|
||||||
|
if is_horiz:
|
||||||
|
offset_x, offset_y = 0, -base_offset
|
||||||
|
else:
|
||||||
|
offset_x, offset_y = base_offset, 0
|
||||||
|
# Find the edge's mxGeometry and inject label offset.
|
||||||
|
# drawpyo emits: <mxGeometry relative="1" as="geometry" />
|
||||||
|
# We replace with expanded form containing offset point.
|
||||||
|
new_geo = (
|
||||||
|
f'<mxGeometry x="-0.2" y="0" relative="1" as="geometry">'
|
||||||
|
f'<mxPoint x="{offset_x}" y="{offset_y}" as="offset" />'
|
||||||
|
f'</mxGeometry>'
|
||||||
|
)
|
||||||
|
# Match both self-closing variants that drawpyo may produce
|
||||||
|
for old_geo in [
|
||||||
|
'<mxGeometry relative="1" as="geometry" />',
|
||||||
|
'<mxGeometry relative="1" as="geometry"/>',
|
||||||
|
]:
|
||||||
|
marker = f'id="{edge_id}"'
|
||||||
|
if marker in xml and old_geo in xml:
|
||||||
|
# Only replace the mxGeometry that belongs to THIS edge
|
||||||
|
# Find the edge cell, then replace its geometry
|
||||||
|
idx = xml.find(marker)
|
||||||
|
if idx >= 0:
|
||||||
|
geo_idx = xml.find(old_geo, idx)
|
||||||
|
if geo_idx >= 0 and geo_idx - idx < 2000: # sanity: within same cell
|
||||||
|
xml = xml[:geo_idx] + new_geo + xml[geo_idx + len(old_geo):]
|
||||||
|
break
|
||||||
|
|
||||||
# Inject raw icon stencil cells before </root>
|
# Inject raw icon stencil cells before </root>
|
||||||
if self._raw_cells:
|
if self._raw_cells:
|
||||||
inject = "\n".join(f" {c}" for c in self._raw_cells)
|
inject = "\n".join(f" {c}" for c in self._raw_cells)
|
||||||
@@ -1132,6 +1267,17 @@ class OCIDiagramGenerator:
|
|||||||
if placeholder in xml:
|
if placeholder in xml:
|
||||||
xml = xml.replace(placeholder, str(obj.id))
|
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
|
# Sanitize: no pure white in OCI palette
|
||||||
xml = xml.replace('fill="#ffffff"', 'fill="#FCFBFA"')
|
xml = xml.replace('fill="#ffffff"', 'fill="#FCFBFA"')
|
||||||
xml = xml.replace('fill="#FFFFFF"', 'fill="#FCFBFA"')
|
xml = xml.replace('fill="#FFFFFF"', 'fill="#FCFBFA"')
|
||||||
@@ -1219,35 +1365,137 @@ class OCIDiagramGenerator:
|
|||||||
else:
|
else:
|
||||||
gen.add_service(svc_id, label, svc.get("type", "compute"),
|
gen.add_service(svc_id, label, svc.get("type", "compute"),
|
||||||
cloud_id, cx, cy, svc_w, svc_h)
|
cloud_id, cx, cy, svc_w, svc_h)
|
||||||
cy += svc_h + 20
|
actual_h = gen._calc_service_block_h(label, svc.get("type", "compute"))
|
||||||
|
cy += max(svc_h, actual_h) + 20
|
||||||
|
|
||||||
# Tenancy
|
# Tenancy — auto-size from regions if w/h not specified in spec
|
||||||
tenancy_spec = spec.get("tenancy", {})
|
tenancy_spec = spec.get("tenancy", {})
|
||||||
tenancy_x = tenancy_spec.get("x", 30)
|
tenancy_x = tenancy_spec.get("x", 30)
|
||||||
tenancy_y = tenancy_spec.get("y", 80)
|
tenancy_y = tenancy_spec.get("y", 80)
|
||||||
tenancy_w = tenancy_spec.get("w", 1850)
|
|
||||||
tenancy_h = tenancy_spec.get("h", 720)
|
# 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(
|
gen.add_tenancy(
|
||||||
"tenancy",
|
"tenancy",
|
||||||
tenancy_spec.get("label", "Oracle Cloud Infrastructure"),
|
tenancy_spec.get("label", "Oracle Cloud Infrastructure"),
|
||||||
x=tenancy_x, y=tenancy_y, w=tenancy_w, h=tenancy_h,
|
x=tenancy_x, y=tenancy_y, w=tenancy_w, h=tenancy_h,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Regions
|
# 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", []):
|
for region in tenancy_spec.get("regions", []):
|
||||||
is_primary = region.get("primary", False)
|
is_primary = region.get("primary", False)
|
||||||
rx = region.get("x", 15 if is_primary else 1195)
|
rx = region.get("x", region_x_cursor)
|
||||||
# ry default 40: ~18px below tenancy label (12pt bold) + comfortable padding
|
|
||||||
ry = region.get("y", 40)
|
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)
|
# ── 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(
|
gen.add_region(
|
||||||
region["id"],
|
region["id"],
|
||||||
region["label"],
|
region["label"],
|
||||||
"tenancy", rx, ry, rw, rh,
|
"tenancy", rx, ry, rw, rh,
|
||||||
)
|
)
|
||||||
|
# Advance cursor for next region positioning
|
||||||
|
region_x_cursor = rx + rw + 20
|
||||||
|
|
||||||
# Availability Domains (optional)
|
# Availability Domains (optional)
|
||||||
for ad in region.get("availability_domains", []):
|
for ad in region.get("availability_domains", []):
|
||||||
@@ -1257,60 +1505,94 @@ class OCIDiagramGenerator:
|
|||||||
w=ad.get("w", rw - 30), h=ad.get("h", rh - 70),
|
w=ad.get("w", rw - 30), h=ad.get("h", rh - 70),
|
||||||
)
|
)
|
||||||
|
|
||||||
# VCNs
|
# ── DRG placement: OUTSIDE VCN, INSIDE region ──
|
||||||
for vcn in region.get("vcns", []):
|
# 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"])
|
vcn_parent = vcn.get("parent", region["id"])
|
||||||
vcn_w = vcn.get("w", rw - 30)
|
auto_w = vcn.get("_auto_w", rw - vcn_offset_x - 15)
|
||||||
vcn_h = vcn.get("h", rh - 70)
|
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(
|
gen.add_vcn(
|
||||||
vcn["id"],
|
vcn["id"],
|
||||||
f"VCN {vcn['label']}",
|
f"VCN {vcn['label']}",
|
||||||
vcn_parent,
|
vcn_parent,
|
||||||
# y default 45: leaves proper gap below the region label (12pt ≈22px + padding)
|
x=vcn.get("x", vcn_offset_x), y=vcn.get("y", 45),
|
||||||
x=vcn.get("x", 15), y=vcn.get("y", 45),
|
|
||||||
w=vcn_w, h=vcn_h,
|
w=vcn_w, h=vcn_h,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── GATEWAY-AWARE AUTO-LAYOUT ──
|
# ── VCN-internal gateways (IGW, NAT, SGW — NOT DRG) ──
|
||||||
# Step 1: Place gateways on the LEFT EDGE of the VCN.
|
vcn_gateways = vcn.get("_vcn_gateways", [])
|
||||||
# Step 2: Offset subnets RIGHT to avoid overlap with gateways.
|
gw_lane_w = 0
|
||||||
# Step 3: Stack subnets vertically (top-to-bottom).
|
|
||||||
# This matches Oracle ref arch style: gateways on VCN boundary,
|
|
||||||
# subnets fill the remaining width.
|
|
||||||
|
|
||||||
gateways = vcn.get("gateways", [])
|
if vcn_gateways:
|
||||||
gw_lane_w = 0 # width reserved for gateway column
|
GW_DEFAULT_W = 110
|
||||||
|
GW_DEFAULT_H = gen.GW_DEFAULT_H
|
||||||
if gateways:
|
GW_SPACING = gen.GW_SPACING
|
||||||
# Calculate gateway lane width (max gateway width + padding)
|
gw_lane_w = max(gw.get("w", GW_DEFAULT_W) for gw in vcn_gateways) + 20
|
||||||
gw_lane_w = max(gw.get("w", 110) for gw in 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)
|
||||||
|
|
||||||
# Place gateways vertically centered in VCN
|
|
||||||
total_gw_h = sum(gw.get("h", 70) for gw in gateways) + 8 * max(0, len(gateways) - 1)
|
|
||||||
gw_start_y = max(32, (vcn_h - total_gw_h) // 2)
|
gw_start_y = max(32, (vcn_h - total_gw_h) // 2)
|
||||||
gy = gw_start_y
|
gy = gw_start_y
|
||||||
for gw in gateways:
|
for gw in vcn_gateways:
|
||||||
gw_id = gw.get("id", gen._next_id())
|
gw_id = gw.get("id", gen._next_id())
|
||||||
gw_w = gw.get("w", 110)
|
gw_w = gw.get("w", GW_DEFAULT_W)
|
||||||
gw_h = gw.get("h", 70)
|
|
||||||
gw_x = gw.get("x", 10)
|
gw_x = gw.get("x", 10)
|
||||||
gw_y = gw.get("y", gy)
|
gw_y = gw.get("y", gy)
|
||||||
label = gw["label"].replace("\\n", "\n")
|
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(
|
gen.add_service(
|
||||||
gw_id, label, gw["type"],
|
gw_id, label, gw["type"],
|
||||||
vcn["id"], gw_x, gw_y, gw_w, gw_h,
|
vcn["id"], gw_x, gw_y, gw_w, gw_h,
|
||||||
)
|
)
|
||||||
gy = gw_y + gw_h + 8
|
# Stack using actual rendered height, not spec height
|
||||||
|
gy = gw_y + actual_block_h + GW_SPACING
|
||||||
|
|
||||||
# Subnet horizontal offset: push right if gateways present
|
# Subnet offset — Oracle ref arch: 42px left indent, 50px top
|
||||||
subnet_x = 8 + gw_lane_w
|
VCN_LEFT_PAD = 42 if not vcn_gateways else 8
|
||||||
subnet_avail_w = vcn_w - subnet_x - 8 # remaining width for subnets
|
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)
|
# Stack subnets vertically (top-to-bottom)
|
||||||
subnet_y = 32
|
subnet_y = VCN_TOP_PAD
|
||||||
|
SVC_SPACING = gen.SVC_SPACING
|
||||||
for subnet in vcn.get("subnets", []):
|
for subnet in vcn.get("subnets", []):
|
||||||
sw = subnet.get("w", subnet_avail_w)
|
sw = subnet.get("w", subnet_avail_w)
|
||||||
sh = subnet.get("h", 120)
|
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(
|
gen.add_subnet(
|
||||||
subnet["id"], subnet["label"],
|
subnet["id"], subnet["label"],
|
||||||
vcn["id"], subnet_x, subnet_y, sw, sh,
|
vcn["id"], subnet_x, subnet_y, sw, sh,
|
||||||
@@ -1318,22 +1600,22 @@ class OCIDiagramGenerator:
|
|||||||
|
|
||||||
# Auto-layout services HORIZONTALLY within subnet, centered.
|
# Auto-layout services HORIZONTALLY within subnet, centered.
|
||||||
svc_top = 30
|
svc_top = 30
|
||||||
subnet_svcs = subnet.get("services", [])
|
|
||||||
svc_widths = [svc.get("w", min(sw - 30, 150)) for svc in subnet_svcs]
|
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)
|
total_svc_w = sum(svc_widths) + SVC_SPACING * max(0, len(svc_widths) - 1)
|
||||||
svc_x = max(15, (sw - total_svc_w) // 2)
|
svc_x = max(15, (sw - total_svc_w) // 2)
|
||||||
for svc, svc_w in zip(subnet_svcs, svc_widths):
|
for svc, svc_w in zip(subnet_svcs, svc_widths):
|
||||||
svc_id = svc.get("id", gen._next_id())
|
svc_id = svc.get("id", gen._next_id())
|
||||||
svc_h = svc.get("h", 80)
|
# 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")
|
label = svc["label"].replace("\\n", "\n")
|
||||||
gen.add_service(
|
gen.add_service(
|
||||||
svc_id, label, svc["type"],
|
svc_id, label, svc["type"],
|
||||||
subnet["id"], svc_x, svc_top, svc_w, svc_h,
|
subnet["id"], svc_x, svc_top, svc_w, svc_h,
|
||||||
font_size=svc.get("fontSize"),
|
font_size=svc.get("fontSize"),
|
||||||
)
|
)
|
||||||
svc_x += svc_w + 16
|
svc_x += svc_w + SVC_SPACING
|
||||||
|
|
||||||
subnet_y += sh + 14
|
subnet_y += sh + SUBNET_GAP
|
||||||
|
|
||||||
# Compartments inside region — can contain VCNs
|
# Compartments inside region — can contain VCNs
|
||||||
for comp in region.get("compartments", []):
|
for comp in region.get("compartments", []):
|
||||||
@@ -1381,22 +1663,23 @@ class OCIDiagramGenerator:
|
|||||||
font_size=svc.get("fontSize"),
|
font_size=svc.get("fontSize"),
|
||||||
)
|
)
|
||||||
svc_x += svc_w + 16
|
svc_x += svc_w + 16
|
||||||
subnet_y += sh + 14
|
subnet_y += sh + 28 # Oracle ref: subnet gap
|
||||||
# Gateways on left side of compartment VCN, stacked vertically
|
# Gateways on left side of compartment VCN, stacked vertically
|
||||||
gx = 10
|
gx = 10
|
||||||
gy = 40
|
gy = 40
|
||||||
for gw in vcn.get("gateways", []):
|
for gw in vcn.get("gateways", []):
|
||||||
gw_id = gw.get("id", gen._next_id())
|
gw_id = gw.get("id", gen._next_id())
|
||||||
gw_w = gw.get("w", 95)
|
gw_w = gw.get("w", 95)
|
||||||
gw_h = gw.get("h", 60)
|
|
||||||
gw_x = gw.get("x", gx)
|
gw_x = gw.get("x", gx)
|
||||||
gw_y = gw.get("y", gy)
|
gw_y = gw.get("y", gy)
|
||||||
label = gw["label"].replace("\\n", "\n")
|
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(
|
gen.add_service(
|
||||||
gw_id, label, gw["type"],
|
gw_id, label, gw["type"],
|
||||||
vcn["id"], gw_x, gw_y, gw_w, gw_h,
|
vcn["id"], gw_x, gw_y, gw_w, gw_h,
|
||||||
)
|
)
|
||||||
gy = gw_y + gw_h + 8
|
gy = gw_y + actual_h + cls.GW_SPACING
|
||||||
|
|
||||||
# Observability row at bottom of region — uses icons (only if explicitly requested)
|
# Observability row at bottom of region — uses icons (only if explicitly requested)
|
||||||
if region.get("observability"):
|
if region.get("observability"):
|
||||||
@@ -1441,8 +1724,28 @@ class OCIDiagramGenerator:
|
|||||||
)
|
)
|
||||||
opx += svc_w + 12
|
opx += svc_w + 12
|
||||||
|
|
||||||
# Connections
|
# Connections — merge duplicate from/to pairs (e.g., dual FastConnect)
|
||||||
for conn in spec.get("connections", []):
|
# 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())
|
conn_id = conn.get("id", gen._next_id())
|
||||||
gen.add_connection(
|
gen.add_connection(
|
||||||
conn_id, conn.get("label"), conn["type"],
|
conn_id, conn.get("label"), conn["type"],
|
||||||
|
|||||||
@@ -273,14 +273,72 @@ def validate_catalog():
|
|||||||
return errors == 0
|
return errors == 0
|
||||||
|
|
||||||
|
|
||||||
|
def check_links():
|
||||||
|
"""Check all catalog URLs for broken links (HTTP HEAD requests)."""
|
||||||
|
data = load_catalog()
|
||||||
|
entries = data.get("entries", [])
|
||||||
|
broken = []
|
||||||
|
redirected = []
|
||||||
|
ok_count = 0
|
||||||
|
|
||||||
|
print(f"Checking {len(entries)} URLs...")
|
||||||
|
for i, entry in enumerate(entries):
|
||||||
|
url = entry.get("url", "")
|
||||||
|
title = entry.get("title", "UNKNOWN")
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
resp = requests.head(url, timeout=10, allow_redirects=True)
|
||||||
|
if resp.status_code == 404:
|
||||||
|
broken.append((title, url))
|
||||||
|
print(f" ❌ 404: {title}")
|
||||||
|
elif resp.status_code in (301, 302) or resp.url != url:
|
||||||
|
redirected.append((title, url, resp.url))
|
||||||
|
print(f" ↪️ Redirect: {title} → {resp.url}")
|
||||||
|
ok_count += 1
|
||||||
|
elif resp.status_code < 400:
|
||||||
|
ok_count += 1
|
||||||
|
else:
|
||||||
|
broken.append((title, url))
|
||||||
|
print(f" ⚠️ {resp.status_code}: {title}")
|
||||||
|
except requests.RequestException as e:
|
||||||
|
broken.append((title, url))
|
||||||
|
print(f" ⚠️ Error: {title} — {e}")
|
||||||
|
# Rate limit: 200ms between requests
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"Results: {ok_count} OK, {len(broken)} broken, {len(redirected)} redirected")
|
||||||
|
|
||||||
|
if broken:
|
||||||
|
print(f"\n❌ Broken links ({len(broken)}):")
|
||||||
|
for title, url in broken:
|
||||||
|
print(f" - {title}")
|
||||||
|
print(f" {url}")
|
||||||
|
|
||||||
|
if redirected:
|
||||||
|
print(f"\n↪️ Redirected ({len(redirected)}):")
|
||||||
|
for title, old, new in redirected:
|
||||||
|
print(f" - {title}")
|
||||||
|
print(f" OLD: {old}")
|
||||||
|
print(f" NEW: {new}")
|
||||||
|
|
||||||
|
return len(broken) == 0
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Refresh Architecture Center catalog")
|
parser = argparse.ArgumentParser(description="Refresh Architecture Center catalog")
|
||||||
parser.add_argument("--url", help="Fetch a single URL and print YAML entry")
|
parser.add_argument("--url", help="Fetch a single URL and print YAML entry")
|
||||||
parser.add_argument("--whats-new", action="store_true", help="Crawl What's New pages for new entries")
|
parser.add_argument("--whats-new", action="store_true", help="Crawl What's New pages for new entries")
|
||||||
parser.add_argument("--validate", action="store_true", help="Validate existing catalog")
|
parser.add_argument("--validate", action="store_true", help="Validate existing catalog")
|
||||||
|
parser.add_argument("--check-links", action="store_true", help="Check all URLs for 404s and redirects")
|
||||||
parser.add_argument("--output", default=CATALOG_PATH, help="Output file path")
|
parser.add_argument("--output", default=CATALOG_PATH, help="Output file path")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.check_links:
|
||||||
|
ok = check_links()
|
||||||
|
sys.exit(0 if ok else 1)
|
||||||
|
|
||||||
if args.validate:
|
if args.validate:
|
||||||
ok = validate_catalog()
|
ok = validate_catalog()
|
||||||
sys.exit(0 if ok else 1)
|
sys.exit(0 if ok else 1)
|
||||||
|
|||||||
Reference in New Issue
Block a user