10 Commits

Author SHA1 Message Date
sku-refresh-bot
32319b67d7 chore(pricing): monthly SKU catalog refresh (2026-04-24)
Automated refresh from Oracle public pricing API.
See workflow artifact 'sku-refresh-logs' for the diff.
2026-04-24 15:53:41 +00:00
root
9fbe05a6a5 Use per-run branch name to avoid push collisions
All checks were successful
Deploy Skill to OCI / deploy (push) Successful in 19s
Prior run pushed automation/sku-refresh-2026-04-24 successfully but
failed before opening the PR (issue-creation bug, now fixed). On the
next run, main has moved, so a new commit on the same branch name has
a different parent → non-fast-forward rejection.

Append ${{ github.run_number }} to make the branch unique per workflow
invocation. Orphan branches left behind can be cleaned up after their
PRs are merged/closed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:45:57 -03:00
root
9b4d04cf34 Make discover count machine-readable for CI parsing
All checks were successful
Deploy Skill to OCI / deploy (push) Successful in 25s
The workflow's grep-on-free-text approach was fragile: local run correctly
extracted 162, but in the CI run the 'Open issue' step was skipped, meaning
new_count ended up as 0 after parsing. Likely causes: stdout buffering,
color codes, or shell differences between local and the Gitea runner's
shell.

