Compare commits
10 Commits
77298a471c
...
automation
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32319b67d7 | ||
|
|
9fbe05a6a5 | ||
|
|
9b4d04cf34 | ||
|
|
30394ece48 | ||
|
|
480c4bca52 | ||
|
|
433b08071b | ||
|
|
874d40d38a | ||
|
|
a22711c065 | ||
|
|
9d5cda8986 | ||
|
|
6de2251e00 |
@@ -19,13 +19,10 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Python
|
- name: Setup Python
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update -qq
|
apt-get update -qq
|
||||||
sudo apt-get install -y python3 python3-pip
|
apt-get install -y --no-install-recommends python3 python3-requests python3-bs4 python3-yaml
|
||||||
python3 --version
|
python3 --version
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: python3 -m pip install --user requests beautifulsoup4 pyyaml
|
|
||||||
|
|
||||||
- name: Check Architecture Center links
|
- name: Check Architecture Center links
|
||||||
id: check
|
id: check
|
||||||
continue-on-error: true
|
continue-on-error: true
|
||||||
@@ -71,7 +68,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload report
|
- name: Upload report
|
||||||
if: always()
|
if: always()
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: kb-health-report
|
name: kb-health-report
|
||||||
path: /tmp/link-check.txt
|
path: /tmp/link-check.txt
|
||||||
|
|||||||
147
.gitea/workflows/sku-catalog-refresh.yaml
Normal file
147
.gitea/workflows/sku-catalog-refresh.yaml
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# Monthly SKU catalog refresh.
|
||||||
|
#
|
||||||
|
# Runs on the 1st of each month at 09:00 UTC and on manual dispatch.
|
||||||
|
#
|
||||||
|
# Does two things:
|
||||||
|
# 1. `--refresh`: pulls latest PAYG prices from Oracle's public API,
|
||||||
|
# updates kb/pricing/oci-sku-catalog.yaml, and opens a PR if anything
|
||||||
|
# changed.
|
||||||
|
# 2. `--discover`: reports SKUs present in the API but absent from the
|
||||||
|
# catalog (filtered to serviceCategory values already curated).
|
||||||
|
# If new SKUs are found, opens a Gitea issue listing them by category
|
||||||
|
# for manual review.
|
||||||
|
#
|
||||||
|
# Token:
|
||||||
|
# Uses the auto-provisioned secrets.GITHUB_TOKEN (Gitea Actions provides this
|
||||||
|
# per-run, scoped to this repo, GitHub-Actions-compatible). No manual secret
|
||||||
|
# configuration required. The explicit `permissions:` block below grants the
|
||||||
|
# scopes needed to push a branch, open a PR, and open an issue.
|
||||||
|
name: SKU Catalog Refresh
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 9 1 * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
refresh-and-discover:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y --no-install-recommends python3 python3-requests python3-yaml jq
|
||||||
|
python3 --version
|
||||||
|
|
||||||
|
- name: Refresh prices from Oracle API
|
||||||
|
id: refresh
|
||||||
|
run: |
|
||||||
|
python3 tools/refresh_sku_catalog.py --refresh --diff 2>&1 \
|
||||||
|
| tee /tmp/refresh.log
|
||||||
|
if git diff --quiet kb/pricing/oci-sku-catalog.yaml; then
|
||||||
|
echo "changed=false" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "changed=true" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Discover missing SKUs
|
||||||
|
id: discover
|
||||||
|
run: |
|
||||||
|
python3 tools/refresh_sku_catalog.py --discover -v 2>&1 \
|
||||||
|
| tee /tmp/discover.log
|
||||||
|
NEW_COUNT=$(sed -n 's/^DISCOVER_MISSING_COUNT=\([0-9]\+\)$/\1/p' \
|
||||||
|
/tmp/discover.log | tail -1)
|
||||||
|
echo "Parsed new_count='${NEW_COUNT}'"
|
||||||
|
echo "new_count=${NEW_COUNT:-0}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Upload logs as artifact
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: sku-refresh-logs
|
||||||
|
path: |
|
||||||
|
/tmp/refresh.log
|
||||||
|
/tmp/discover.log
|
||||||
|
retention-days: 90
|
||||||
|
|
||||||
|
- name: Open PR with price updates
|
||||||
|
if: steps.refresh.outputs.changed == 'true'
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
GITEA_SERVER: ${{ github.server_url }}
|
||||||
|
GITEA_REPO: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
DATE=$(date -u +%Y-%m-%d)
|
||||||
|
BRANCH="automation/sku-refresh-${DATE}-run${{ github.run_number }}"
|
||||||
|
|
||||||
|
git config user.name "sku-refresh-bot"
|
||||||
|
git config user.email "sku-refresh@automation.local"
|
||||||
|
git checkout -b "$BRANCH"
|
||||||
|
git add kb/pricing/oci-sku-catalog.yaml
|
||||||
|
git commit -m "chore(pricing): monthly SKU catalog refresh (${DATE})
|
||||||
|
|
||||||
|
Automated refresh from Oracle public pricing API.
|
||||||
|
See workflow artifact 'sku-refresh-logs' for the diff."
|
||||||
|
|
||||||
|
# Push using token auth
|
||||||
|
REMOTE=$(echo "$GITEA_SERVER/$GITEA_REPO.git" \
|
||||||
|
| sed "s#https://#https://sku-refresh-bot:${GITEA_TOKEN}@#")
|
||||||
|
git push "$REMOTE" "$BRANCH"
|
||||||
|
|
||||||
|
# Open PR via Gitea API
|
||||||
|
OWNER=$(echo "$GITEA_REPO" | cut -d/ -f1)
|
||||||
|
REPO=$(echo "$GITEA_REPO" | cut -d/ -f2)
|
||||||
|
curl -sS -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${GITEA_SERVER}/api/v1/repos/${OWNER}/${REPO}/pulls" \
|
||||||
|
-d "$(jq -n \
|
||||||
|
--arg title "chore(pricing): monthly SKU refresh ${DATE}" \
|
||||||
|
--arg head "$BRANCH" \
|
||||||
|
--arg base "main" \
|
||||||
|
--arg body "$(printf 'Automated refresh — [refresh log artifact](../../actions).\n\n```\n%s\n```' \
|
||||||
|
"$(tail -40 /tmp/refresh.log)")" \
|
||||||
|
'{title:$title, head:$head, base:$base, body:$body}')"
|
||||||
|
|
||||||
|
- name: Open issue for new SKUs
|
||||||
|
if: steps.discover.outputs.new_count != '0'
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
GITEA_SERVER: ${{ github.server_url }}
|
||||||
|
GITEA_REPO: ${{ github.repository }}
|
||||||
|
NEW_COUNT: ${{ steps.discover.outputs.new_count }}
|
||||||
|
run: |
|
||||||
|
DATE=$(date -u +%Y-%m-%d)
|
||||||
|
OWNER=$(echo "$GITEA_REPO" | cut -d/ -f1)
|
||||||
|
REPO=$(echo "$GITEA_REPO" | cut -d/ -f2)
|
||||||
|
|
||||||
|
BODY=$(printf '## %s new SKUs detected in Oracle API\n\n**Detected:** %s\n\nThese SKUs are present in the Oracle public pricing API but not yet in `kb/pricing/oci-sku-catalog.yaml`. Filter: serviceCategory values already represented in our catalog (so only relevant families show up).\n\n### Full listing\n\n```\n%s\n```\n\n### Next step\n\nReview and add relevant entries to `kb/pricing/oci-sku-catalog.yaml` under the appropriate category block. After merging, prices will be picked up automatically on the next monthly refresh.\n' \
|
||||||
|
"$NEW_COUNT" "$DATE" "$(cat /tmp/discover.log)")
|
||||||
|
|
||||||
|
curl -sS -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${GITEA_SERVER}/api/v1/repos/${OWNER}/${REPO}/issues" \
|
||||||
|
-d "$(jq -n \
|
||||||
|
--arg title "SKU catalog: ${NEW_COUNT} new SKUs to review (${DATE})" \
|
||||||
|
--arg body "$BODY" \
|
||||||
|
'{title:$title, body:$body, labels:["automation","sku-catalog"]}')"
|
||||||
|
|
||||||
|
- name: Summary
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
echo "## SKU Refresh Summary" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- Catalog changed: ${{ steps.refresh.outputs.changed }}" >> $GITHUB_STEP_SUMMARY
|
||||||
|
echo "- New SKUs discovered: ${{ steps.discover.outputs.new_count }}" >> $GITHUB_STEP_SUMMARY
|
||||||
5
Makefile
5
Makefile
@@ -1,6 +1,6 @@
|
|||||||
# OCI Deal Accelerator — Build Automation
|
# OCI Deal Accelerator — Build Automation
|
||||||
|
|
||||||
.PHONY: help install test validate example diagram deck full clean lint codex-package update-icons freshness freshness-refresh sync-skill
|
.PHONY: help install test validate example diagram deck full clean lint codex-package update-icons freshness freshness-refresh sync-skill sku-discover
|
||||||
|
|
||||||
# Use venv if present, otherwise find best available python3
|
# Use venv if present, otherwise find best available python3
|
||||||
ifneq (,$(wildcard .venv/bin/python))
|
ifneq (,$(wildcard .venv/bin/python))
|
||||||
@@ -97,3 +97,6 @@ kb-check: ## KB freshness JSON (used by skill welcome-flow pre-flight)
|
|||||||
|
|
||||||
freshness-refresh: ## Run refresh tools for stale KB files that support automation
|
freshness-refresh: ## Run refresh tools for stale KB files that support automation
|
||||||
@$(PYTHON) tools/kb_freshness.py --auto-refresh
|
@$(PYTHON) tools/kb_freshness.py --auto-refresh
|
||||||
|
|
||||||
|
sku-discover: ## Report SKUs present in Oracle API but missing from oci-sku-catalog.yaml
|
||||||
|
@$(PYTHON) tools/refresh_sku_catalog.py --discover
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
last_verified: '2026-03-27'
|
last_verified: '2026-04-24'
|
||||||
source: Oracle OCI Price List + internal BOM reference v1.73
|
source: Oracle OCI Price List + internal BOM reference v1.73
|
||||||
description: 'OCI SKU catalog for BOM generation. ~160 SKUs across 14+ categories. Prices are approximate and for estimation
|
description: 'OCI SKU catalog for BOM generation. ~160 SKUs across 14+ categories. Prices are approximate and for estimation
|
||||||
only. Always verify against https://www.oracle.com/cloud/pricing/
|
only. Always verify against https://www.oracle.com/cloud/pricing/
|
||||||
@@ -560,6 +560,18 @@ categories:
|
|||||||
list_price_usd: 0.0807
|
list_price_usd: 0.0807
|
||||||
default_hours_units: 730
|
default_hours_units: 730
|
||||||
billing_type: hourly
|
billing_type: hourly
|
||||||
|
- sku: B95714
|
||||||
|
product: Oracle Autonomous AI Transaction Processing - Dedicated - ECPU
|
||||||
|
metric: ECPU Per Hour
|
||||||
|
list_price_usd: 0.0807
|
||||||
|
default_hours_units: 730
|
||||||
|
billing_type: hourly
|
||||||
|
- sku: B95715
|
||||||
|
product: Oracle Autonomous AI Transaction Processing - Dedicated - ECPU - BYOL
|
||||||
|
metric: ECPU Per Hour
|
||||||
|
list_price_usd: 0.0807
|
||||||
|
default_hours_units: 730
|
||||||
|
billing_type: hourly
|
||||||
exascale:
|
exascale:
|
||||||
display_name: Oracle Cloud Infrastructure - Database Exadata Exascale Infrastructure
|
display_name: Oracle Cloud Infrastructure - Database Exadata Exascale Infrastructure
|
||||||
skus:
|
skus:
|
||||||
@@ -1289,12 +1301,6 @@ categories:
|
|||||||
list_price_usd: 0.015
|
list_price_usd: 0.015
|
||||||
default_hours_units: 730
|
default_hours_units: 730
|
||||||
billing_type: hourly
|
billing_type: hourly
|
||||||
# OCI Data Science and Data Flow do not have dedicated SKUs in the Oracle
|
|
||||||
# public pricing API — they are billed via the underlying Compute + Block/Object
|
|
||||||
# Storage consumed by notebook sessions, model-deployment endpoints, and Spark
|
|
||||||
# runs. The entries below are placeholder estimates (VM.Standard3.Flex OCPU rate)
|
|
||||||
# so BOMs can include them as line items; always confirm against the customer's
|
|
||||||
# actual shape + usage before quoting.
|
|
||||||
- sku: EST-DS-NOTEBOOK
|
- sku: EST-DS-NOTEBOOK
|
||||||
product: '[ESTIMATE] OCI Data Science - Notebook Session (billed via underlying VM.Standard compute OCPU)'
|
product: '[ESTIMATE] OCI Data Science - Notebook Session (billed via underlying VM.Standard compute OCPU)'
|
||||||
metric: OCPU Per Hour
|
metric: OCPU Per Hour
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
---
|
---
|
||||||
last_verified: 2026-03-14
|
last_verified: 2026-04-24
|
||||||
service: OCI Compute
|
service: OCI Compute
|
||||||
category: compute
|
category: compute
|
||||||
oci_color: "#4F7B6E"
|
oci_color: "#4F7B6E"
|
||||||
|
changelog:
|
||||||
|
- date: "2026-04-24"
|
||||||
|
change: "Added missing shapes surfaced by discover+WebFetch: VM/BM.Standard.E6.Ax, VM/BM.Standard.A4.Ax, and full X12 family (VM.Standard4.Ax.Flex 39 OCPU / 360 GB, BM.Standard4.Ax.120 latest-gen bare metal). Verified against docs.oracle.com/en-us/iaas/Content/Compute/References/computeshapes.htm."
|
||||||
---
|
---
|
||||||
|
|
||||||
what: Virtual machines and bare metal instances. Flexible shapes allow custom
|
what: Virtual machines and bare metal instances. Flexible shapes allow custom
|
||||||
@@ -42,6 +45,24 @@ shape_families:
|
|||||||
max_ocpu: 18
|
max_ocpu: 18
|
||||||
memory_per_ocpu_gb: "1-64 GB per OCPU"
|
memory_per_ocpu_gb: "1-64 GB per OCPU"
|
||||||
use_for: "High-frequency trading, HPC"
|
use_for: "High-frequency trading, HPC"
|
||||||
|
- name: VM.Standard.E6.Ax.Flex
|
||||||
|
processor: "AMD EPYC (Ampere-branded E6 variant)"
|
||||||
|
max_ocpu: 94
|
||||||
|
max_memory_gb: 712
|
||||||
|
network_gbps: 99
|
||||||
|
use_for: "E6 Ampere-variant general purpose"
|
||||||
|
- name: VM.Standard.A4.Ax.Flex
|
||||||
|
processor: "Arm (A4 Ampere-branded variant)"
|
||||||
|
max_ocpu: 45
|
||||||
|
max_memory_gb: 720
|
||||||
|
network_gbps: 100
|
||||||
|
use_for: "A4 Ampere-variant Arm workloads"
|
||||||
|
- name: VM.Standard4.Ax.Flex
|
||||||
|
processor: "AMD EPYC (X12 Ampere-branded)"
|
||||||
|
max_ocpu: 39
|
||||||
|
max_memory_gb: 360
|
||||||
|
network_gbps: 99
|
||||||
|
use_for: "X12-generation general purpose (latest)"
|
||||||
- name: BM.Standard.E5
|
- name: BM.Standard.E5
|
||||||
processor: "AMD EPYC 9J14 (Genoa)"
|
processor: "AMD EPYC 9J14 (Genoa)"
|
||||||
ocpus: 192
|
ocpus: 192
|
||||||
@@ -50,6 +71,30 @@ shape_families:
|
|||||||
processor: "AMD EPYC 9J45 (Turin)"
|
processor: "AMD EPYC 9J45 (Turin)"
|
||||||
ocpus: 256
|
ocpus: 256
|
||||||
use_for: "Bare metal latest generation"
|
use_for: "Bare metal latest generation"
|
||||||
|
- name: BM.Standard.E6.Ax.192
|
||||||
|
processor: "AMD EPYC (E6 Ampere-branded variant)"
|
||||||
|
ocpus: 192
|
||||||
|
max_memory_gb: 1536
|
||||||
|
network_gbps: 200
|
||||||
|
use_for: "Bare metal E6 Ampere-variant"
|
||||||
|
- name: BM.Standard.A4.48
|
||||||
|
processor: "Arm (A4)"
|
||||||
|
ocpus: 48
|
||||||
|
max_memory_gb: 768
|
||||||
|
network_gbps: 100
|
||||||
|
use_for: "Bare metal A4 Arm general purpose"
|
||||||
|
- name: BM.Standard.A4.Ax.48
|
||||||
|
processor: "Arm (A4 Ampere-branded variant)"
|
||||||
|
ocpus: 48
|
||||||
|
max_memory_gb: 768
|
||||||
|
network_gbps: 100
|
||||||
|
use_for: "Bare metal A4 Ampere-variant Arm"
|
||||||
|
- name: BM.Standard4.Ax.120
|
||||||
|
processor: "AMD EPYC (X12 Ampere-branded)"
|
||||||
|
ocpus: 120
|
||||||
|
max_memory_gb: 1152
|
||||||
|
network_gbps: 200
|
||||||
|
use_for: "Bare metal X12 latest generation"
|
||||||
- name: VM.GPU.A10.1
|
- name: VM.GPU.A10.1
|
||||||
processor: "NVIDIA A10"
|
processor: "NVIDIA A10"
|
||||||
gpu_count: 1
|
gpu_count: 1
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# OCI Service: Exadata Cloud Service (ExaCS)
|
# OCI Service: Exadata Cloud Service (ExaCS)
|
||||||
# Last verified: 2026-03
|
# Last verified: 2026-04
|
||||||
|
|
||||||
service:
|
service:
|
||||||
name: "Exadata Cloud Service (ExaCS)"
|
name: "Exadata Cloud Service (ExaCS)"
|
||||||
@@ -35,37 +35,102 @@ variants:
|
|||||||
usable_tb: 63.6
|
usable_tb: 63.6
|
||||||
- id: exacs_x11m
|
- id: exacs_x11m
|
||||||
name: "ExaCS X11M"
|
name: "ExaCS X11M"
|
||||||
description: "Exadata X11M shape — latest generation with AMD EPYC 9J25 processors and XRMEM acceleration."
|
description: "Exadata X11M shape — latest generation with AMD EPYC processors and XRMEM acceleration. Two storage server variants: HC (default, capacity-optimised) and EF (Extreme Flash, latency-optimised)."
|
||||||
per_db_server:
|
per_db_server:
|
||||||
ecpus: 760
|
ecpus: 760
|
||||||
memory_gb: 1390
|
memory_gb: 1390
|
||||||
xrmem_gb: 1250
|
xrmem_gb: 1250
|
||||||
flash_cache_tb: 27.2
|
flash_cache_tb: 27.2
|
||||||
per_storage_server:
|
cpu: "2 × AMD EPYC 9J25 (96 cores each, 192 cores per server)"
|
||||||
usable_tb: 52.5
|
storage_server_variants:
|
||||||
|
hc:
|
||||||
|
name: "X11M High Capacity (HC)"
|
||||||
|
description: "Default storage variant. 12 × 22 TB SAS HDDs + 27.2 TB flash cache + 1.25 TB XRMEM per SS. Best $/TB; same hot-data latency as EF (served from flash cache + XRMEM)."
|
||||||
|
raw_tb_per_ss: 264
|
||||||
|
flash_cache_tb_per_ss: 27.2
|
||||||
|
xrmem_tb_per_ss: 1.25
|
||||||
|
usable_disk_tb_per_ss: 80 # Datasheet "Total Usable Disk Capacity" per SS — post HIGH redundancy + drive-failure reserve, before compression
|
||||||
|
max_db_size_tb_per_ss_no_local_backup: 64 # Derived: 192 TB / 3 SS at base config (datasheet Elastic Example 1)
|
||||||
|
max_db_size_tb_per_ss_with_local_backup: 32 # Derived: 96 TB / 3 SS at base config
|
||||||
|
total_cores_per_ss: 64
|
||||||
|
recommended_for: "OLTP, mixed workloads, DWH where active set fits in flash cache (~80% of cases)"
|
||||||
|
ef:
|
||||||
|
name: "X11M Extreme Flash (EF)"
|
||||||
|
description: "All-flash storage variant. Lower latency on cache-miss reads, but higher $/TB. Use when the working set exceeds flash cache or for latency-sensitive DWH full scans."
|
||||||
|
usable_tb_per_ss: 52.5
|
||||||
type: "Extreme Flash (EF)"
|
type: "Extreme Flash (EF)"
|
||||||
|
recommended_for: "Latency-sensitive DWH with large full-table scans that miss flash cache; sub-ms consistent latency requirements"
|
||||||
|
pricing_2026_04:
|
||||||
|
source: "OCI Cost Estimator 2026-04-23"
|
||||||
|
db_server_per_hour_usd: 2.9032
|
||||||
|
storage_server_per_hour_usd: 2.9032
|
||||||
|
base_config_monthly_usd: 10800 # (2 DB + 3 SS) × $2.9032 × 744 hr; excludes DB licenses (BYOL) and ECPU charges
|
||||||
|
|
||||||
sizing:
|
sizing:
|
||||||
model: "Elastic per-server (2-32 DB servers, 3-64 storage servers)"
|
model: "Elastic per-server (X9M+: 2-32 DB servers, 3-64 storage servers). 'Quarter/half/full rack' terminology is legacy; X9M and X11M are billed per-server."
|
||||||
rack_configurations:
|
notes: |
|
||||||
quarter_rack:
|
Three different storage figures appear in Oracle datasheets — they measure different things:
|
||||||
ocpus: "252 (X9M: 2 × 126/server)"
|
• Total Usable Disk Capacity = ASM-allocatable space, post HIGH redundancy + drive-failure reserve, before compression
|
||||||
memory: "2.71 TB (X9M: 2 × 1,390 GB)"
|
• Max DB Size – No Local Backup = practical limit for DB allocation after RECO/SPARSE/DBFS reserves (this is what OCI Cost Estimator caps at)
|
||||||
storage: "Up to 190.8 TB usable (X9M: 3 SS × 63.6 TB)"
|
• Max DB Size – With Local Backup = ~half of above (FRA on disk for backups)
|
||||||
db_nodes: 2
|
Always quote both Usable Disk and Max DB Size to avoid customer confusion.
|
||||||
|
OCI scales storage by adding individual storage servers, not in racks.
|
||||||
|
|
||||||
|
base_configuration:
|
||||||
|
description: "Minimum elastic configuration — what you get on day-1 before scaling."
|
||||||
|
db_servers: 2
|
||||||
storage_servers: 3
|
storage_servers: 3
|
||||||
half_rack:
|
|
||||||
ocpus: "504 (X9M: 4 × 126/server)"
|
x11m_elastic_base:
|
||||||
memory: "5.43 TB"
|
description: "Datasheet 'Elastic Configuration Example 1' (2 DB + 3 SS, all-elastic SKUs)."
|
||||||
storage: "Up to 383 TB usable"
|
source: "ADB-D X11M Datasheet v1.0, Jan 2025, Table 1"
|
||||||
db_nodes: 4
|
db_servers: 2
|
||||||
storage_servers: 6
|
storage_servers: 3
|
||||||
full_rack:
|
total_ecpus: 1520 # 2 × 760
|
||||||
ocpus: "1,008 (X9M: 8 × 126/server)"
|
total_memory_gb: 2780 # 2 × 1,390
|
||||||
memory: "10.86 TB"
|
total_xrmem_tb: 3.75 # 3 × 1.25 (XRMEM lives on storage servers)
|
||||||
storage: "Up to 766 TB usable"
|
total_flash_tb: 81.6 # 3 × 27.2
|
||||||
db_nodes: 8
|
hc_variant:
|
||||||
storage_servers: 12
|
total_usable_disk_tb: 240 # Datasheet authoritative
|
||||||
|
max_db_size_no_local_backup_tb: 192 # OCI Cost Estimator caps allocation here
|
||||||
|
max_db_size_with_local_backup_tb: 96
|
||||||
|
max_sql_flash_bandwidth_gbps: 300
|
||||||
|
max_sql_xrmem_bandwidth_gbps: 1500
|
||||||
|
max_sql_disk_bandwidth_gbps: 5.4
|
||||||
|
max_sql_read_iops: 5_600_000
|
||||||
|
max_sql_write_iops: 3_000_000
|
||||||
|
max_sql_disk_iops: 7_800
|
||||||
|
max_data_load_rate_tb_per_hr: 7.5
|
||||||
|
ef_variant:
|
||||||
|
usable_tb: 157.5 # 3 × 52.5
|
||||||
|
sample_elastic_growth_examples:
|
||||||
|
compute_heavy: { db_servers: 8, storage_servers: 8, total_ecpus: 6080, max_db_size_tb: 512 }
|
||||||
|
storage_heavy: { db_servers: 2, storage_servers: 14, total_ecpus: 1520, max_db_size_tb: 896 }
|
||||||
|
|
||||||
|
x11m_base_system:
|
||||||
|
description: "Hardware-generation-agnostic 'Base System' SKU — fixed-spec, NOT expandable, single VM Cluster only. Different product from elastic; entry-price option for small footprints."
|
||||||
|
source: "ADB-D X11M Datasheet v1.0, Jan 2025, Table 1, footnote 1"
|
||||||
|
db_servers: 2
|
||||||
|
storage_servers: 3
|
||||||
|
total_ecpus: 192
|
||||||
|
total_memory_gb: 720
|
||||||
|
total_flash_tb: 38.4
|
||||||
|
total_usable_disk_tb: 73
|
||||||
|
max_db_size_no_local_backup_tb: 58
|
||||||
|
max_db_size_with_local_backup_tb: 29
|
||||||
|
network: "10 GbE (vs 50 GbE on elastic)"
|
||||||
|
notes: "Base System is NOT the same as 'Elastic Example 1 with 2 DB + 3 SS'. The elastic config has ~8× more ECPUs, ~4× more memory, ~3× more storage at same physical-server count, and 50 GbE networking. Base System is for cost-optimised entry; if customer expects to scale, go elastic from day 1."
|
||||||
|
|
||||||
|
x9m_base:
|
||||||
|
cpu_ocpus: 252 # 2 × 126
|
||||||
|
memory_tb: 2.71 # 2 × 1,390 GB
|
||||||
|
pmem_tb: 3.0 # 2 × 1.5 (persistent memory tier)
|
||||||
|
flash_cache_tb_db: 51.2 # 2 × 25.6
|
||||||
|
hc_variant:
|
||||||
|
usable_tb_per_ss: 63.6
|
||||||
|
base_usable_tb_high_redundancy: 190.8 # 3 × 63.6
|
||||||
|
notes: "X9M HC drives are 14 TB (vs 22 TB on X11M)."
|
||||||
|
|
||||||
legacy_x8_fixed_shapes:
|
legacy_x8_fixed_shapes:
|
||||||
quarter_rack:
|
quarter_rack:
|
||||||
storage_tb: 149
|
storage_tb: 149
|
||||||
@@ -74,8 +139,9 @@ sizing:
|
|||||||
full_rack:
|
full_rack:
|
||||||
storage_tb: 598
|
storage_tb: 598
|
||||||
notes: "X8 shapes are fixed-size (not elastic). Storage values are usable disk, not flash cache."
|
notes: "X8 shapes are fixed-size (not elastic). Storage values are usable disk, not flash cache."
|
||||||
ocpu_scaling: "OCPUs can be scaled online in increments without downtime. You pay for enabled OCPUs, not the total available in the rack."
|
|
||||||
sizing_guidance: "Start with a quarter rack for most production workloads. Scale OCPUs on demand. Choose half/full rack only when storage capacity or aggregate memory requirements exceed quarter rack limits."
|
ocpu_scaling: "OCPUs/ECPUs can be scaled online in increments without downtime. You pay for enabled cores, not the total available in the rack."
|
||||||
|
sizing_guidance: "Start with the elastic base (2 DB + 3 SS) for most production workloads. Add storage servers when capacity or IOPS exceed base. Add DB servers when CPU saturates. Choose HC over EF unless workload is latency-sensitive on cache-miss reads."
|
||||||
|
|
||||||
gotchas:
|
gotchas:
|
||||||
- id: exacs_minimum_cost
|
- id: exacs_minimum_cost
|
||||||
@@ -102,6 +168,10 @@ gotchas:
|
|||||||
severity: LOW
|
severity: LOW
|
||||||
description: "ExaCS requires placement in a private subnet with sufficient IP addresses. Each DB node and each virtual IP consumes an IP; plan your subnet CIDR accordingly."
|
description: "ExaCS requires placement in a private subnet with sufficient IP addresses. Each DB node and each virtual IP consumes an IP; plan your subnet CIDR accordingly."
|
||||||
|
|
||||||
|
- id: exacs_x11m_hc_vs_ef
|
||||||
|
severity: MEDIUM
|
||||||
|
description: "X11M ships in two storage variants — HC (default, 264 TB raw / SS) and EF (all-flash, 52.5 TB usable / SS). Most customers should choose HC: same hot-data latency (served from 27.2 TB flash cache per SS) at far better $/TB. Only pick EF when the working set genuinely exceeds flash cache and you need sub-ms consistent latency on cache-miss reads. Many quotes default to EF without justification — confirm with the customer."
|
||||||
|
|
||||||
implied_dependencies:
|
implied_dependencies:
|
||||||
- service: vcn
|
- service: vcn
|
||||||
reason: "ExaCS infrastructure must be deployed in a VCN"
|
reason: "ExaCS infrastructure must be deployed in a VCN"
|
||||||
@@ -123,3 +193,9 @@ changelog:
|
|||||||
- date: "2026-03-14"
|
- date: "2026-03-14"
|
||||||
contributor: { name: "Diego Cabrera", team: "Field Architecture" }
|
contributor: { name: "Diego Cabrera", team: "Field Architecture" }
|
||||||
change: "Initial creation with shapes, sizing, gotchas, dependencies"
|
change: "Initial creation with shapes, sizing, gotchas, dependencies"
|
||||||
|
- date: "2026-04-23"
|
||||||
|
contributor: { name: "Diego Velásquez", team: "Field Architecture" }
|
||||||
|
change: "Added X11M HC variant (default) alongside existing EF; replaced misleading rack_configurations table that mixed X9M data with elastic per-server model; added X11M and X9M base configs; added pricing verified against OCI Cost Estimator (DB+SS @ $2.9032/hr); added gotcha on HC vs EF default choice."
|
||||||
|
- date: "2026-04-23"
|
||||||
|
contributor: { name: "Diego Velásquez", team: "Field Architecture" }
|
||||||
|
change: "Replaced earlier estimator-derived storage figures (63 TB/SS, 190 TB at base) with authoritative numbers from ADB-D X11M Datasheet v1.0 Jan 2025 Table 1. X11M HC SS = 80 TB Total Usable Disk (HIGH redundancy + drive-failure reserve). 3-SS Elastic base = 240 TB Total Usable Disk OR 192 TB Max DB Size (no local backup, what the Estimator caps at) OR 96 TB Max DB Size (with local backup). Also added separate Base System SKU (fixed, non-expandable, 73 TB usable disk) — distinct product from elastic 2 DB + 3 SS. Added performance metrics (IOPS, GB/s) for elastic base."
|
||||||
|
|||||||
@@ -48,3 +48,4 @@ bom:
|
|||||||
notes:
|
notes:
|
||||||
- "Pricing based on Ashburn region (OC1)"
|
- "Pricing based on Ashburn region (OC1)"
|
||||||
- "Discounts subject to Oracle approval"
|
- "Discounts subject to Oracle approval"
|
||||||
|
- "Hours convention — 730 vs 744: this BOM uses 730 hrs/month (= 8,760 annual hrs / 12), which equals the actual yearly OCI billing for hourly SKUs. OCI Cost Estimator defaults to 744 hrs/month (worst-case 31-day month); extrapolated to 12 months, 744 overstates the real annual cost by 1.92%. For 12-month TCO/proposals, 730 is the accurate figure; 744 is only appropriate when quoting a single peak month as an upper bound."
|
||||||
|
|||||||
@@ -204,6 +204,17 @@ class OCIBomGenerator:
|
|||||||
def _col_count(self) -> int:
|
def _col_count(self) -> int:
|
||||||
return len(self._col_defs())
|
return len(self._col_defs())
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _monthly_values(item: dict) -> tuple[float, float]:
|
||||||
|
"""Return monthly values with and without discount for one BOM line."""
|
||||||
|
price = float(item.get("list_price_usd", 0) or 0)
|
||||||
|
hours_units = float(item.get("hours_units", 0) or 0)
|
||||||
|
qty = float(item.get("qty", 0) or 0)
|
||||||
|
discount = float(item.get("discount", 0) or 0)
|
||||||
|
monthly_wo = price * hours_units * qty
|
||||||
|
monthly_w = monthly_wo * (1 - discount)
|
||||||
|
return monthly_wo, monthly_w
|
||||||
|
|
||||||
def _build_sheet(self, ws):
|
def _build_sheet(self, ws):
|
||||||
"""Build the complete BOM sheet."""
|
"""Build the complete BOM sheet."""
|
||||||
ws.title = "BOM"
|
ws.title = "BOM"
|
||||||
@@ -317,6 +328,7 @@ class OCIBomGenerator:
|
|||||||
|
|
||||||
data_start_row = row
|
data_start_row = row
|
||||||
cat_subtotal_rows = []
|
cat_subtotal_rows = []
|
||||||
|
data_item_rows = [] # line-item rows (excludes category headers/subtotals/totals)
|
||||||
|
|
||||||
# Ensure categories present in items but missing from catalog order
|
# Ensure categories present in items but missing from catalog order
|
||||||
# (e.g., "other" from unknown SKUs) still render at the end.
|
# (e.g., "other" from unknown SKUs) still render at the end.
|
||||||
@@ -325,6 +337,11 @@ class OCIBomGenerator:
|
|||||||
if cat_key not in render_order:
|
if cat_key not in render_order:
|
||||||
render_order.append(cat_key)
|
render_order.append(cat_key)
|
||||||
|
|
||||||
|
grand_total_wo = 0.0
|
||||||
|
grand_total_w = 0.0
|
||||||
|
grand_total_conv_wo = 0.0
|
||||||
|
grand_total_conv_w = 0.0
|
||||||
|
|
||||||
for cat_key in render_order:
|
for cat_key in render_order:
|
||||||
if cat_key not in items_by_cat:
|
if cat_key not in items_by_cat:
|
||||||
continue
|
continue
|
||||||
@@ -344,6 +361,10 @@ class OCIBomGenerator:
|
|||||||
row += 1
|
row += 1
|
||||||
|
|
||||||
cat_first_data = row
|
cat_first_data = row
|
||||||
|
cat_total_wo = 0.0
|
||||||
|
cat_total_w = 0.0
|
||||||
|
cat_total_conv_wo = 0.0
|
||||||
|
cat_total_conv_w = 0.0
|
||||||
|
|
||||||
for idx, item in enumerate(cat_items):
|
for idx, item in enumerate(cat_items):
|
||||||
# Static data
|
# Static data
|
||||||
@@ -356,38 +377,22 @@ class OCIBomGenerator:
|
|||||||
ws.cell(row=row, column=COL_MONTHS, value=item["months"])
|
ws.cell(row=row, column=COL_MONTHS, value=item["months"])
|
||||||
ws.cell(row=row, column=COL_DISC, value=item["discount"])
|
ws.cell(row=row, column=COL_DISC, value=item["discount"])
|
||||||
|
|
||||||
# Formula: Monthly w/o discount = Price × Hours × Qty
|
monthly_wo, monthly_w = self._monthly_values(item)
|
||||||
p = get_column_letter(COL_PRICE)
|
ws.cell(row=row, column=COL_M_WO, value=monthly_wo)
|
||||||
h = get_column_letter(COL_HOURS)
|
ws.cell(row=row, column=COL_M_W, value=monthly_w)
|
||||||
q = get_column_letter(COL_QTY)
|
cat_total_wo += monthly_wo
|
||||||
d = get_column_letter(COL_DISC)
|
cat_total_w += monthly_w
|
||||||
ws.cell(
|
|
||||||
row=row, column=COL_M_WO,
|
|
||||||
value=f"={p}{row}*{h}{row}*{q}{row}",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Formula: Monthly w/ discount = Monthly_wo × (1 - Discount)
|
|
||||||
mwo = get_column_letter(COL_M_WO)
|
|
||||||
ws.cell(
|
|
||||||
row=row, column=COL_M_W,
|
|
||||||
value=f"={mwo}{row}*(1-{d}{row})",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Conversion formulas
|
# Conversion formulas
|
||||||
if conv:
|
if conv:
|
||||||
xr = self.conversion.get("exchange_rate", 1)
|
xr = self.conversion.get("exchange_rate", 1)
|
||||||
tr = self.conversion.get("tax_rate", 0)
|
tr = self.conversion.get("tax_rate", 0)
|
||||||
mw = get_column_letter(COL_M_W)
|
conv_wo = monthly_w * xr
|
||||||
# Converted w/o tax
|
conv_w = conv_wo * (1 + tr)
|
||||||
ws.cell(
|
ws.cell(row=row, column=COL_CONV_WO, value=conv_wo)
|
||||||
row=row, column=COL_CONV_WO,
|
ws.cell(row=row, column=COL_CONV_W, value=conv_w)
|
||||||
value=f"={mw}{row}*{xr}",
|
cat_total_conv_wo += conv_wo
|
||||||
)
|
cat_total_conv_w += conv_w
|
||||||
# Converted w/ tax
|
|
||||||
ws.cell(
|
|
||||||
row=row, column=COL_CONV_W,
|
|
||||||
value=f"={get_column_letter(COL_CONV_WO)}{row}*(1+{tr})",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Styling per row
|
# Styling per row
|
||||||
is_alt = idx % 2 == 1
|
is_alt = idx % 2 == 1
|
||||||
@@ -414,25 +419,26 @@ class OCIBomGenerator:
|
|||||||
ws.cell(row=row, column=COL_CONV_WO).number_format = '#,##0.00'
|
ws.cell(row=row, column=COL_CONV_WO).number_format = '#,##0.00'
|
||||||
ws.cell(row=row, column=COL_CONV_W).number_format = '#,##0.00'
|
ws.cell(row=row, column=COL_CONV_W).number_format = '#,##0.00'
|
||||||
|
|
||||||
|
data_item_rows.append(row)
|
||||||
row += 1
|
row += 1
|
||||||
|
|
||||||
# Category subtotal row
|
# Category subtotal row
|
||||||
cat_last_data = row - 1
|
cat_last_data = row - 1
|
||||||
ws.cell(row=row, column=COL_PRODUCT, value=f"Subtotal — {display_name}")
|
ws.cell(row=row, column=COL_PRODUCT, value=f"Subtotal — {display_name}")
|
||||||
mwo_letter = get_column_letter(COL_M_WO)
|
ws.cell(row=row, column=COL_M_WO, value=cat_total_wo)
|
||||||
mw_letter = get_column_letter(COL_M_W)
|
ws.cell(row=row, column=COL_M_W, value=cat_total_w)
|
||||||
ws.cell(row=row, column=COL_M_WO, value=f"=SUM({mwo_letter}{cat_first_data}:{mwo_letter}{cat_last_data})")
|
|
||||||
ws.cell(row=row, column=COL_M_W, value=f"=SUM({mw_letter}{cat_first_data}:{mw_letter}{cat_last_data})")
|
|
||||||
ws.cell(row=row, column=COL_M_WO).number_format = '$#,##0.00'
|
ws.cell(row=row, column=COL_M_WO).number_format = '$#,##0.00'
|
||||||
ws.cell(row=row, column=COL_M_W).number_format = '$#,##0.00'
|
ws.cell(row=row, column=COL_M_W).number_format = '$#,##0.00'
|
||||||
|
grand_total_wo += cat_total_wo
|
||||||
|
grand_total_w += cat_total_w
|
||||||
|
|
||||||
if conv:
|
if conv:
|
||||||
cwo_letter = get_column_letter(COL_CONV_WO)
|
ws.cell(row=row, column=COL_CONV_WO, value=cat_total_conv_wo)
|
||||||
cw_letter = get_column_letter(COL_CONV_W)
|
ws.cell(row=row, column=COL_CONV_W, value=cat_total_conv_w)
|
||||||
ws.cell(row=row, column=COL_CONV_WO, value=f"=SUM({cwo_letter}{cat_first_data}:{cwo_letter}{cat_last_data})")
|
|
||||||
ws.cell(row=row, column=COL_CONV_W, value=f"=SUM({cw_letter}{cat_first_data}:{cw_letter}{cat_last_data})")
|
|
||||||
ws.cell(row=row, column=COL_CONV_WO).number_format = '#,##0.00'
|
ws.cell(row=row, column=COL_CONV_WO).number_format = '#,##0.00'
|
||||||
ws.cell(row=row, column=COL_CONV_W).number_format = '#,##0.00'
|
ws.cell(row=row, column=COL_CONV_W).number_format = '#,##0.00'
|
||||||
|
grand_total_conv_wo += cat_total_conv_wo
|
||||||
|
grand_total_conv_w += cat_total_conv_w
|
||||||
|
|
||||||
for ci in range(1, num_cols + 1):
|
for ci in range(1, num_cols + 1):
|
||||||
cell = ws.cell(row=row, column=ci)
|
cell = ws.cell(row=row, column=ci)
|
||||||
@@ -448,19 +454,14 @@ class OCIBomGenerator:
|
|||||||
total_row = row
|
total_row = row
|
||||||
ws.cell(row=row, column=COL_PRODUCT, value="TOTAL").font = total_font
|
ws.cell(row=row, column=COL_PRODUCT, value="TOTAL").font = total_font
|
||||||
|
|
||||||
# Sum of subtotals
|
ws.cell(row=row, column=COL_M_WO, value=grand_total_wo)
|
||||||
mwo_refs = "+".join(f"{get_column_letter(COL_M_WO)}{r}" for r in cat_subtotal_rows)
|
ws.cell(row=row, column=COL_M_W, value=grand_total_w)
|
||||||
mw_refs = "+".join(f"{get_column_letter(COL_M_W)}{r}" for r in cat_subtotal_rows)
|
|
||||||
ws.cell(row=row, column=COL_M_WO, value=f"={mwo_refs}")
|
|
||||||
ws.cell(row=row, column=COL_M_W, value=f"={mw_refs}")
|
|
||||||
ws.cell(row=row, column=COL_M_WO).number_format = '$#,##0.00'
|
ws.cell(row=row, column=COL_M_WO).number_format = '$#,##0.00'
|
||||||
ws.cell(row=row, column=COL_M_W).number_format = '$#,##0.00'
|
ws.cell(row=row, column=COL_M_W).number_format = '$#,##0.00'
|
||||||
|
|
||||||
if conv:
|
if conv:
|
||||||
cwo_refs = "+".join(f"{get_column_letter(COL_CONV_WO)}{r}" for r in cat_subtotal_rows)
|
ws.cell(row=row, column=COL_CONV_WO, value=grand_total_conv_wo)
|
||||||
cw_refs = "+".join(f"{get_column_letter(COL_CONV_W)}{r}" for r in cat_subtotal_rows)
|
ws.cell(row=row, column=COL_CONV_W, value=grand_total_conv_w)
|
||||||
ws.cell(row=row, column=COL_CONV_WO, value=f"={cwo_refs}")
|
|
||||||
ws.cell(row=row, column=COL_CONV_W, value=f"={cw_refs}")
|
|
||||||
ws.cell(row=row, column=COL_CONV_WO).number_format = '#,##0.00'
|
ws.cell(row=row, column=COL_CONV_WO).number_format = '#,##0.00'
|
||||||
ws.cell(row=row, column=COL_CONV_W).number_format = '#,##0.00'
|
ws.cell(row=row, column=COL_CONV_W).number_format = '#,##0.00'
|
||||||
|
|
||||||
@@ -476,15 +477,13 @@ class OCIBomGenerator:
|
|||||||
ws.cell(row=row, column=COL_PRODUCT, value="Annual Run Rate (ARR)").font = Font(
|
ws.cell(row=row, column=COL_PRODUCT, value="Annual Run Rate (ARR)").font = Font(
|
||||||
name="Segoe UI", size=11, bold=True, color=Colors.COPPER
|
name="Segoe UI", size=11, bold=True, color=Colors.COPPER
|
||||||
)
|
)
|
||||||
mw_total = get_column_letter(COL_M_W)
|
ws.cell(row=row, column=COL_M_W, value=grand_total_w * 12)
|
||||||
ws.cell(row=row, column=COL_M_W, value=f"={mw_total}{total_row}*12")
|
|
||||||
ws.cell(row=row, column=COL_M_W).number_format = '$#,##0.00'
|
ws.cell(row=row, column=COL_M_W).number_format = '$#,##0.00'
|
||||||
ws.cell(row=row, column=COL_M_W).font = Font(
|
ws.cell(row=row, column=COL_M_W).font = Font(
|
||||||
name="Segoe UI", size=11, bold=True, color=Colors.COPPER
|
name="Segoe UI", size=11, bold=True, color=Colors.COPPER
|
||||||
)
|
)
|
||||||
if conv:
|
if conv:
|
||||||
cw_total = get_column_letter(COL_CONV_W)
|
ws.cell(row=row, column=COL_CONV_W, value=grand_total_conv_w * 12)
|
||||||
ws.cell(row=row, column=COL_CONV_W, value=f"={cw_total}{total_row}*12")
|
|
||||||
ws.cell(row=row, column=COL_CONV_W).number_format = '#,##0.00'
|
ws.cell(row=row, column=COL_CONV_W).number_format = '#,##0.00'
|
||||||
ws.cell(row=row, column=COL_CONV_W).font = Font(
|
ws.cell(row=row, column=COL_CONV_W).font = Font(
|
||||||
name="Segoe UI", size=11, bold=True, color=Colors.COPPER
|
name="Segoe UI", size=11, bold=True, color=Colors.COPPER
|
||||||
@@ -495,10 +494,8 @@ class OCIBomGenerator:
|
|||||||
|
|
||||||
# ── Cost proportion formulas (back-fill) ─────────────────
|
# ── Cost proportion formulas (back-fill) ─────────────────
|
||||||
# Cost % = line monthly w/ discount / total monthly w/ discount
|
# Cost % = line monthly w/ discount / total monthly w/ discount
|
||||||
for item_row in range(data_start_row, total_row):
|
|
||||||
cell_val = ws.cell(row=item_row, column=COL_M_W).value
|
|
||||||
if cell_val and str(cell_val).startswith("=") and "SUM" not in str(cell_val):
|
|
||||||
mw_l = get_column_letter(COL_M_W)
|
mw_l = get_column_letter(COL_M_W)
|
||||||
|
for item_row in data_item_rows:
|
||||||
ws.cell(
|
ws.cell(
|
||||||
row=item_row, column=COL_PCT,
|
row=item_row, column=COL_PCT,
|
||||||
value=f"=IF({mw_l}{total_row}=0,0,{mw_l}{item_row}/{mw_l}{total_row})",
|
value=f"=IF({mw_l}{total_row}=0,0,{mw_l}{item_row}/{mw_l}{total_row})",
|
||||||
@@ -630,11 +627,9 @@ class OCIBomGenerator:
|
|||||||
ws_bom.cell(row=row, column=9, value=item["months"])
|
ws_bom.cell(row=row, column=9, value=item["months"])
|
||||||
ws_bom.cell(row=row, column=10, value=item["discount"])
|
ws_bom.cell(row=row, column=10, value=item["discount"])
|
||||||
|
|
||||||
# Formulas matching BOM.C1 style
|
monthly_wo, monthly_w = self._monthly_values(item)
|
||||||
ws_bom.cell(row=row, column=11,
|
ws_bom.cell(row=row, column=11, value=monthly_wo)
|
||||||
value="=(F{r}*G{r}*H{r})".format(r=row))
|
ws_bom.cell(row=row, column=12, value=monthly_w)
|
||||||
ws_bom.cell(row=row, column=12,
|
|
||||||
value="=IFERROR(K{r}*(1-J{r}),K{r})".format(r=row))
|
|
||||||
|
|
||||||
# Formats
|
# Formats
|
||||||
ws_bom.cell(row=row, column=6).number_format = '#,##0.0000'
|
ws_bom.cell(row=row, column=6).number_format = '#,##0.0000'
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ Or import and use programmatically:
|
|||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
import argparse
|
import argparse
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@@ -118,6 +119,21 @@ def _default_architecture_principles() -> dict:
|
|||||||
return result if any(result.values()) else {}
|
return result if any(result.values()) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _principles_kb_index() -> dict:
|
||||||
|
"""Index every KB principle by id so spec items with only id get enriched."""
|
||||||
|
data = _load_patterns_yaml("architecture-principles.yaml")
|
||||||
|
principles = data.get("principles", {}) if isinstance(data, dict) else {}
|
||||||
|
index = {}
|
||||||
|
for cat_items in principles.values():
|
||||||
|
for p in cat_items or []:
|
||||||
|
if isinstance(p, dict) and p.get("id"):
|
||||||
|
index[str(p["id"])] = {
|
||||||
|
"name": p.get("name", ""),
|
||||||
|
"summary": p.get("principle", p.get("summary", "")),
|
||||||
|
}
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
def _default_operational_raci(model: str = "co_managed") -> list:
|
def _default_operational_raci(model: str = "co_managed") -> list:
|
||||||
data = _load_patterns_yaml("operational-raci.yaml")
|
data = _load_patterns_yaml("operational-raci.yaml")
|
||||||
models = data.get("models", {}) if isinstance(data, dict) else {}
|
models = data.get("models", {}) if isinstance(data, dict) else {}
|
||||||
@@ -171,6 +187,13 @@ _HA_DR_BY_TIER = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_UPTIME_BY_TIER = {
|
||||||
|
"platinum": "99.99%",
|
||||||
|
"gold": "99.95%",
|
||||||
|
"silver": "99.9%",
|
||||||
|
"bronze": "99.5%",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _derive_ha_dr_tiers(workloads: list) -> list:
|
def _derive_ha_dr_tiers(workloads: list) -> list:
|
||||||
"""Produce one HA/DR row per distinct service tier present."""
|
"""Produce one HA/DR row per distinct service tier present."""
|
||||||
@@ -236,6 +259,167 @@ def _derive_service_tiering(services: list) -> list:
|
|||||||
return workloads
|
return workloads
|
||||||
|
|
||||||
|
|
||||||
|
def _timeline_to_weeks(timeline: str) -> int | None:
|
||||||
|
"""Parse a simple duration phrase like '12 weeks' or '6 months'."""
|
||||||
|
if not timeline:
|
||||||
|
return None
|
||||||
|
text = str(timeline).lower()
|
||||||
|
match = re.search(r"(\d+)\s*(week|weeks|month|months)", text)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
amount = int(match.group(1))
|
||||||
|
unit = match.group(2)
|
||||||
|
return amount * 4 if unit.startswith("month") else amount
|
||||||
|
|
||||||
|
|
||||||
|
def _proposal_default_migration(timeline: str = "") -> dict:
|
||||||
|
"""Generate a conservative Define/Design/Deliver plan from total duration."""
|
||||||
|
total_weeks = _timeline_to_weeks(timeline)
|
||||||
|
if total_weeks and total_weeks >= 6:
|
||||||
|
define = max(1, round(total_weeks * 0.2))
|
||||||
|
design = max(1, round(total_weeks * 0.3))
|
||||||
|
deliver = max(1, total_weeks - define - design)
|
||||||
|
total_duration = f"{total_weeks} weeks"
|
||||||
|
else:
|
||||||
|
define, design, deliver = 2, 4, 6
|
||||||
|
total_duration = timeline or "12 weeks (to validate)"
|
||||||
|
return {
|
||||||
|
"total_duration": total_duration,
|
||||||
|
"phases": [
|
||||||
|
{
|
||||||
|
"name": "Define",
|
||||||
|
"duration": f"{define} weeks",
|
||||||
|
"tasks": [
|
||||||
|
"Validate scope and business drivers",
|
||||||
|
"Baseline current estate and constraints",
|
||||||
|
"Confirm success criteria and migration assumptions",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Design",
|
||||||
|
"duration": f"{design} weeks",
|
||||||
|
"tasks": [
|
||||||
|
"Finalize target architecture and landing zone",
|
||||||
|
"Confirm security, DR, and networking approach",
|
||||||
|
"Validate sizing and migration wave plan",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Deliver",
|
||||||
|
"duration": f"{deliver} weeks",
|
||||||
|
"tasks": [
|
||||||
|
"Execute migration waves and rehearsals",
|
||||||
|
"Run cutover and stabilization",
|
||||||
|
"Complete operations handover",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"downtime": "Confirm cutover window with application owners during design.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _proposal_default_risks(summary: dict) -> list:
|
||||||
|
"""Infer generic-but-grounded proposal risks from sparse summary text."""
|
||||||
|
text_parts = [
|
||||||
|
pick(summary, "why"),
|
||||||
|
pick(summary, "target_state"),
|
||||||
|
pick(summary, "timeline"),
|
||||||
|
*coerce_list(summary.get("current_state")),
|
||||||
|
]
|
||||||
|
text = " ".join(str(p) for p in text_parts if p).lower()
|
||||||
|
risks = []
|
||||||
|
|
||||||
|
if any(token in text for token in ("legacy", "on-prem", "on premises", "migration")):
|
||||||
|
risks.append({
|
||||||
|
"risk": "Current-state migration scope may be larger than initially captured.",
|
||||||
|
"severity": "HIGH",
|
||||||
|
"mitigation": "Complete application and database discovery before locking the wave plan.",
|
||||||
|
})
|
||||||
|
if any(token in text for token in ("dr", "disaster recovery", "rto", "rpo", "resilien")):
|
||||||
|
risks.append({
|
||||||
|
"risk": "Resilience objectives need explicit validation against the proposed operating model.",
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"mitigation": "Confirm RTO/RPO targets and rehearse failover before production cutover.",
|
||||||
|
})
|
||||||
|
if any(token in text for token in ("cost", "budget", "tco", "byol", "payg")):
|
||||||
|
risks.append({
|
||||||
|
"risk": "Commercial assumptions may diverge from the customer's actual utilization baseline.",
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"mitigation": "Validate sizing, discount, and licensing assumptions with finance and platform owners.",
|
||||||
|
})
|
||||||
|
if any(token in text for token in ("team", "skill", "operations", "dba", "cloud")):
|
||||||
|
risks.append({
|
||||||
|
"risk": "Day-2 operating responsibilities may be unclear for the delivery team.",
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"mitigation": "Review the co-managed RACI and confirm ownership before the pilot starts.",
|
||||||
|
})
|
||||||
|
|
||||||
|
if not risks:
|
||||||
|
risks.append({
|
||||||
|
"risk": "Incomplete discovery details can force late design changes.",
|
||||||
|
"severity": "MEDIUM",
|
||||||
|
"mitigation": "Run a discovery checkpoint before committing to procurement or migration dates.",
|
||||||
|
})
|
||||||
|
return risks[:4]
|
||||||
|
|
||||||
|
|
||||||
|
def _proposal_default_next_steps(summary: dict) -> list:
|
||||||
|
timeline = pick(summary, "timeline")
|
||||||
|
steps = [
|
||||||
|
"Review the proposal with technical and business stakeholders.",
|
||||||
|
"Validate current-state inventory, sizing, and dependency assumptions.",
|
||||||
|
"Confirm the target delivery model and success criteria for the first wave.",
|
||||||
|
]
|
||||||
|
if timeline:
|
||||||
|
steps.append(f"Reconcile the plan against the stated timeline ({timeline}).")
|
||||||
|
steps.append("Decide whether to proceed with a proof of concept or detailed design workshop.")
|
||||||
|
return steps[:5]
|
||||||
|
|
||||||
|
|
||||||
|
def _enrich_partial_proposal_spec(spec: dict) -> dict:
|
||||||
|
"""Add safe fallback sections so sparse proposal specs still render usable decks."""
|
||||||
|
if not isinstance(spec, dict):
|
||||||
|
return spec
|
||||||
|
|
||||||
|
summary = spec.get("summary")
|
||||||
|
if not isinstance(summary, dict):
|
||||||
|
return spec
|
||||||
|
|
||||||
|
target_state = pick(summary, "target_state")
|
||||||
|
has_context = bool(target_state or pick(summary, "why") or coerce_list(summary.get("current_state")))
|
||||||
|
if not has_context:
|
||||||
|
return spec
|
||||||
|
|
||||||
|
enriched = dict(spec)
|
||||||
|
|
||||||
|
if "architecture" not in enriched and target_state:
|
||||||
|
enriched["architecture"] = {"description": target_state}
|
||||||
|
|
||||||
|
if "architecture_principles" not in enriched:
|
||||||
|
principles = _default_architecture_principles()
|
||||||
|
if principles:
|
||||||
|
enriched["architecture_principles"] = principles
|
||||||
|
|
||||||
|
if "migration" not in enriched:
|
||||||
|
enriched["migration"] = _proposal_default_migration(pick(summary, "timeline"))
|
||||||
|
|
||||||
|
if not any(k in enriched for k in ("operational_raci", "raci", "operations_raci")):
|
||||||
|
raci_items = _default_operational_raci("co_managed")
|
||||||
|
if raci_items:
|
||||||
|
enriched["operational_raci"] = {
|
||||||
|
"model": "co_managed",
|
||||||
|
"raci_items": raci_items,
|
||||||
|
}
|
||||||
|
|
||||||
|
if "risks" not in enriched:
|
||||||
|
enriched["risks"] = _proposal_default_risks(summary)
|
||||||
|
|
||||||
|
if "next_steps" not in enriched:
|
||||||
|
enriched["next_steps"] = {"steps": _proposal_default_next_steps(summary)}
|
||||||
|
|
||||||
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
def _adapt_flat_spec(spec: dict) -> dict:
|
def _adapt_flat_spec(spec: dict) -> dict:
|
||||||
"""Map the MCP flat payload to the proposal-spec structure from_spec expects."""
|
"""Map the MCP flat payload to the proposal-spec structure from_spec expects."""
|
||||||
arch = spec.get("architecture") if isinstance(spec.get("architecture"), dict) else {}
|
arch = spec.get("architecture") if isinstance(spec.get("architecture"), dict) else {}
|
||||||
@@ -836,17 +1020,22 @@ class OCIDeckGenerator(OraclePresBase):
|
|||||||
row_idx = i + 1
|
row_idx = i + 1
|
||||||
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
|
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
|
||||||
workload_name = pick(wl, "name", "workload", "title")
|
workload_name = pick(wl, "name", "workload", "title")
|
||||||
|
tier_label = pick(wl, "tier", "service_tier", default="Silver")
|
||||||
|
tier_key = tier_label.lower()
|
||||||
|
tier_color = tier_colors.get(tier_key, Colors.SECONDARY_TEXT)
|
||||||
|
tier_defaults = _HA_DR_BY_TIER.get(tier_key, {})
|
||||||
|
uptime = wl.get("uptime") or _UPTIME_BY_TIER.get(tier_key, "")
|
||||||
|
rto = wl.get("rto") or tier_defaults.get("rto", "")
|
||||||
|
rpo = wl.get("rpo") or tier_defaults.get("rpo", "")
|
||||||
self._style_table_cell(table.cell(row_idx, 0), workload_name, font_size=10, bold=True, bg_color=bg)
|
self._style_table_cell(table.cell(row_idx, 0), workload_name, font_size=10, bold=True, bg_color=bg)
|
||||||
tier_label = pick(wl, "tier", "name", default="Silver")
|
|
||||||
tier_color = tier_colors.get(tier_label.lower(), Colors.SECONDARY_TEXT)
|
|
||||||
self._style_table_cell(
|
self._style_table_cell(
|
||||||
table.cell(row_idx, 1), tier_label.title(),
|
table.cell(row_idx, 1), tier_label.title(),
|
||||||
font_size=10, bold=True, color=tier_color, bg_color=bg,
|
font_size=10, bold=True, color=tier_color, bg_color=bg,
|
||||||
alignment=PP_ALIGN.CENTER,
|
alignment=PP_ALIGN.CENTER,
|
||||||
)
|
)
|
||||||
self._style_table_cell(table.cell(row_idx, 2), wl.get("uptime", ""), font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
|
self._style_table_cell(table.cell(row_idx, 2), uptime, font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
|
||||||
self._style_table_cell(table.cell(row_idx, 3), wl.get("rto", ""), font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
|
self._style_table_cell(table.cell(row_idx, 3), rto, font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
|
||||||
self._style_table_cell(table.cell(row_idx, 4), wl.get("rpo", ""), font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
|
self._style_table_cell(table.cell(row_idx, 4), rpo, font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
|
||||||
|
|
||||||
def add_architecture_principles_slide(self, principles: dict):
|
def add_architecture_principles_slide(self, principles: dict):
|
||||||
"""Architecture Principles slide — ECAL Design/Deployment/Service categories.
|
"""Architecture Principles slide — ECAL Design/Deployment/Service categories.
|
||||||
@@ -857,6 +1046,8 @@ class OCIDeckGenerator(OraclePresBase):
|
|||||||
slide = self._add_blank_slide()
|
slide = self._add_blank_slide()
|
||||||
self._add_title_bar(slide, "Architecture Principles", margin=self.MARGIN)
|
self._add_title_bar(slide, "Architecture Principles", margin=self.MARGIN)
|
||||||
|
|
||||||
|
kb_index = _principles_kb_index()
|
||||||
|
|
||||||
y = Inches(1.2)
|
y = Inches(1.2)
|
||||||
col_x = self.MARGIN
|
col_x = self.MARGIN
|
||||||
col_width = Inches(4)
|
col_width = Inches(4)
|
||||||
@@ -877,7 +1068,11 @@ class OCIDeckGenerator(OraclePresBase):
|
|||||||
for item in items:
|
for item in items:
|
||||||
pid = item.get("id", "")
|
pid = item.get("id", "")
|
||||||
name = item.get("name", "")
|
name = item.get("name", "")
|
||||||
summary = item.get("summary", "")
|
summary = item.get("summary", "") or item.get("principle", "")
|
||||||
|
if pid and (not name or not summary):
|
||||||
|
fallback = kb_index.get(str(pid), {})
|
||||||
|
name = name or fallback.get("name", "")
|
||||||
|
summary = summary or fallback.get("summary", "")
|
||||||
label = f"{pid} {name}" if pid else name
|
label = f"{pid} {name}" if pid else name
|
||||||
if summary:
|
if summary:
|
||||||
label += f" — {summary}"
|
label += f" — {summary}"
|
||||||
@@ -1588,6 +1783,8 @@ class OCIDeckGenerator(OraclePresBase):
|
|||||||
# with actual content instead of only blank title/closing slides.
|
# with actual content instead of only blank title/closing slides.
|
||||||
if _is_flat_spec(spec):
|
if _is_flat_spec(spec):
|
||||||
spec = _adapt_flat_spec(spec)
|
spec = _adapt_flat_spec(spec)
|
||||||
|
else:
|
||||||
|
spec = _enrich_partial_proposal_spec(spec)
|
||||||
|
|
||||||
meta = spec.get("metadata", {})
|
meta = spec.get("metadata", {})
|
||||||
gen = cls(
|
gen = cls(
|
||||||
@@ -1622,10 +1819,43 @@ class OCIDeckGenerator(OraclePresBase):
|
|||||||
# Slide 5: Architecture
|
# Slide 5: Architecture
|
||||||
if "architecture" in spec:
|
if "architecture" in spec:
|
||||||
a = spec["architecture"]
|
a = spec["architecture"]
|
||||||
|
visual = a.get("visual")
|
||||||
|
description = a.get("description", "")
|
||||||
|
primary_region = (
|
||||||
|
spec.get("primary_region")
|
||||||
|
or spec.get("region")
|
||||||
|
or a.get("primary_region")
|
||||||
|
or a.get("region")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
dr_region = (
|
||||||
|
spec.get("dr_region")
|
||||||
|
or spec.get("secondary_region")
|
||||||
|
or a.get("dr_region")
|
||||||
|
or a.get("secondary_region")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
# Auto-build a two-region visual when the spec names a DR region
|
||||||
|
# but didn't pre-render the visual structure itself.
|
||||||
|
if dr_region and not visual:
|
||||||
|
visual = {
|
||||||
|
"regions": [
|
||||||
|
{"name": primary_region or "Primary", "primary": True, "label": "PRIMARY"},
|
||||||
|
{"name": dr_region, "primary": False, "label": "DR STANDBY"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
elif dr_region and isinstance(visual, dict):
|
||||||
|
regions = visual.get("regions") or []
|
||||||
|
names = {str(r.get("name", "")).lower() for r in regions if isinstance(r, dict)}
|
||||||
|
if dr_region.lower() not in names:
|
||||||
|
regions.append({"name": dr_region, "primary": False, "label": "DR STANDBY"})
|
||||||
|
visual["regions"] = regions
|
||||||
|
if dr_region and description and dr_region.lower() not in description.lower():
|
||||||
|
description = f"{description} • DR region: {dr_region}"
|
||||||
gen.add_architecture_slide(
|
gen.add_architecture_slide(
|
||||||
diagram_path=a.get("diagram_path"),
|
diagram_path=a.get("diagram_path"),
|
||||||
description=a.get("description", ""),
|
description=description,
|
||||||
visual=a.get("visual"),
|
visual=visual,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Slide 6: Decisions
|
# Slide 6: Decisions
|
||||||
|
|||||||
@@ -300,6 +300,8 @@ SVC_CATEGORY = {
|
|||||||
# Integration (purple #804998)
|
# Integration (purple #804998)
|
||||||
"drg": "svc_integration",
|
"drg": "svc_integration",
|
||||||
"dynamic_routing_gateway": "svc_integration",
|
"dynamic_routing_gateway": "svc_integration",
|
||||||
|
"rpc": "svc_integration",
|
||||||
|
"remote_peering_connection": "svc_integration",
|
||||||
"streaming": "svc_integration", "kafka": "svc_integration",
|
"streaming": "svc_integration", "kafka": "svc_integration",
|
||||||
"queue": "svc_integration", "oci_queue": "svc_integration",
|
"queue": "svc_integration", "oci_queue": "svc_integration",
|
||||||
"oic": "svc_integration", "integration_cloud": "svc_integration",
|
"oic": "svc_integration", "integration_cloud": "svc_integration",
|
||||||
@@ -1640,6 +1642,7 @@ class OCIDiagramGenerator:
|
|||||||
|
|
||||||
# Regions — auto-size containers from content when spec doesn't override
|
# Regions — auto-size containers from content when spec doesn't override
|
||||||
region_x_cursor = 15 # tracks right edge for auto-positioning next region
|
region_x_cursor = 15 # tracks right edge for auto-positioning next region
|
||||||
|
drg_by_region = [] # list of (region_id, drg_id) for cross-region RPC auto-wiring
|
||||||
for region in tenancy_spec.get("regions", []):
|
for region in tenancy_spec.get("regions", []):
|
||||||
is_primary = region.get("primary", False)
|
is_primary = region.get("primary", False)
|
||||||
rx = region.get("x", region_x_cursor)
|
rx = region.get("x", region_x_cursor)
|
||||||
@@ -1700,6 +1703,18 @@ class OCIDiagramGenerator:
|
|||||||
auto_rw = total_vcn_w + drg_lane_w + 50 # margins
|
auto_rw = total_vcn_w + drg_lane_w + 50 # margins
|
||||||
auto_rh = total_vcn_h + 70 # 45 top (label) + 25 bottom
|
auto_rh = total_vcn_h + 70 # 45 top (label) + 25 bottom
|
||||||
|
|
||||||
|
# Local DR standby (same region as primary) — adds a dormant
|
||||||
|
# node below the VCNs. Bump auto-height to make room.
|
||||||
|
local_dr = bool(region.get("local_dr") or region.get("local_dr_standby"))
|
||||||
|
if not local_dr:
|
||||||
|
for v in region_vcns:
|
||||||
|
if v.get("local_dr") or v.get("local_dr_standby"):
|
||||||
|
local_dr = True
|
||||||
|
break
|
||||||
|
local_dr_h = 90 if local_dr else 0
|
||||||
|
if local_dr:
|
||||||
|
auto_rh += local_dr_h
|
||||||
|
|
||||||
# Always use at least auto-calculated size even if spec is smaller
|
# Always use at least auto-calculated size even if spec is smaller
|
||||||
rw = max(region.get("w", auto_rw), auto_rw)
|
rw = max(region.get("w", auto_rw), auto_rw)
|
||||||
rh = max(region.get("h", auto_rh), auto_rh)
|
rh = max(region.get("h", auto_rh), auto_rh)
|
||||||
@@ -1738,6 +1753,7 @@ class OCIDiagramGenerator:
|
|||||||
drg_id, label, drg["type"],
|
drg_id, label, drg["type"],
|
||||||
region["id"], drg_x, drg_y, drg_w, drg_h,
|
region["id"], drg_x, drg_y, drg_w, drg_h,
|
||||||
)
|
)
|
||||||
|
drg_by_region.append((region["id"], drg_id))
|
||||||
|
|
||||||
# VCNs — offset right if DRG lane present
|
# VCNs — offset right if DRG lane present
|
||||||
vcn_offset_x = drg_lane_w + 15
|
vcn_offset_x = drg_lane_w + 15
|
||||||
@@ -1832,6 +1848,25 @@ class OCIDiagramGenerator:
|
|||||||
|
|
||||||
subnet_y += sh + SUBNET_GAP
|
subnet_y += sh + SUBNET_GAP
|
||||||
|
|
||||||
|
# Local DR standby — render a dormant node inside the region
|
||||||
|
# at the bottom-right corner so it's visually clear that the
|
||||||
|
# primary has a same-region failover target.
|
||||||
|
if local_dr:
|
||||||
|
standby_w = 160
|
||||||
|
standby_h = 70
|
||||||
|
standby_x = max(15, rw - standby_w - 20)
|
||||||
|
standby_y = rh - standby_h - 15
|
||||||
|
standby_label = (
|
||||||
|
region.get("local_dr_label")
|
||||||
|
or region.get("local_dr_standby_label")
|
||||||
|
or "Local DR\nStandby"
|
||||||
|
)
|
||||||
|
standby_id = region.get("local_dr_id") or gen._next_id()
|
||||||
|
gen.add_service(
|
||||||
|
standby_id, standby_label, "standby",
|
||||||
|
region["id"], standby_x, standby_y, standby_w, standby_h,
|
||||||
|
)
|
||||||
|
|
||||||
# Compartments inside region — can contain VCNs
|
# Compartments inside region — can contain VCNs
|
||||||
for comp in region.get("compartments", []):
|
for comp in region.get("compartments", []):
|
||||||
comp_id = comp.get("id", gen._next_id())
|
comp_id = comp.get("id", gen._next_id())
|
||||||
@@ -1939,9 +1974,39 @@ class OCIDiagramGenerator:
|
|||||||
)
|
)
|
||||||
opx += svc_w + 12
|
opx += svc_w + 12
|
||||||
|
|
||||||
|
# Cross-region RPC — when 2+ regions own a DRG and the spec didn't
|
||||||
|
# already wire a peering link between them, auto-add a "Remote
|
||||||
|
# Peering Connection" edge for each pair so the cross-region path
|
||||||
|
# is visible in the diagram.
|
||||||
|
connections = spec.get("connections", [])
|
||||||
|
if len(drg_by_region) >= 2:
|
||||||
|
existing_pairs = {
|
||||||
|
(c.get("from"), c.get("to")) for c in connections
|
||||||
|
if isinstance(c, dict)
|
||||||
|
}
|
||||||
|
existing_pairs |= {(b, a) for (a, b) in existing_pairs}
|
||||||
|
seen_drgs_per_region = {}
|
||||||
|
for region_id, drg_id in drg_by_region:
|
||||||
|
seen_drgs_per_region.setdefault(region_id, []).append(drg_id)
|
||||||
|
region_ids = list(seen_drgs_per_region.keys())
|
||||||
|
for i in range(len(region_ids)):
|
||||||
|
for j in range(i + 1, len(region_ids)):
|
||||||
|
src = seen_drgs_per_region[region_ids[i]][0]
|
||||||
|
tgt = seen_drgs_per_region[region_ids[j]][0]
|
||||||
|
if (src, tgt) in existing_pairs:
|
||||||
|
continue
|
||||||
|
connections = list(connections) + [{
|
||||||
|
"id": f"rpc_{src}_{tgt}",
|
||||||
|
"from": src,
|
||||||
|
"to": tgt,
|
||||||
|
"type": "network",
|
||||||
|
"label": "RPC\n(Remote Peering)",
|
||||||
|
}]
|
||||||
|
existing_pairs.add((src, tgt))
|
||||||
|
existing_pairs.add((tgt, src))
|
||||||
|
|
||||||
# Connections — merge duplicate from/to pairs (e.g., dual FastConnect)
|
# Connections — merge duplicate from/to pairs (e.g., dual FastConnect)
|
||||||
# into a single edge with combined label
|
# into a single edge with combined label
|
||||||
connections = spec.get("connections", [])
|
|
||||||
seen_pairs = {} # (from, to) -> index in deduped list
|
seen_pairs = {} # (from, to) -> index in deduped list
|
||||||
deduped = []
|
deduped = []
|
||||||
for conn in connections:
|
for conn in connections:
|
||||||
|
|||||||
@@ -220,6 +220,13 @@ def refresh_catalog(verbose=False):
|
|||||||
print(" Not in API (possibly retired): {}".format(not_found))
|
print(" Not in API (possibly retired): {}".format(not_found))
|
||||||
print(" Catalog saved: {}".format(CATALOG_PATH))
|
print(" Catalog saved: {}".format(CATALOG_PATH))
|
||||||
|
|
||||||
|
# Reverse-direction check: SKUs in API we haven't catalogued yet.
|
||||||
|
catalog_skus_after = {str(e["sku"]) for cat in raw.get("categories", {}).values()
|
||||||
|
for e in cat.get("skus", [])}
|
||||||
|
stub_map = {s: {} for s in catalog_skus_after}
|
||||||
|
new_skus = discover_missing_skus(api_map, stub_map, verbose=verbose)
|
||||||
|
print_missing_skus(new_skus, limit=None if verbose else 20)
|
||||||
|
|
||||||
|
|
||||||
def validate_catalog(verbose=False):
|
def validate_catalog(verbose=False):
|
||||||
"""Compare current catalog prices against API and report differences."""
|
"""Compare current catalog prices against API and report differences."""
|
||||||
@@ -282,6 +289,106 @@ def validate_catalog(verbose=False):
|
|||||||
if len(missing) > 10:
|
if len(missing) > 10:
|
||||||
print(" ... and {} more".format(len(missing) - 10))
|
print(" ... and {} more".format(len(missing) - 10))
|
||||||
|
|
||||||
|
# Reverse-direction check: SKUs in API we haven't catalogued yet.
|
||||||
|
new_skus = discover_missing_skus(api_map, sku_map, verbose=verbose)
|
||||||
|
print_missing_skus(new_skus, limit=None if verbose else 20)
|
||||||
|
|
||||||
|
|
||||||
|
def discover_missing_skus(api_map, catalog_sku_map, verbose=False):
|
||||||
|
"""Find SKUs present in the API but missing from the catalog.
|
||||||
|
|
||||||
|
The filter is auto-derived: we collect every `serviceCategory` value for
|
||||||
|
SKUs already in the catalog and only report new SKUs whose serviceCategory
|
||||||
|
falls within that curated set. This keeps the report relevant — as new
|
||||||
|
categories are added to the catalog, the discovery scope expands.
|
||||||
|
"""
|
||||||
|
curated_categories = set()
|
||||||
|
for sku in catalog_sku_map:
|
||||||
|
if sku in api_map:
|
||||||
|
sc = api_map[sku].get("serviceCategory", "") or ""
|
||||||
|
if sc:
|
||||||
|
curated_categories.add(sc)
|
||||||
|
|
||||||
|
if verbose:
|
||||||
|
print(" Curated serviceCategory values ({}): {}".format(
|
||||||
|
len(curated_categories), ", ".join(sorted(curated_categories))
|
||||||
|
))
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
for sku, product in api_map.items():
|
||||||
|
if sku in catalog_sku_map:
|
||||||
|
continue
|
||||||
|
sc = product.get("serviceCategory", "") or ""
|
||||||
|
if sc not in curated_categories:
|
||||||
|
continue
|
||||||
|
missing.append({
|
||||||
|
"sku": sku,
|
||||||
|
"product": product.get("displayName", ""),
|
||||||
|
"service_category": sc,
|
||||||
|
"metric": product.get("metricName", ""),
|
||||||
|
"price": extract_payg_price(product),
|
||||||
|
})
|
||||||
|
missing.sort(key=lambda x: (x["service_category"], x["sku"]))
|
||||||
|
return missing
|
||||||
|
|
||||||
|
|
||||||
|
def print_missing_skus(missing, limit=None):
|
||||||
|
"""Print a readable report of missing SKUs, grouped by serviceCategory."""
|
||||||
|
if not missing:
|
||||||
|
print("\nNo new SKUs discovered in curated categories. Catalog covers the API well.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print("\n{} SKUs present in API but missing from catalog".format(len(missing)))
|
||||||
|
print("(filtered to serviceCategory values already represented in our catalog)\n")
|
||||||
|
|
||||||
|
by_cat = {}
|
||||||
|
for m in missing:
|
||||||
|
by_cat.setdefault(m["service_category"], []).append(m)
|
||||||
|
|
||||||
|
shown = 0
|
||||||
|
truncated = False
|
||||||
|
for cat, items in sorted(by_cat.items()):
|
||||||
|
print(" [{}] ({} new)".format(cat, len(items)))
|
||||||
|
for m in items:
|
||||||
|
if limit is not None and shown >= limit:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
price_str = "${:.4f}".format(m["price"]) if m["price"] else " -"
|
||||||
|
print(" {:<10} {:>10} {}".format(
|
||||||
|
m["sku"], price_str, m["product"][:70]
|
||||||
|
))
|
||||||
|
shown += 1
|
||||||
|
if truncated:
|
||||||
|
break
|
||||||
|
|
||||||
|
if truncated:
|
||||||
|
print("\n ... and {} more (re-run with -v to list all)".format(len(missing) - shown))
|
||||||
|
|
||||||
|
print("\nNext step: review and add relevant entries to kb/pricing/oci-sku-catalog.yaml")
|
||||||
|
print(" under the appropriate category block.")
|
||||||
|
|
||||||
|
|
||||||
|
def discover_catalog(verbose=False):
|
||||||
|
"""Stand-alone --discover entry point: report new API SKUs not yet in catalog."""
|
||||||
|
print("Fetching current products from Oracle API...")
|
||||||
|
products = fetch_all_products(currency=DEFAULT_CURRENCY, verbose=verbose)
|
||||||
|
api_map = {}
|
||||||
|
for p in products:
|
||||||
|
pn = p.get("partNumber", "")
|
||||||
|
if pn:
|
||||||
|
api_map[pn] = p
|
||||||
|
|
||||||
|
raw, sku_map = load_current_catalog()
|
||||||
|
if not raw:
|
||||||
|
print("Error: Could not load catalog at {}".format(CATALOG_PATH))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
missing = discover_missing_skus(api_map, sku_map, verbose=verbose)
|
||||||
|
print_missing_skus(missing, limit=None if verbose else 40)
|
||||||
|
# Machine-readable summary for CI/automation — always last line on stdout.
|
||||||
|
print("\nDISCOVER_MISSING_COUNT={}".format(len(missing)))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def inspect_sku(part_number):
|
def inspect_sku(part_number):
|
||||||
"""Fetch and display a single SKU from the API."""
|
"""Fetch and display a single SKU from the API."""
|
||||||
@@ -571,6 +678,9 @@ def main():
|
|||||||
"(currently: compute)")
|
"(currently: compute)")
|
||||||
parser.add_argument("--validate", action="store_true",
|
parser.add_argument("--validate", action="store_true",
|
||||||
help="Validate catalog prices against API")
|
help="Validate catalog prices against API")
|
||||||
|
parser.add_argument("--discover", action="store_true",
|
||||||
|
help="Report SKUs present in API but missing from catalog "
|
||||||
|
"(filtered to serviceCategory values already curated)")
|
||||||
parser.add_argument("--sku", type=str,
|
parser.add_argument("--sku", type=str,
|
||||||
help="Inspect a single SKU from the API")
|
help="Inspect a single SKU from the API")
|
||||||
parser.add_argument("--dump", type=str, metavar="FILE",
|
parser.add_argument("--dump", type=str, metavar="FILE",
|
||||||
@@ -589,6 +699,8 @@ def main():
|
|||||||
refresh_catalog(verbose=args.verbose or args.diff)
|
refresh_catalog(verbose=args.verbose or args.diff)
|
||||||
elif args.validate:
|
elif args.validate:
|
||||||
validate_catalog(verbose=args.verbose)
|
validate_catalog(verbose=args.verbose)
|
||||||
|
elif args.discover:
|
||||||
|
return discover_catalog(verbose=args.verbose)
|
||||||
elif args.dump:
|
elif args.dump:
|
||||||
dump_all(args.dump, verbose=args.verbose)
|
dump_all(args.dump, verbose=args.verbose)
|
||||||
else:
|
else:
|
||||||
|
|||||||
Reference in New Issue
Block a user