Fix: tools/refresh_sku_catalog.py --discover now emits a deterministic
last-line marker `DISCOVER_MISSING_COUNT=<n>`. The workflow parses that
with sed (anchored, unambiguous) instead of the human-facing summary
line. Also added a diagnostic echo so the parsed value shows up in CI
logs for future debugging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:43:06 -03:00
root
30394ece48 Pin upload-artifact to v3 for Gitea Actions compatibility
All checks were successful
Deploy Skill to OCI / deploy (push) Successful in 21s
Gitea Actions runner does not support actions/upload-artifact v2.0.0+
(error: "@actions/artifact v2.0.0+, upload-artifact@v4+ and
download-artifact@v4+ are not currently supported on GHES").

The v4 release switched to a new artifact API that only GitHub-hosted
runners implement. v3 still uses the v1 API and works on Gitea /
self-hosted / GHES runners.

Downgraded in both sku-catalog-refresh.yaml and kb-health.yaml.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:31:58 -03:00
root
480c4bca52 Fix Python setup in Gitea Actions workflows
All checks were successful
Deploy Skill to OCI / deploy (push) Successful in 25s
The oci-mcp-runner uses node:20-bookworm which runs as root and does
NOT ship with sudo installed. Our workflows used 'sudo apt-get' → step
failed with 'sudo: command not found' (exit 127) on first invocation
of sku-catalog-refresh.yaml.

Fix both affected workflows (sku-catalog-refresh + kb-health):
- Drop the `sudo` prefix (we're already root).
- Install Python deps via apt packages (python3-requests, python3-yaml,
  python3-bs4) instead of pip. Avoids PEP 668 / externally-managed
  environment error on Debian 12 and is faster/cacheable by the runner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 12:24:35 -03:00
root
433b08071b Use auto-provisioned GITHUB_TOKEN in SKU refresh workflow
All checks were successful
Deploy Skill to OCI / deploy (push) Successful in 30s
Switch from manually-configured secrets.GITEA_TOKEN to the auto-provisioned
secrets.GITHUB_TOKEN that Gitea Actions creates per run. Scoped to the repo,
rotates per workflow run, no manual config needed.

Added explicit permissions block (contents:write, pull-requests:write,
issues:write) so the ephemeral token has the scopes required to push a
branch, open the PR, and open the issue for new SKUs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:51:00 -03:00
root
874d40d38a Improve SKU catalog hygiene and BOM accuracy
All checks were successful
Deploy Skill to OCI / deploy (push) Successful in 21s
- refresh_sku_catalog.py: add --discover to report API SKUs missing from
  catalog, filtered to already-curated serviceCategory values. Auto-runs
  at end of --validate and --refresh so gaps surface on every maintenance
  pass. First run found 162 missing SKUs (Blackwell GPUs, X12 Ax compute,
  VMware reserved tiers, WebLogic-on-OKE, Analytics/OIC BYOL).
- oci_bom_gen.py: fix Cost % column being blank. Back-fill was gated on
  formula detection but data rows write raw numbers, so the predicate
  never matched. Now tracks data_item_rows explicitly.
- .gitea/workflows/sku-catalog-refresh.yaml: monthly automation (1st at
  09:00 UTC). Auto-refresh prices → push branch + open PR; detect new
  SKUs → open issue with labels. Gated on secrets.GITEA_TOKEN.
- kb/pricing/oci-sku-catalog.yaml: add B95714 / B95715 (Autonomous ATP
  Dedicated ECPU, LI + BYOL) — canonical SKUs for ADB-D pricing.
- kb/services/compute.yaml: add X12 family (VM.Standard4.Ax.Flex,
  BM.Standard4.Ax.120), E6.Ax and A4.Ax variants. Verified against
  docs.oracle.com computeshapes.htm. Bump last_verified + changelog.
- templates/bom-spec.yaml: document 730 hrs/month convention as default
  (= real annual billing / 12; 744 overstates by 1.92% on 12-month TCO).
- Makefile: new 'make sku-discover' target.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:40:53 -03:00
root
a22711c065 Update ExaCS X11M KB with authoritative datasheet figures
All checks were successful
Deploy Skill to OCI / deploy (push) Successful in 30s
Source: ADB-D X11M Datasheet v1.0 (Jan 2025), Table 1.

- Add HC storage variant (default): 80 TB usable disk/SS, 264 TB raw, 1.25 TB
  XRMEM + 27.2 TB flash cache per SS. Keep EF variant (52.5 TB usable/SS).
- Add x11m_elastic_base (2 DB + 3 SS): 240 TB total usable disk, 192 TB Max
  DB Size no-backup (what OCI Cost Estimator caps at), 96 TB with backup.
  Include performance metrics (5.6M read IOPS, 300 GB/s flash bw, etc.).
- Add x11m_base_system as distinct SKU: fixed-spec, non-expandable, 192
  ECPUs / 73 TB usable disk / 10 GbE — not the same as elastic 2+3.
- Replace misleading rack_configurations table (mixed X9M figures) with
  elastic per-server model; 'quarter/half/full rack' noted as legacy.
- Add pricing verified against OCI Cost Estimator 2026-04-23: DB and SS at
  \$2.9032/hr each; base config \$10,800/month excluding licenses.
- Add gotcha exacs_x11m_hc_vs_ef: default quotes to EF without
  justification; HC serves hot data from flash cache at same latency and
  far better \$/TB.
- Document the three distinct storage figures (Total Usable Disk vs Max DB
  Size no-backup vs with-backup) to avoid customer confusion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:02:42 -03:00
root
9d5cda8986 Fix BOM workbook monthly totals
All checks were successful
Deploy Skill to OCI / deploy (push) Successful in 21s
2026-04-23 10:54:07 -03:00
root
6de2251e00 Fix slide and diagram bugs in deck/diagram generators
Service Tiering slide no longer falls back to the workload `name`
field for the tier label, which previously rendered "Bronze Bronze"
when the spec only carried tier names. Uptime/RTO/RPO now fill from
tier defaults when the spec omits them.

Architecture Principles slide enriches caller-supplied items with
name + summary from the KB when only the principle id is given,
instead of rendering bare placeholders like "P-01 P-02".

Architecture Overview slide auto-builds a two-region visual when the
proposal spec names a `dr_region` but doesn't pre-render `architecture.visual`,
so DR is no longer dropped from the deck.

Diagram generator gained a region-level `local_dr` flag that adds a
"Local DR Standby" dormant node inside the region, and now auto-wires
an "RPC (Remote Peering)" edge between DRGs when the spec defines
two or more regions with DRGs but no manual peering connection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 00:35:10 -03:00
11 changed files with 792 additions and 115 deletions

View File

@@ -19,13 +19,10 @@ jobs:
- name: Setup Python
run: |
sudo apt-get update -qq
sudo apt-get install -y python3 python3-pip
apt-get update -qq
apt-get install -y --no-install-recommends python3 python3-requests python3-bs4 python3-yaml
python3 --version
- name: Install dependencies
run: python3 -m pip install --user requests beautifulsoup4 pyyaml
- name: Check Architecture Center links
id: check
continue-on-error: true
@@ -71,7 +68,7 @@ jobs:
- name: Upload report
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v3
with:
name: kb-health-report
path: /tmp/link-check.txt

View 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

View File

@@ -1,6 +1,6 @@
# 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
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
@$(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

View File

@@ -1,4 +1,4 @@
last_verified: '2026-03-27'
last_verified: '2026-04-24'
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
only. Always verify against https://www.oracle.com/cloud/pricing/
@@ -560,6 +560,18 @@ categories:
list_price_usd: 0.0807
default_hours_units: 730
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:
display_name: Oracle Cloud Infrastructure - Database Exadata Exascale Infrastructure
skus:
@@ -1289,12 +1301,6 @@ categories:
list_price_usd: 0.015
default_hours_units: 730
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
product: '[ESTIMATE] OCI Data Science - Notebook Session (billed via underlying VM.Standard compute OCPU)'
metric: OCPU Per Hour

View File

@@ -1,8 +1,11 @@
---
last_verified: 2026-03-14
last_verified: 2026-04-24
service: OCI Compute
category: compute
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
@@ -42,6 +45,24 @@ shape_families:
max_ocpu: 18
memory_per_ocpu_gb: "1-64 GB per OCPU"
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
processor: "AMD EPYC 9J14 (Genoa)"
ocpus: 192
@@ -50,6 +71,30 @@ shape_families:
processor: "AMD EPYC 9J45 (Turin)"
ocpus: 256
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
processor: "NVIDIA A10"
gpu_count: 1

View File

@@ -1,5 +1,5 @@
# OCI Service: Exadata Cloud Service (ExaCS)
# Last verified: 2026-03
# Last verified: 2026-04
service:
name: "Exadata Cloud Service (ExaCS)"
@@ -35,37 +35,102 @@ variants:
usable_tb: 63.6
- id: 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:
ecpus: 760
memory_gb: 1390
xrmem_gb: 1250
flash_cache_tb: 27.2
per_storage_server:
usable_tb: 52.5
cpu: "2 × AMD EPYC 9J25 (96 cores each, 192 cores per server)"
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)"
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:
model: "Elastic per-server (2-32 DB servers, 3-64 storage servers)"
rack_configurations:
quarter_rack:
ocpus: "252 (X9M: 2 × 126/server)"
memory: "2.71 TB (X9M: 2 × 1,390 GB)"
storage: "Up to 190.8 TB usable (X9M: 3 SS × 63.6 TB)"
db_nodes: 2
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."
notes: |
Three different storage figures appear in Oracle datasheets — they measure different things:
• Total Usable Disk Capacity = ASM-allocatable space, post HIGH redundancy + drive-failure reserve, before compression
• Max DB Size No Local Backup = practical limit for DB allocation after RECO/SPARSE/DBFS reserves (this is what OCI Cost Estimator caps at)
• Max DB Size With Local Backup = ~half of above (FRA on disk for backups)
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
half_rack:
ocpus: "504 (X9M: 4 × 126/server)"
memory: "5.43 TB"
storage: "Up to 383 TB usable"
db_nodes: 4
storage_servers: 6
full_rack:
ocpus: "1,008 (X9M: 8 × 126/server)"
memory: "10.86 TB"
storage: "Up to 766 TB usable"
db_nodes: 8
storage_servers: 12
x11m_elastic_base:
description: "Datasheet 'Elastic Configuration Example 1' (2 DB + 3 SS, all-elastic SKUs)."
source: "ADB-D X11M Datasheet v1.0, Jan 2025, Table 1"
db_servers: 2
storage_servers: 3
total_ecpus: 1520 # 2 × 760
total_memory_gb: 2780 # 2 × 1,390
total_xrmem_tb: 3.75 # 3 × 1.25 (XRMEM lives on storage servers)
total_flash_tb: 81.6 # 3 × 27.2
hc_variant:
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:
quarter_rack:
storage_tb: 149
@@ -74,8 +139,9 @@ sizing:
full_rack:
storage_tb: 598
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:
- id: exacs_minimum_cost
@@ -102,6 +168,10 @@ gotchas:
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."
- 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:
- service: vcn
reason: "ExaCS infrastructure must be deployed in a VCN"
@@ -123,3 +193,9 @@ changelog:
- date: "2026-03-14"
contributor: { name: "Diego Cabrera", team: "Field Architecture" }
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."

View File

@@ -48,3 +48,4 @@ bom:
notes:
- "Pricing based on Ashburn region (OC1)"
- "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."

View File

@@ -204,6 +204,17 @@ class OCIBomGenerator:
def _col_count(self) -> int:
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):
"""Build the complete BOM sheet."""
ws.title = "BOM"
@@ -317,6 +328,7 @@ class OCIBomGenerator:
data_start_row = row
cat_subtotal_rows = []
data_item_rows = [] # line-item rows (excludes category headers/subtotals/totals)
# Ensure categories present in items but missing from catalog order
# (e.g., "other" from unknown SKUs) still render at the end.
@@ -325,6 +337,11 @@ class OCIBomGenerator:
if cat_key not in render_order:
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:
if cat_key not in items_by_cat:
continue
@@ -344,6 +361,10 @@ class OCIBomGenerator:
row += 1
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):
# Static data
@@ -356,38 +377,22 @@ class OCIBomGenerator:
ws.cell(row=row, column=COL_MONTHS, value=item["months"])
ws.cell(row=row, column=COL_DISC, value=item["discount"])
# Formula: Monthly w/o discount = Price × Hours × Qty
p = get_column_letter(COL_PRICE)
h = get_column_letter(COL_HOURS)
q = get_column_letter(COL_QTY)
d = get_column_letter(COL_DISC)
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})",
)
monthly_wo, monthly_w = self._monthly_values(item)
ws.cell(row=row, column=COL_M_WO, value=monthly_wo)
ws.cell(row=row, column=COL_M_W, value=monthly_w)
cat_total_wo += monthly_wo
cat_total_w += monthly_w
# Conversion formulas
if conv:
xr = self.conversion.get("exchange_rate", 1)
tr = self.conversion.get("tax_rate", 0)
mw = get_column_letter(COL_M_W)
# Converted w/o tax
ws.cell(
row=row, column=COL_CONV_WO,
value=f"={mw}{row}*{xr}",
)
# Converted w/ tax
ws.cell(
row=row, column=COL_CONV_W,
value=f"={get_column_letter(COL_CONV_WO)}{row}*(1+{tr})",
)
conv_wo = monthly_w * xr
conv_w = conv_wo * (1 + tr)
ws.cell(row=row, column=COL_CONV_WO, value=conv_wo)
ws.cell(row=row, column=COL_CONV_W, value=conv_w)
cat_total_conv_wo += conv_wo
cat_total_conv_w += conv_w
# Styling per row
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_W).number_format = '#,##0.00'
data_item_rows.append(row)
row += 1
# Category subtotal row
cat_last_data = row - 1
ws.cell(row=row, column=COL_PRODUCT, value=f"Subtotal — {display_name}")
mwo_letter = get_column_letter(COL_M_WO)
mw_letter = get_column_letter(COL_M_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, value=cat_total_wo)
ws.cell(row=row, column=COL_M_W, value=cat_total_w)
ws.cell(row=row, column=COL_M_WO).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:
cwo_letter = get_column_letter(COL_CONV_WO)
cw_letter = get_column_letter(COL_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, value=cat_total_conv_wo)
ws.cell(row=row, column=COL_CONV_W, value=cat_total_conv_w)
ws.cell(row=row, column=COL_CONV_WO).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):
cell = ws.cell(row=row, column=ci)
@@ -448,19 +454,14 @@ class OCIBomGenerator:
total_row = row
ws.cell(row=row, column=COL_PRODUCT, value="TOTAL").font = total_font
# Sum of subtotals
mwo_refs = "+".join(f"{get_column_letter(COL_M_WO)}{r}" for r in cat_subtotal_rows)
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, value=grand_total_wo)
ws.cell(row=row, column=COL_M_W, value=grand_total_w)
ws.cell(row=row, column=COL_M_WO).number_format = '$#,##0.00'
ws.cell(row=row, column=COL_M_W).number_format = '$#,##0.00'
if conv:
cwo_refs = "+".join(f"{get_column_letter(COL_CONV_WO)}{r}" for r in cat_subtotal_rows)
cw_refs = "+".join(f"{get_column_letter(COL_CONV_W)}{r}" for r in cat_subtotal_rows)
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, value=grand_total_conv_wo)
ws.cell(row=row, column=COL_CONV_W, value=grand_total_conv_w)
ws.cell(row=row, column=COL_CONV_WO).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(
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=f"={mw_total}{total_row}*12")
ws.cell(row=row, column=COL_M_W, value=grand_total_w * 12)
ws.cell(row=row, column=COL_M_W).number_format = '$#,##0.00'
ws.cell(row=row, column=COL_M_W).font = Font(
name="Segoe UI", size=11, bold=True, color=Colors.COPPER
)
if conv:
cw_total = get_column_letter(COL_CONV_W)
ws.cell(row=row, column=COL_CONV_W, value=f"={cw_total}{total_row}*12")
ws.cell(row=row, column=COL_CONV_W, value=grand_total_conv_w * 12)
ws.cell(row=row, column=COL_CONV_W).number_format = '#,##0.00'
ws.cell(row=row, column=COL_CONV_W).font = Font(
name="Segoe UI", size=11, bold=True, color=Colors.COPPER
@@ -495,10 +494,8 @@ class OCIBomGenerator:
# ── Cost proportion formulas (back-fill) ─────────────────
# 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)
for item_row in data_item_rows:
ws.cell(
row=item_row, column=COL_PCT,
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=10, value=item["discount"])
# Formulas matching BOM.C1 style
ws_bom.cell(row=row, column=11,
value="=(F{r}*G{r}*H{r})".format(r=row))
ws_bom.cell(row=row, column=12,
value="=IFERROR(K{r}*(1-J{r}),K{r})".format(r=row))
monthly_wo, monthly_w = self._monthly_values(item)
ws_bom.cell(row=row, column=11, value=monthly_wo)
ws_bom.cell(row=row, column=12, value=monthly_w)
# Formats
ws_bom.cell(row=row, column=6).number_format = '#,##0.0000'

View File

@@ -17,6 +17,7 @@ Or import and use programmatically:
import yaml
import argparse
import re
from datetime import datetime
from pathlib import Path
from typing import Optional
@@ -118,6 +119,21 @@ def _default_architecture_principles() -> dict:
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:
data = _load_patterns_yaml("operational-raci.yaml")
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:
"""Produce one HA/DR row per distinct service tier present."""
@@ -236,6 +259,167 @@ def _derive_service_tiering(services: list) -> list:
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:
"""Map the MCP flat payload to the proposal-spec structure from_spec expects."""
arch = spec.get("architecture") if isinstance(spec.get("architecture"), dict) else {}
@@ -836,17 +1020,22 @@ class OCIDeckGenerator(OraclePresBase):
row_idx = i + 1
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 else None
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)
tier_label = pick(wl, "tier", "name", default="Silver")
tier_color = tier_colors.get(tier_label.lower(), Colors.SECONDARY_TEXT)
self._style_table_cell(
table.cell(row_idx, 1), tier_label.title(),
font_size=10, bold=True, color=tier_color, bg_color=bg,
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, 3), wl.get("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, 2), uptime, 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), rpo, font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
def add_architecture_principles_slide(self, principles: dict):
"""Architecture Principles slide — ECAL Design/Deployment/Service categories.
@@ -857,6 +1046,8 @@ class OCIDeckGenerator(OraclePresBase):
slide = self._add_blank_slide()
self._add_title_bar(slide, "Architecture Principles", margin=self.MARGIN)
kb_index = _principles_kb_index()
y = Inches(1.2)
col_x = self.MARGIN
col_width = Inches(4)
@@ -877,7 +1068,11 @@ class OCIDeckGenerator(OraclePresBase):
for item in items:
pid = item.get("id", "")
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
if summary:
label += f"{summary}"
@@ -1588,6 +1783,8 @@ class OCIDeckGenerator(OraclePresBase):
# with actual content instead of only blank title/closing slides.
if _is_flat_spec(spec):
spec = _adapt_flat_spec(spec)
else:
spec = _enrich_partial_proposal_spec(spec)
meta = spec.get("metadata", {})
gen = cls(
@@ -1622,10 +1819,43 @@ class OCIDeckGenerator(OraclePresBase):
# Slide 5: Architecture
if "architecture" in spec:
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(
diagram_path=a.get("diagram_path"),
description=a.get("description", ""),
visual=a.get("visual"),
description=description,
visual=visual,
)
# Slide 6: Decisions

View File

@@ -300,6 +300,8 @@ SVC_CATEGORY = {
# Integration (purple #804998)
"drg": "svc_integration",
"dynamic_routing_gateway": "svc_integration",
"rpc": "svc_integration",
"remote_peering_connection": "svc_integration",
"streaming": "svc_integration", "kafka": "svc_integration",
"queue": "svc_integration", "oci_queue": "svc_integration",
"oic": "svc_integration", "integration_cloud": "svc_integration",
@@ -1640,6 +1642,7 @@ class OCIDiagramGenerator:
# Regions — auto-size containers from content when spec doesn't override
region_x_cursor = 15 # tracks right edge for auto-positioning next region
drg_by_region = [] # list of (region_id, drg_id) for cross-region RPC auto-wiring
for region in tenancy_spec.get("regions", []):
is_primary = region.get("primary", False)
rx = region.get("x", region_x_cursor)
@@ -1700,6 +1703,18 @@ class OCIDiagramGenerator:
auto_rw = total_vcn_w + drg_lane_w + 50 # margins
auto_rh = total_vcn_h + 70 # 45 top (label) + 25 bottom
# Local DR standby (same region as primary) — adds a dormant
# node below the VCNs. Bump auto-height to make room.
local_dr = bool(region.get("local_dr") or region.get("local_dr_standby"))
if not local_dr:
for v in region_vcns:
if v.get("local_dr") or v.get("local_dr_standby"):
local_dr = True
break
local_dr_h = 90 if local_dr else 0
if local_dr:
auto_rh += local_dr_h
# Always use at least auto-calculated size even if spec is smaller
rw = max(region.get("w", auto_rw), auto_rw)
rh = max(region.get("h", auto_rh), auto_rh)
@@ -1738,6 +1753,7 @@ class OCIDiagramGenerator:
drg_id, label, drg["type"],
region["id"], drg_x, drg_y, drg_w, drg_h,
)
drg_by_region.append((region["id"], drg_id))
# VCNs — offset right if DRG lane present
vcn_offset_x = drg_lane_w + 15
@@ -1832,6 +1848,25 @@ class OCIDiagramGenerator:
subnet_y += sh + SUBNET_GAP
# Local DR standby — render a dormant node inside the region
# at the bottom-right corner so it's visually clear that the
# primary has a same-region failover target.
if local_dr:
standby_w = 160
standby_h = 70
standby_x = max(15, rw - standby_w - 20)
standby_y = rh - standby_h - 15
standby_label = (
region.get("local_dr_label")
or region.get("local_dr_standby_label")
or "Local DR\nStandby"
)
standby_id = region.get("local_dr_id") or gen._next_id()
gen.add_service(
standby_id, standby_label, "standby",
region["id"], standby_x, standby_y, standby_w, standby_h,
)
# Compartments inside region — can contain VCNs
for comp in region.get("compartments", []):
comp_id = comp.get("id", gen._next_id())
@@ -1939,9 +1974,39 @@ class OCIDiagramGenerator:
)
opx += svc_w + 12
# Cross-region RPC — when 2+ regions own a DRG and the spec didn't
# already wire a peering link between them, auto-add a "Remote
# Peering Connection" edge for each pair so the cross-region path
# is visible in the diagram.
connections = spec.get("connections", [])
if len(drg_by_region) >= 2:
existing_pairs = {
(c.get("from"), c.get("to")) for c in connections
if isinstance(c, dict)
}
existing_pairs |= {(b, a) for (a, b) in existing_pairs}
seen_drgs_per_region = {}
for region_id, drg_id in drg_by_region:
seen_drgs_per_region.setdefault(region_id, []).append(drg_id)
region_ids = list(seen_drgs_per_region.keys())
for i in range(len(region_ids)):
for j in range(i + 1, len(region_ids)):
src = seen_drgs_per_region[region_ids[i]][0]
tgt = seen_drgs_per_region[region_ids[j]][0]
if (src, tgt) in existing_pairs:
continue
connections = list(connections) + [{
"id": f"rpc_{src}_{tgt}",
"from": src,
"to": tgt,
"type": "network",
"label": "RPC\n(Remote Peering)",
}]
existing_pairs.add((src, tgt))
existing_pairs.add((tgt, src))
# Connections — merge duplicate from/to pairs (e.g., dual FastConnect)
# into a single edge with combined label
connections = spec.get("connections", [])
seen_pairs = {} # (from, to) -> index in deduped list
deduped = []
for conn in connections:

View File

@@ -220,6 +220,13 @@ def refresh_catalog(verbose=False):
print(" Not in API (possibly retired): {}".format(not_found))
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):
"""Compare current catalog prices against API and report differences."""
@@ -282,6 +289,106 @@ def validate_catalog(verbose=False):
if 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):
"""Fetch and display a single SKU from the API."""
@@ -571,6 +678,9 @@ def main():
"(currently: compute)")
parser.add_argument("--validate", action="store_true",
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,
help="Inspect a single SKU from the API")
parser.add_argument("--dump", type=str, metavar="FILE",
@@ -589,6 +699,8 @@ def main():
refresh_catalog(verbose=args.verbose or args.diff)
elif args.validate:
validate_catalog(verbose=args.verbose)
elif args.discover:
return discover_catalog(verbose=args.verbose)
elif args.dump:
dump_all(args.dump, verbose=args.verbose)
else: