Compare commits
21 Commits
4660b93a98
...
automation
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f95c4c3dc8 | ||
|
|
30394ece48 | ||
|
|
480c4bca52 | ||
|
|
433b08071b | ||
|
|
874d40d38a | ||
|
|
a22711c065 | ||
|
|
9d5cda8986 | ||
|
|
6de2251e00 | ||
|
|
77298a471c | ||
|
|
6209907a63 | ||
|
|
fc550c14bb | ||
|
|
490072a4b7 | ||
|
|
bcb16795d1 | ||
|
|
e57ab57b48 | ||
|
|
dc4abf3774 | ||
|
|
01baff1f5b | ||
|
|
aaf0ecc548 | ||
|
|
7cfb5dce2d | ||
|
|
98cb570e96 | ||
|
|
20be7c297e | ||
|
|
886c09ad10 |
@@ -19,7 +19,7 @@ When the user starts a conversation without providing discovery notes or a speci
|
|||||||
|
|
||||||
### Pre-flight: KB freshness check
|
### Pre-flight: KB freshness check
|
||||||
|
|
||||||
**Before showing the welcome message**, run `python tools/kb_freshness.py --check --json` and parse the JSON output. Behavior based on the result:
|
**Before showing the welcome message**, run `make kb-check 2>/dev/null` and parse the JSON output. Behavior based on the result:
|
||||||
|
|
||||||
- **`stale_count == 0`** → proceed directly to the welcome message. No banner.
|
- **`stale_count == 0`** → proceed directly to the welcome message. No banner.
|
||||||
- **`stale_count > 0` and at least one file has `refreshable: true`** → prepend this banner above the menu and ask the user inline:
|
- **`stale_count > 0` and at least one file has `refreshable: true`** → prepend this banner above the menu and ask the user inline:
|
||||||
@@ -30,8 +30,8 @@ When the user starts a conversation without providing discovery notes or a speci
|
|||||||
Refresh now before showing the menu? [y/N]
|
Refresh now before showing the menu? [y/N]
|
||||||
```
|
```
|
||||||
|
|
||||||
- If the user replies `y` / `yes` / `sí` → run `python tools/kb_freshness.py --auto-refresh`, wait for completion, then show the menu.
|
- If the user replies `y` / `yes` / `sí` → run `make freshness-refresh`, wait for completion, then show the menu.
|
||||||
- If the user replies `n` / anything else → show the menu immediately, with a one-line compact reminder above it: `⚠️ <N> KB file(s) stale — run /freshness or python tools/kb_freshness.py --auto-refresh later.`
|
- If the user replies `n` / anything else → show the menu immediately, with a one-line compact reminder above it: `⚠️ <N> KB file(s) stale — run /freshness or make freshness-refresh later.`
|
||||||
|
|
||||||
- **`stale_count > 0` but no file has `refreshable: true`** (only manual files stale) → prepend a non-blocking informational banner above the menu, do NOT ask:
|
- **`stale_count > 0` but no file has `refreshable: true`** (only manual files stale) → prepend a non-blocking informational banner above the menu, do NOT ask:
|
||||||
|
|
||||||
|
|||||||
20
.claude/settings.json
Normal file
20
.claude/settings.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Read(**)",
|
||||||
|
"Write(examples/**)",
|
||||||
|
"Write(output/**)",
|
||||||
|
"Bash(python3.12:*)",
|
||||||
|
"Bash(python3:*)",
|
||||||
|
"Bash(.venv/bin/python:*)",
|
||||||
|
"Bash(make:*)",
|
||||||
|
"Bash(git:*)",
|
||||||
|
"Bash(pip:*)",
|
||||||
|
"Bash(pip3:*)",
|
||||||
|
"Bash(pip3.12:*)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(which:*)",
|
||||||
|
"Bash(find:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,7 +74,40 @@
|
|||||||
"WebFetch(domain:github.com)",
|
"WebFetch(domain:github.com)",
|
||||||
"Bash(curl -s --max-time 15 -H \"X-Oracle-Accept-CurrencyCode: USD\" \"https://itra.oraclecloud.com/itas/.anon/myservices/api/v1/products?limit=2&partNumber=B110627\")",
|
"Bash(curl -s --max-time 15 -H \"X-Oracle-Accept-CurrencyCode: USD\" \"https://itra.oraclecloud.com/itas/.anon/myservices/api/v1/products?limit=2&partNumber=B110627\")",
|
||||||
"Bash(curl -v --max-time 10 -H \"X-Oracle-Accept-CurrencyCode: USD\" \"https://itra.oraclecloud.com/itas/.anon/myservices/api/v1/products?limit=1\")",
|
"Bash(curl -v --max-time 10 -H \"X-Oracle-Accept-CurrencyCode: USD\" \"https://itra.oraclecloud.com/itas/.anon/myservices/api/v1/products?limit=1\")",
|
||||||
"Bash(curl -s --max-time 15 \"https://apexapps.oracle.com/pls/apex/cetools/api/v1/products/?partNumber=B110627¤cyCode=USD\")"
|
"Bash(curl -s --max-time 15 \"https://apexapps.oracle.com/pls/apex/cetools/api/v1/products/?partNumber=B110627¤cyCode=USD\")",
|
||||||
|
"Bash(find:*)",
|
||||||
|
"Bash(echo \"exit=$?\")",
|
||||||
|
"Bash(echo \"quiet exit=$?\")",
|
||||||
|
"Bash(make freshness:*)",
|
||||||
|
"Bash(echo \"freshness exit=$?\")",
|
||||||
|
"Bash(make help:*)",
|
||||||
|
"Bash(make lint:*)",
|
||||||
|
"Bash(curl -sS \"https://apexapps.oracle.com/pls/apex/cetools/api/v1/products/?currencyCode=USD\")",
|
||||||
|
"Bash(/bin/rm kb/pricing/ai-ml.yaml kb/pricing/analytics.yaml kb/pricing/containers-serverless.yaml kb/pricing/database.yaml kb/pricing/developer.yaml kb/pricing/hybrid-vmware.yaml kb/pricing/integration.yaml kb/pricing/networking.yaml kb/pricing/observability.yaml kb/pricing/pricing-models.yaml kb/pricing/security.yaml kb/pricing/storage.yaml)",
|
||||||
|
"Bash(python3.12 tools/oci_deck_gen.py --spec examples/proposal-spec.yaml --output examples/output-cli-improvements-deliverables/architecture-proposal.pptx)",
|
||||||
|
"Bash(python3.12 tools/oci_diagram_gen.py --spec examples/diagram-spec.yaml --output examples/output-cli-improvements-deliverables/architecture.drawio)",
|
||||||
|
"Bash(python3.12 tools/oci_pdf_gen.py --spec examples/proposal-spec.yaml --output examples/output-cli-improvements-deliverables/customer-proposal.pdf)",
|
||||||
|
"Bash(python3.12 tools/oci_bom_gen.py --spec examples/exacs-bom-spec.yaml --output examples/output-cli-improvements-deliverables/exacs-bom.xlsx)",
|
||||||
|
"Bash(python3.12 tools/oci_bom_gen.py --spec examples/exacs-x11m-bom-spec.yaml --output examples/output-cli-improvements-deliverables/exacs-x11m-bom.xlsx)",
|
||||||
|
"Bash(python3.12 tools/kb_cli.py search \"Autonomous Database\" --json)",
|
||||||
|
"Bash(python3.12 tools/findings_cli.py search \"adb\" --product ADB-S --json)",
|
||||||
|
"Bash(python3.12 tools/findings_cli.py stats --json)",
|
||||||
|
"Bash(python3.12 tools/kb_cli.py arch-search --services adb-s exacs --tags ha-dr --json)",
|
||||||
|
"Bash(python3.12 tools/kb_cli.py ecal-score --summary \"ADB-S migration with Data Guard business case architecture review customer profile strategy map handover go-live\" --json)",
|
||||||
|
"Bash(python3.12 tools/oci_bom_gen.py --spec examples/exacs-bom-spec.yaml --output examples/output-cli-improvements-deliverables/exacs-bom-appca.xlsx --appca)",
|
||||||
|
"Bash(python3.12 tools/oci_bom_gen.py --spec examples/exacs-x11m-bom-spec.yaml --output examples/output-cli-improvements-deliverables/exacs-x11m-bom-appca.xlsx --appca)",
|
||||||
|
"Bash(python3.12:*)",
|
||||||
|
"Bash(pip3.12 list:*)",
|
||||||
|
"Bash(pip3.12 install:*)",
|
||||||
|
"Bash(xargs ls:*)",
|
||||||
|
"WebFetch(domain:www.drawio.com)",
|
||||||
|
"WebFetch(domain:jgraph.github.io)",
|
||||||
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
|
"Bash(ls /root/.claude/projects/-mnt-c-Users-Diego-Documents-oci-deal-accelerator-oracle-oci-deal-accelerator/a0d00b4c-8d61-421c-8ba9-19bb85f15fdb/tool-results/*.zip)",
|
||||||
|
"WebFetch(domain:developers.openai.com)",
|
||||||
|
"Bash(echo \"---exit:$?---\")",
|
||||||
|
"Bash(date +%s)",
|
||||||
|
"Read(//tmp/**)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
21
.codex/config.toml
Normal file
21
.codex/config.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Codex project configuration for OCI Deal Accelerator
|
||||||
|
# Applies to every user who opens this project with Codex CLI.
|
||||||
|
|
||||||
|
# Allow the agent to run commands without per-command approval prompts.
|
||||||
|
approval_policy = "never"
|
||||||
|
|
||||||
|
# Workspace-write: agent can write inside the project root but not system-wide.
|
||||||
|
sandbox_mode = "workspace-write"
|
||||||
|
|
||||||
|
[sandbox_workspace_write]
|
||||||
|
# Allow network access so `pip install -r requirements.txt` and `make venv`
|
||||||
|
# can actually download dependencies. Without this, Codex blocks PyPI.
|
||||||
|
network_access = true
|
||||||
|
writable_roots = []
|
||||||
|
exclude_tmpdir_env_var = false
|
||||||
|
exclude_slash_tmp = false
|
||||||
|
|
||||||
|
[shell_environment_policy]
|
||||||
|
# Inherit the user's shell env so python3.12 / python3 resolution works
|
||||||
|
# the same way as the Makefile expects.
|
||||||
|
inherit = "all"
|
||||||
75
.gitea/workflows/kb-health.yaml
Normal file
75
.gitea/workflows/kb-health.yaml
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Weekly KB health check — validates catalog links and reports broken URLs.
|
||||||
|
# Runs Monday 8am UTC. Does NOT block deployments or affect user experience.
|
||||||
|
# If broken links found, creates a Gitea issue for manual review.
|
||||||
|
name: KB Health Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 8 * * 1'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-links:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Python
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y --no-install-recommends python3 python3-requests python3-bs4 python3-yaml
|
||||||
|
python3 --version
|
||||||
|
|
||||||
|
- name: Check Architecture Center links
|
||||||
|
id: check
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
python3 tools/refresh_arch_catalog.py --check-links 2>&1 | tee /tmp/link-check.txt
|
||||||
|
echo "exit_code=$?" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Check SKU catalog freshness
|
||||||
|
id: sku
|
||||||
|
continue-on-error: true
|
||||||
|
run: |
|
||||||
|
python3 tools/refresh_sku_catalog.py --validate 2>&1 | tee -a /tmp/link-check.txt
|
||||||
|
|
||||||
|
- name: Create issue if problems found
|
||||||
|
if: steps.check.outcome == 'failure'
|
||||||
|
run: |
|
||||||
|
BROKEN=$(grep -c "❌" /tmp/link-check.txt || echo "0")
|
||||||
|
REDIRECTED=$(grep -c "↪️" /tmp/link-check.txt || echo "0")
|
||||||
|
BODY=$(cat <<'ISSUE_EOF'
|
||||||
|
## KB Health Check — Broken Links Found
|
||||||
|
|
||||||
|
**Run date:** $(date -u +%Y-%m-%d)
|
||||||
|
**Broken:** $BROKEN URLs
|
||||||
|
**Redirected:** $REDIRECTED URLs
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Full report</summary>
|
||||||
|
|
||||||
|
```
|
||||||
|
$(cat /tmp/link-check.txt)
|
||||||
|
```
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### Action needed
|
||||||
|
- For 404s: remove entry or find new URL via `python tools/refresh_arch_catalog.py --whats-new`
|
||||||
|
- For redirects: update URL in `kb/architecture-center/catalog.yaml`
|
||||||
|
ISSUE_EOF
|
||||||
|
)
|
||||||
|
# Note: Gitea issue creation requires gitea CLI or API call
|
||||||
|
# For now, save report as artifact
|
||||||
|
echo "$BODY" > /tmp/kb-health-report.md
|
||||||
|
echo "::warning::$BROKEN broken links found in KB catalog"
|
||||||
|
|
||||||
|
- name: Upload report
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: kb-health-report
|
||||||
|
path: /tmp/link-check.txt
|
||||||
|
retention-days: 30
|
||||||
146
.gitea/workflows/sku-catalog-refresh.yaml
Normal file
146
.gitea/workflows/sku-catalog-refresh.yaml
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# 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=$(grep -oE '^[0-9]+ SKUs present in API' /tmp/discover.log \
|
||||||
|
| head -1 | awk '{print $1}' || echo "0")
|
||||||
|
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}"
|
||||||
|
|
||||||
|
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
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,9 +1,11 @@
|
|||||||
|
.venv/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*.pyo
|
*.pyo
|
||||||
output/
|
output/
|
||||||
*.pptx
|
*.pptx
|
||||||
!examples/sample-output/*.pptx
|
!examples/sample-output/*.pptx
|
||||||
|
!templates/Oracle_PPT-template_FY26.pptx
|
||||||
|
|
||||||
# OCI Toolkit binary (download from Oracle, don't commit)
|
# OCI Toolkit binary (download from Oracle, don't commit)
|
||||||
kb/diagram/OCI Library.xml
|
kb/diagram/OCI Library.xml
|
||||||
|
|||||||
45
AGENTS.md
45
AGENTS.md
@@ -85,32 +85,39 @@ DEFINE (Ideate → Validate → Plan) → DESIGN (Current → Future → Confi
|
|||||||
|
|
||||||
Phase details in `docs/` — skill references them via progressive disclosure.
|
Phase details in `docs/` — skill references them via progressive disclosure.
|
||||||
|
|
||||||
|
## Environment Setup (MANDATORY before running tools)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make venv && source .venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
The Makefile auto-detects the best available Python (3.12 > 3.11 > 3.10 > python3).
|
||||||
|
**Always use `make <target>` to run tools.** For custom specs, activate the venv first.
|
||||||
|
|
||||||
|
### Codex sandbox note
|
||||||
|
|
||||||
|
This project ships `.codex/config.toml` which enables `network_access = true`
|
||||||
|
for the workspace-write sandbox. This is required so `make venv` can reach
|
||||||
|
PyPI. If you see `No matching distribution found for pyyaml`, the config file
|
||||||
|
is missing or Codex is ignoring it — re-open the project from its root so
|
||||||
|
Codex loads `.codex/config.toml`.
|
||||||
|
|
||||||
## Running Tools
|
## Running Tools
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Generate slide deck (default output)
|
# Standard targets (recommended)
|
||||||
|
make deck # slide deck with sample spec
|
||||||
|
make diagram # architecture diagram
|
||||||
|
make full # all outputs (pptx + drawio + docx + xlsx + pdf)
|
||||||
|
make validate # WA validation
|
||||||
|
make lint # check YAML syntax
|
||||||
|
make venv # create/update virtual environment
|
||||||
|
|
||||||
|
# Custom specs (activate venv first)
|
||||||
python tools/oci_deck_gen.py --spec examples/proposal-spec.yaml --output proposal.pptx
|
python tools/oci_deck_gen.py --spec examples/proposal-spec.yaml --output proposal.pptx
|
||||||
|
|
||||||
# Generate customer-facing PDF (branded, no internal KB refs)
|
|
||||||
python tools/oci_pdf_gen.py --spec examples/proposal-spec.yaml --output proposal.pdf
|
python tools/oci_pdf_gen.py --spec examples/proposal-spec.yaml --output proposal.pdf
|
||||||
|
|
||||||
# Generate diagram
|
|
||||||
python tools/oci_diagram_gen.py --spec examples/diagram-spec.yaml --output arch.drawio
|
python tools/oci_diagram_gen.py --spec examples/diagram-spec.yaml --output arch.drawio
|
||||||
|
|
||||||
# Run WA validation
|
|
||||||
python scripts/validate-architecture.py \
|
|
||||||
--profile examples/sample-workload-profile.yaml \
|
|
||||||
--architecture examples/sample-architecture.yaml \
|
|
||||||
--output scorecard.yaml
|
|
||||||
|
|
||||||
# Output orchestrator (multiple formats at once)
|
|
||||||
python tools/oci_output.py --spec examples/proposal-spec.yaml --format full --output-dir output/
|
python tools/oci_output.py --spec examples/proposal-spec.yaml --format full --output-dir output/
|
||||||
|
|
||||||
# Build automation
|
|
||||||
make help # show all commands
|
|
||||||
make full # generate all outputs
|
|
||||||
make validate # run WA validation
|
|
||||||
make lint # check YAML syntax
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Output Formats
|
## Output Formats
|
||||||
|
|||||||
90
CLAUDE.md
90
CLAUDE.md
@@ -110,47 +110,44 @@ doc only ← technical doc without slides
|
|||||||
deliver ← handover + go-live checklist + success criteria
|
deliver ← handover + go-live checklist + success criteria
|
||||||
```
|
```
|
||||||
|
|
||||||
## Running Tools
|
## Environment Setup
|
||||||
|
|
||||||
|
Before running ANY Python tool, ensure dependencies are installed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Generate slide deck (default output)
|
make venv && source .venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
The Makefile auto-detects the best available Python (3.12 > 3.11 > 3.10 > python3).
|
||||||
|
**Always use `make <target>` to run tools** — this guarantees the correct Python with all dependencies.
|
||||||
|
|
||||||
|
## Running Tools
|
||||||
|
|
||||||
|
Prefer `make` targets. For custom specs, activate the venv first (`source .venv/bin/activate`), then use `python`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Standard targets (recommended)
|
||||||
|
make deck # slide deck with sample spec
|
||||||
|
make diagram # architecture diagram
|
||||||
|
make full # all outputs (pptx + drawio + docx + xlsx + pdf)
|
||||||
|
make validate # WA validation
|
||||||
|
make lint # check YAML syntax
|
||||||
|
make venv # create/update virtual environment
|
||||||
|
make freshness # check KB freshness
|
||||||
|
|
||||||
|
# Custom specs (activate venv first)
|
||||||
python tools/oci_deck_gen.py --spec examples/proposal-spec.yaml --output proposal.pptx
|
python tools/oci_deck_gen.py --spec examples/proposal-spec.yaml --output proposal.pptx
|
||||||
|
|
||||||
# Generate customer-facing PDF (branded, no internal KB refs)
|
|
||||||
python tools/oci_pdf_gen.py --spec examples/proposal-spec.yaml --output proposal.pdf
|
python tools/oci_pdf_gen.py --spec examples/proposal-spec.yaml --output proposal.pdf
|
||||||
python tools/oci_pdf_gen.py --spec examples/proposal-spec.yaml --output proposal.pdf --diagram arch.png
|
|
||||||
|
|
||||||
# Generate BOM (.xlsx Bill of Materials)
|
|
||||||
python tools/oci_bom_gen.py --spec examples/bom-spec.yaml --output customer-bom.xlsx
|
python tools/oci_bom_gen.py --spec examples/bom-spec.yaml --output customer-bom.xlsx
|
||||||
|
|
||||||
# Generate diagram
|
|
||||||
python tools/oci_diagram_gen.py --spec examples/diagram-spec.yaml --output arch.drawio
|
python tools/oci_diagram_gen.py --spec examples/diagram-spec.yaml --output arch.drawio
|
||||||
|
|
||||||
# Run WA validation
|
|
||||||
python scripts/validate-architecture.py \
|
|
||||||
--profile examples/sample-workload-profile.yaml \
|
|
||||||
--architecture examples/sample-architecture.yaml \
|
|
||||||
--output scorecard.yaml
|
|
||||||
|
|
||||||
# Output orchestrator (multiple formats at once)
|
|
||||||
python tools/oci_output.py --spec examples/proposal-spec.yaml --format full --output-dir output/
|
python tools/oci_output.py --spec examples/proposal-spec.yaml --format full --output-dir output/
|
||||||
|
|
||||||
# Refresh SKU catalog from Oracle pricing API
|
# KB maintenance (activate venv first)
|
||||||
python tools/refresh_sku_catalog.py --refresh # update all prices
|
python tools/refresh_sku_catalog.py --refresh # update all prices
|
||||||
python tools/refresh_sku_catalog.py --refresh --diff # update + show changes
|
python tools/refresh_sku_catalog.py --refresh --diff # update + show changes
|
||||||
python tools/refresh_sku_catalog.py --validate # check for stale prices
|
python tools/refresh_sku_catalog.py --validate # check for stale prices
|
||||||
python tools/refresh_sku_catalog.py --sku B110627 # inspect single SKU
|
python tools/refresh_arch_catalog.py --whats-new # crawl What's New pages
|
||||||
|
|
||||||
# Refresh Architecture Center catalog
|
|
||||||
python tools/refresh_arch_catalog.py --whats-new # crawl What's New pages
|
|
||||||
python tools/refresh_arch_catalog.py --url <url> # add single entry
|
|
||||||
python tools/refresh_arch_catalog.py --validate # validate catalog
|
python tools/refresh_arch_catalog.py --validate # validate catalog
|
||||||
|
|
||||||
# Build automation
|
|
||||||
make help # show all commands
|
|
||||||
make full # generate all outputs
|
|
||||||
make validate # run WA validation
|
|
||||||
make lint # check YAML syntax
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Principles
|
## Key Principles
|
||||||
@@ -162,13 +159,46 @@ make lint # check YAML syntax
|
|||||||
- **KB is the moat** — field experience, not documentation regurgitation
|
- **KB is the moat** — field experience, not documentation regurgitation
|
||||||
- **ECAL-aligned** — Define → Design → Deliver with iterative checkpoints
|
- **ECAL-aligned** — Define → Design → Deliver with iterative checkpoints
|
||||||
|
|
||||||
|
## Coding Guidelines
|
||||||
|
|
||||||
|
### 1. Think Before Coding
|
||||||
|
Don't assume. Don't hide confusion. Surface tradeoffs.
|
||||||
|
- State your assumptions explicitly. If uncertain, ask.
|
||||||
|
- If multiple interpretations exist, present them — don't pick silently.
|
||||||
|
- If a simpler approach exists, say so. Push back when warranted.
|
||||||
|
- If something is unclear, stop. Name what's confusing. Ask.
|
||||||
|
|
||||||
|
### 2. Simplicity First
|
||||||
|
Minimum code that solves the problem. Nothing speculative.
|
||||||
|
- No features beyond what was asked.
|
||||||
|
- No abstractions for single-use code.
|
||||||
|
- No "flexibility" or "configurability" that wasn't requested.
|
||||||
|
- No error handling for impossible scenarios.
|
||||||
|
- If you write 200 lines and it could be 50, rewrite it.
|
||||||
|
|
||||||
|
### 3. Surgical Changes
|
||||||
|
Touch only what you must. Clean up only your own mess.
|
||||||
|
- Don't "improve" adjacent code, comments, or formatting.
|
||||||
|
- Don't refactor things that aren't broken.
|
||||||
|
- Match existing style, even if you'd do it differently.
|
||||||
|
- If you notice unrelated dead code, mention it — don't delete it.
|
||||||
|
- Remove imports/variables/functions that YOUR changes made unused.
|
||||||
|
- Every changed line should trace directly to the user's request.
|
||||||
|
|
||||||
|
### 4. Goal-Driven Execution
|
||||||
|
Define success criteria. Loop until verified.
|
||||||
|
- "Add validation" → write tests for invalid inputs, then make them pass.
|
||||||
|
- "Fix the bug" → write a test that reproduces it, then make it pass.
|
||||||
|
- "Refactor X" → ensure tests pass before and after.
|
||||||
|
- For multi-step tasks, state a brief plan with verification steps.
|
||||||
|
|
||||||
## Welcome Flow
|
## Welcome Flow
|
||||||
|
|
||||||
When the user starts a conversation without providing discovery notes or a specific request (e.g., a greeting like "hola", "hey", or empty context):
|
When the user starts a conversation without providing discovery notes or a specific request (e.g., a greeting like "hola", "hey", or empty context):
|
||||||
|
|
||||||
**MANDATORY:** Use the `Read` tool to open `SKILL.md` and read the entire `## Welcome Flow` section **before** showing anything to the user. Reproduce the welcome banner and the 14-option capability menu **verbatim** from that file. Do NOT paraphrase, reorder, summarize, translate, or reconstruct the menu from memory, folder structure, or prior conversations. If `SKILL.md` cannot be read for any reason, tell the user instead of improvising a menu.
|
**MANDATORY:** Use the `Read` tool to open `SKILL.md` and read the entire `## Welcome Flow` section **before** showing anything to the user. Reproduce the welcome banner and the 14-option capability menu **verbatim** from that file. Do NOT paraphrase, reorder, summarize, translate, or reconstruct the menu from memory, folder structure, or prior conversations. If `SKILL.md` cannot be read for any reason, tell the user instead of improvising a menu.
|
||||||
|
|
||||||
**Pre-flight check (also defined in SKILL.md):** Before showing the welcome message, run `python tools/kb_freshness.py --check --json`. If `stale_count > 0`, follow the banner-and-prompt logic in SKILL.md § Welcome Flow → Pre-flight. If the tool errors, silently skip the banner — never block the user.
|
**Pre-flight check (also defined in SKILL.md):** Before showing the welcome message, run `make kb-check 2>/dev/null`. If `stale_count > 0`, follow the banner-and-prompt logic in SKILL.md § Welcome Flow → Pre-flight. If the tool errors, silently skip the banner — never block the user.
|
||||||
|
|
||||||
Then:
|
Then:
|
||||||
|
|
||||||
|
|||||||
28
Makefile
28
Makefile
@@ -1,8 +1,15 @@
|
|||||||
# 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
|
||||||
|
|
||||||
PYTHON ?= python3.12
|
# Use venv if present, otherwise find best available python3
|
||||||
|
ifneq (,$(wildcard .venv/bin/python))
|
||||||
|
PYTHON ?= .venv/bin/python
|
||||||
|
else ifneq (,$(shell command -v python3.12 2>/dev/null))
|
||||||
|
PYTHON ?= python3.12
|
||||||
|
else
|
||||||
|
PYTHON ?= python3
|
||||||
|
endif
|
||||||
SPEC_DIR = examples
|
SPEC_DIR = examples
|
||||||
OUTPUT_DIR = examples/sample-output
|
OUTPUT_DIR = examples/sample-output
|
||||||
|
|
||||||
@@ -10,7 +17,16 @@ help: ## Show this help
|
|||||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
|
||||||
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
install: ## Install Python dependencies
|
VENV_PYTHON := $(shell command -v python3.12 2>/dev/null || command -v python3.11 2>/dev/null || command -v python3.10 2>/dev/null || echo python3)
|
||||||
|
|
||||||
|
venv: ## Create virtual environment and install dependencies
|
||||||
|
@echo "Using $(VENV_PYTHON) to create venv..."
|
||||||
|
$(VENV_PYTHON) -m venv .venv
|
||||||
|
.venv/bin/pip install --upgrade pip
|
||||||
|
.venv/bin/pip install -r requirements.txt
|
||||||
|
@echo "Virtual environment ready. Run: source .venv/bin/activate"
|
||||||
|
|
||||||
|
install: ## Install Python dependencies (system-wide)
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
validate: ## Validate imports and configs
|
validate: ## Validate imports and configs
|
||||||
@@ -76,5 +92,11 @@ sync-skill: ## Regenerate .agents/skills/oci-deal-accelerator/SKILL.md from root
|
|||||||
freshness: ## Report stale KB files (informational, never fails)
|
freshness: ## Report stale KB files (informational, never fails)
|
||||||
-@$(PYTHON) tools/kb_freshness.py --check
|
-@$(PYTHON) tools/kb_freshness.py --check
|
||||||
|
|
||||||
|
kb-check: ## KB freshness JSON (used by skill welcome-flow pre-flight)
|
||||||
|
@$(PYTHON) tools/kb_freshness.py --check --json
|
||||||
|
|
||||||
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
|
||||||
|
|||||||
66
README.md
66
README.md
@@ -14,6 +14,40 @@ What normally takes an SA days of work (structuring notes, designing architectur
|
|||||||
- **Multi-cloud aware** — supports hybrid/multi-cloud diagrams (AWS, Azure, GCP icons) and considers options like ADB Multicloud before recommending full migration
|
- **Multi-cloud aware** — supports hybrid/multi-cloud diagrams (AWS, Azure, GCP icons) and considers options like ADB Multicloud before recommending full migration
|
||||||
- **End-to-end coverage** — from discovery notes to go-live checklist, not just the architecture slide
|
- **End-to-end coverage** — from discovery notes to go-live checklist, not just the architecture slide
|
||||||
|
|
||||||
|
## New user? Start here
|
||||||
|
|
||||||
|
The skill runs as a hosted MCP server. Connect once from your LLM client, sign in with Oracle SSO, and the tools are ready.
|
||||||
|
|
||||||
|
**1. Add the MCP server to your client:**
|
||||||
|
|
||||||
|
| Client | How |
|
||||||
|
|---|---|
|
||||||
|
| **Claude Code** | `claude mcp add --transport http oci-deal-accelerator "https://mcp.tech-lad.com/deal-accelerator/mcp/"` |
|
||||||
|
| **Codex** | `codex mcp add oci-deal-accelerator --url "https://mcp.tech-lad.com/deal-accelerator/mcp/"` |
|
||||||
|
| **Cursor** | Settings (Ctrl+Shift+J) → MCP → Add new MCP server → Name: `oci-deal-accelerator`, Type: `streamable-http`, URL: `https://mcp.tech-lad.com/deal-accelerator/mcp/` |
|
||||||
|
| **Claude Desktop** | Add to `claude_desktop_config.json`: `{ "mcpServers": { "oci-deal-accelerator": { "transport": "http", "url": "https://mcp.tech-lad.com/deal-accelerator/mcp/" } } }` |
|
||||||
|
| **Windsurf** | Settings → Cascade → MCP → Manual config: `{ "oci-deal-accelerator": { "serverUrl": "https://mcp.tech-lad.com/deal-accelerator/sse/" } }` |
|
||||||
|
|
||||||
|
**2. Trigger the connection** — type `/mcp` in Claude Code or Codex, or open any tool in Cursor / Claude Desktop / Windsurf. Your browser opens automatically.
|
||||||
|
|
||||||
|
**3. Pick one of two options in the browser:**
|
||||||
|
- **Sign in with Oracle SSO** — for existing users
|
||||||
|
- **Create a new account** — for new users (see step 4)
|
||||||
|
|
||||||
|
**4. New users — create an account** by clicking "Create a new account". This opens an OCI self-registration form (oracle.com emails only). Tips:
|
||||||
|
- Use your full Oracle email as User ID (e.g. `name@oracle.com`)
|
||||||
|
- Password must be 12+ chars, with an uppercase letter, a number, and a special character
|
||||||
|
- Password must NOT contain your name or username
|
||||||
|
|
||||||
|
**5. Verify your email**, then return to the browser and click **Sign in with Oracle SSO**.
|
||||||
|
|
||||||
|
**6. Done.** The MCP tools are now available in your client.
|
||||||
|
|
||||||
|
### Connection URLs
|
||||||
|
|
||||||
|
- **MCP (HTTP streamable):** `https://mcp.tech-lad.com/deal-accelerator/mcp/`
|
||||||
|
- **SSE (Windsurf only):** `https://mcp.tech-lad.com/deal-accelerator/sse/`
|
||||||
|
|
||||||
## What It Produces
|
## What It Produces
|
||||||
|
|
||||||
From unstructured input (meeting notes, emails, Slack threads), the skill generates:
|
From unstructured input (meeting notes, emails, Slack threads), the skill generates:
|
||||||
@@ -131,6 +165,34 @@ During **Phase 2 (DESIGN)**, the skill automatically matches the proposed archit
|
|||||||
python tools/refresh_arch_catalog.py --whats-new # crawl What's New pages
|
python tools/refresh_arch_catalog.py --whats-new # crawl What's New pages
|
||||||
python tools/refresh_arch_catalog.py --url <url> # add a single entry
|
python tools/refresh_arch_catalog.py --url <url> # add a single entry
|
||||||
python tools/refresh_arch_catalog.py --validate # validate catalog integrity
|
python tools/refresh_arch_catalog.py --validate # validate catalog integrity
|
||||||
|
python tools/refresh_arch_catalog.py --check-links # check for broken URLs (404s, redirects)
|
||||||
|
```
|
||||||
|
|
||||||
|
### KB Health & Freshness
|
||||||
|
|
||||||
|
The KB is automatically monitored for staleness and broken links:
|
||||||
|
|
||||||
|
| Check | How | When |
|
||||||
|
|---|---|---|
|
||||||
|
| **Broken links** | `python tools/refresh_arch_catalog.py --check-links` | Weekly (CI), or on demand |
|
||||||
|
| **Stale prices** | `python tools/refresh_sku_catalog.py --validate` | Weekly (CI), or on demand |
|
||||||
|
| **KB freshness** | `python tools/kb_freshness.py --check` | On skill startup (pre-flight) |
|
||||||
|
| **Diagram quality** | `python scripts/validate-diagram.py <file.drawio>` | After every diagram generation |
|
||||||
|
|
||||||
|
**Automated CI/CD:**
|
||||||
|
- **Deploy workflow** (`.gitea/workflows/deploy.yaml`) — auto-deploys to MCP server on every push to `main`
|
||||||
|
- **KB health workflow** (`.gitea/workflows/kb-health.yaml`) — runs every Monday 8am UTC, checks all catalog URLs and SKU freshness, reports broken links as artifacts
|
||||||
|
|
||||||
|
To run KB health manually:
|
||||||
|
```bash
|
||||||
|
# Check all 123 Architecture Center URLs for 404s
|
||||||
|
python tools/refresh_arch_catalog.py --check-links
|
||||||
|
|
||||||
|
# Fix broken links: re-crawl What's New for updated URLs
|
||||||
|
python tools/refresh_arch_catalog.py --whats-new
|
||||||
|
|
||||||
|
# Refresh stale pricing from Oracle API
|
||||||
|
python tools/refresh_sku_catalog.py --refresh --diff
|
||||||
```
|
```
|
||||||
|
|
||||||
### Feature Compatibility Matrix
|
### Feature Compatibility Matrix
|
||||||
@@ -296,12 +358,16 @@ python tools/oci_bizcase_gen.py --spec business-case.yaml --output business-case
|
|||||||
# Architecture diagram
|
# Architecture diagram
|
||||||
python tools/oci_diagram_gen.py --spec examples/diagram-spec.yaml --output arch.drawio
|
python tools/oci_diagram_gen.py --spec examples/diagram-spec.yaml --output arch.drawio
|
||||||
|
|
||||||
|
# Validate diagram quality (icon sizes, overlaps, container overflow)
|
||||||
|
python scripts/validate-diagram.py arch.drawio
|
||||||
|
|
||||||
# Output orchestrator (multiple formats at once)
|
# Output orchestrator (multiple formats at once)
|
||||||
python tools/oci_output.py --spec examples/proposal-spec.yaml --format full --output-dir output/
|
python tools/oci_output.py --spec examples/proposal-spec.yaml --format full --output-dir output/
|
||||||
|
|
||||||
# Architecture Center catalog
|
# Architecture Center catalog
|
||||||
python tools/refresh_arch_catalog.py --validate
|
python tools/refresh_arch_catalog.py --validate
|
||||||
python tools/refresh_arch_catalog.py --whats-new
|
python tools/refresh_arch_catalog.py --whats-new
|
||||||
|
python tools/refresh_arch_catalog.py --check-links
|
||||||
|
|
||||||
# Feature compatibility
|
# Feature compatibility
|
||||||
python tools/feature_matrix_cli.py check "Auto Scaling" adb_s 23ai
|
python tools/feature_matrix_cli.py check "Auto Scaling" adb_s 23ai
|
||||||
|
|||||||
56
SKILL.md
56
SKILL.md
@@ -15,32 +15,54 @@ You follow the **Oracle ECAL framework** (Define → Design → Deliver) to prod
|
|||||||
|
|
||||||
When the user starts a conversation without providing discovery notes or a specific request, present the welcome message and capability menu.
|
When the user starts a conversation without providing discovery notes or a specific request, present the welcome message and capability menu.
|
||||||
|
|
||||||
### Pre-flight: KB freshness check
|
### Pre-flight checks
|
||||||
|
|
||||||
**Before showing the welcome message**, run `python tools/kb_freshness.py --check --json` and parse the JSON output. Behavior based on the result:
|
Run these checks silently **before** showing the welcome message. **CRITICAL:** Never show command execution, tool output, or errors to the user. If any check fails, silently skip it and proceed to the welcome message. These checks are informational — they NEVER block the user.
|
||||||
|
|
||||||
- **`stale_count == 0`** → proceed directly to the welcome message. No banner.
|
#### Check 1: KB changelog banner
|
||||||
- **`stale_count > 0` and at least one file has `refreshable: true`** → prepend this banner above the menu and ask the user inline:
|
|
||||||
|
Read the file `kb/CHANGELOG.md` and extract the **most recent date and first bullet point**. If the file exists and has entries, prepend a one-line banner above the welcome message:
|
||||||
|
|
||||||
|
```
|
||||||
|
📢 KB updated (<date>): <first bullet summary>
|
||||||
|
```
|
||||||
|
|
||||||
|
Example: `📢 KB updated (Apr 14): Diagram generator calibrated from 37 Oracle ref architectures`
|
||||||
|
|
||||||
|
If the file is missing or empty, skip — no banner.
|
||||||
|
|
||||||
|
#### Check 2: Local repo updates (git users only)
|
||||||
|
|
||||||
|
Run `git fetch --dry-run origin main 2>/dev/null`. If the output contains any text (meaning there are remote commits not yet pulled), prepend this banner:
|
||||||
|
|
||||||
|
```
|
||||||
|
📢 KB updates available — run `git pull` to get latest prices and fixes.
|
||||||
|
```
|
||||||
|
|
||||||
|
If the command fails (not a git repo, no network, MCP deployment), silently skip. This check is only relevant for users running the skill from a local git clone.
|
||||||
|
|
||||||
|
#### Check 3: KB freshness
|
||||||
|
|
||||||
|
Run `make kb-check 2>/dev/null` and parse the JSON output. Behavior:
|
||||||
|
|
||||||
|
- **`stale_count == 0`** → no banner.
|
||||||
|
- **`stale_count > 0` and at least one file has `refreshable: true`** → prepend banner and ask inline:
|
||||||
|
|
||||||
```
|
```
|
||||||
⚠️ KB freshness: <N> file(s) outdated (oldest: <file> — <age_days>d).
|
⚠️ KB freshness: <N> file(s) outdated (oldest: <file> — <age_days>d).
|
||||||
<M> can be auto-refreshed (SKU catalog, Architecture Center).
|
<M> can be auto-refreshed. Refresh now? [y/N]
|
||||||
Refresh now before showing the menu? [y/N]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- If the user replies `y` / `yes` / `sí` → run `python tools/kb_freshness.py --auto-refresh`, wait for completion, then show the menu.
|
- `y` / `yes` / `sí` → run `make freshness-refresh`, then show menu.
|
||||||
- If the user replies `n` / anything else → show the menu immediately, with a one-line compact reminder above it: `⚠️ <N> KB file(s) stale — run /freshness or python tools/kb_freshness.py --auto-refresh later.`
|
- Anything else → show menu with one-line reminder: `⚠️ <N> KB file(s) stale — run make freshness-refresh later.`
|
||||||
|
|
||||||
- **`stale_count > 0` but no file has `refreshable: true`** (only manual files stale) → prepend a non-blocking informational banner above the menu, do NOT ask:
|
- **`stale_count > 0` but no `refreshable: true`** → non-blocking info banner, then show menu directly:
|
||||||
|
|
||||||
```
|
```
|
||||||
⚠️ KB freshness: <N> file(s) need manual review (oldest: <file> — <age_days>d).
|
⚠️ KB freshness: <N> file(s) need manual review (oldest: <file> — <age_days>d).
|
||||||
No automated refresh available — see kb/README.md for review process.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then show the menu directly.
|
If `make kb-check` errors out (exit ≠ 0, missing make, missing Python, missing tool), **silently skip** — no error output, no banner, no mention of failure.
|
||||||
|
|
||||||
**Important**: this check is informational, not gating. If `kb_freshness.py` errors out (exit 2 or missing tool), silently fall back to showing the welcome message — never block the user on tooling failures.
|
|
||||||
|
|
||||||
### Welcome Message
|
### Welcome Message
|
||||||
|
|
||||||
@@ -410,6 +432,14 @@ See [docs/skill/output-formats.md](docs/skill/output-formats.md) for the per-cus
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Cookbook: Building Tool Payloads
|
||||||
|
|
||||||
|
Before mapping customer requirements to SKUs for `generate_bom`, `generate_bom_appca`, or `generate_cost_estimate`, check [docs/bom-cookbook.md](docs/bom-cookbook.md) — it has copy-paste recipes for common patterns (ExaCS X11M BYOL, ADB-Dedicated, ADB-S + Block + FastConnect) and explicitly names the gotchas that otherwise burn exploration turns (e.g. ADB-Dedicated shares infrastructure SKUs with ExaCS — there are no separate `adb_dedicated` SKUs in the catalog).
|
||||||
|
|
||||||
|
If a requirement does not match a recipe, `grep` [`kb/pricing/oci-sku-catalog.yaml`](kb/pricing/oci-sku-catalog.yaml) and consult `kb/services/` before inventing SKUs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Knowledge Base
|
## Knowledge Base
|
||||||
|
|
||||||
KB lives under `kb/`. See [kb/README.md](kb/README.md) for the directory map, frontmatter requirements, refresh tooling, and contributor guide.
|
KB lives under `kb/`. See [kb/README.md](kb/README.md) for the directory map, frontmatter requirements, refresh tooling, and contributor guide.
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
# Navigate to the project root
|
# Navigate to the project root
|
||||||
cd oci-deal-accelerator
|
cd oci-deal-accelerator
|
||||||
|
|
||||||
# Run Codex — it auto-discovers AGENTS.md and .agents/skills/
|
# One-time: create virtual environment with all dependencies
|
||||||
|
make venv
|
||||||
|
|
||||||
|
# Run Codex — it auto-discovers AGENTS.md, .agents/skills/, and .codex/config.toml
|
||||||
codex
|
codex
|
||||||
|
|
||||||
# Or explicitly load the skill
|
# Or explicitly load the skill
|
||||||
@@ -18,6 +21,18 @@ codex --skill oci-deal-accelerator
|
|||||||
Codex automatically reads:
|
Codex automatically reads:
|
||||||
- `AGENTS.md` at project root (project-level instructions)
|
- `AGENTS.md` at project root (project-level instructions)
|
||||||
- `.agents/skills/oci-deal-accelerator/SKILL.md` (the skill definition)
|
- `.agents/skills/oci-deal-accelerator/SKILL.md` (the skill definition)
|
||||||
|
- `.codex/config.toml` (project sandbox + approval config — **enables network for pip**)
|
||||||
|
|
||||||
|
### Why `.codex/config.toml` matters
|
||||||
|
|
||||||
|
By default Codex CLI blocks network access and prompts before every command.
|
||||||
|
The committed `.codex/config.toml` pre-configures this project with:
|
||||||
|
|
||||||
|
- `approval_policy = "never"` — no per-command prompts
|
||||||
|
- `sandbox_mode = "workspace-write"` — agent can write in the repo
|
||||||
|
- `network_access = true` — `pip install` and `make venv` actually work
|
||||||
|
|
||||||
|
Without this file, `make venv` fails with `No matching distribution found for pyyaml` because PyPI is unreachable from the sandbox.
|
||||||
|
|
||||||
### Option 2: Using Codex App
|
### Option 2: Using Codex App
|
||||||
|
|
||||||
|
|||||||
190
docs/bom-cookbook.md
Normal file
190
docs/bom-cookbook.md
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
# BOM Cookbook — Building `generate_bom` Payloads
|
||||||
|
|
||||||
|
Copy-paste recipes that map common customer requirements to the exact OCI SKUs the
|
||||||
|
`generate_bom`, `generate_bom_appca`, and `generate_cost_estimate` MCP tools expect.
|
||||||
|
The goal is to skip the "grep the catalog and guess SKUs" exploration loop that
|
||||||
|
burns tool calls on well-known patterns.
|
||||||
|
|
||||||
|
> **If the requirement does not match any recipe below, consult
|
||||||
|
> [`kb/services/`](../kb/services/) and
|
||||||
|
> [`kb/pricing/oci-sku-catalog.yaml`](../kb/pricing/oci-sku-catalog.yaml)
|
||||||
|
> before inventing SKUs.** All SKUs below are verified against the catalog as of
|
||||||
|
> 2026-04-20; re-`grep` the catalog if a recipe ever fails.
|
||||||
|
|
||||||
|
## Payload Shape
|
||||||
|
|
||||||
|
All three tools accept the same envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"customer_id": "acme-prod",
|
||||||
|
"discount_pct": 0.35,
|
||||||
|
"currency": "USD",
|
||||||
|
"services": [
|
||||||
|
{ "sku": "B90777", "quantity": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `customer_id`: free-form identifier, used for output file naming.
|
||||||
|
- `discount_pct`: decimal (0.35 = 35%). Applied to every line unless overridden.
|
||||||
|
- `services[].quantity`: integer or float depending on the SKU metric (ECPUs, GB, hours).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recipe 1 — Exadata Cloud Service (ExaCS) X11M, BYOL
|
||||||
|
|
||||||
|
**When it matches:** "ExaCS X11M BYOL, N ECPUs, M TB storage, no DR" — customer-managed
|
||||||
|
Oracle Database on dedicated Exadata hardware, BYOL licensing.
|
||||||
|
|
||||||
|
**SKUs:**
|
||||||
|
|
||||||
|
| SKU | Component | Typical qty |
|
||||||
|
|-----|-----------|-------------|
|
||||||
|
| `B90777` | Exadata Infrastructure — Base System | `1` per rack |
|
||||||
|
| `B110627` | Exadata Database Server — X11M | `2` (Base/Quarter) — adjust per rack config |
|
||||||
|
| `B110629` | Exadata Storage Server — X11M | `3` (Base/Quarter) — adjust per rack config |
|
||||||
|
| `B110632` | Exadata Database ECPU — Dedicated Infra BYOL (X11M) | One per ECPU |
|
||||||
|
|
||||||
|
Swap `B110632` → `B110631` for PAYG (license-included) pricing.
|
||||||
|
|
||||||
|
**Payload (120 ECPUs, 120 TB, BYOL, standard Base System):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"customer_id": "acme-exacs",
|
||||||
|
"discount_pct": 0.35,
|
||||||
|
"currency": "USD",
|
||||||
|
"services": [
|
||||||
|
{ "sku": "B90777", "quantity": 1 },
|
||||||
|
{ "sku": "B110627", "quantity": 2 },
|
||||||
|
{ "sku": "B110629", "quantity": 3 },
|
||||||
|
{ "sku": "B110632", "quantity": 120 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recipe 2 — Autonomous Database Dedicated (ADB-D), BYOL
|
||||||
|
|
||||||
|
**When it matches:** "ADB Dedicated, N ECPUs, M TB, BYOL" — Autonomous Database running
|
||||||
|
on dedicated Exadata infrastructure.
|
||||||
|
|
||||||
|
**⚠️ Gotcha that burns tool calls:** ADB-Dedicated has **no separate infrastructure
|
||||||
|
SKUs** in the catalog. It reuses the same Exadata dedicated infra SKUs as ExaCS
|
||||||
|
(`B90777`, `B110627`, `B110629`) plus the same Exadata ECPU SKU (`B110631`/`B110632`).
|
||||||
|
The Autonomous-vs-customer-managed distinction is a tenancy-level configuration,
|
||||||
|
not a billing line item. Do not search for `autonomous_dedicated` SKUs — they do
|
||||||
|
not exist.
|
||||||
|
|
||||||
|
**SKUs:** identical to Recipe 1.
|
||||||
|
|
||||||
|
**Payload (64 ECPUs, BYOL):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"customer_id": "acme-adbd",
|
||||||
|
"discount_pct": 0.35,
|
||||||
|
"currency": "USD",
|
||||||
|
"services": [
|
||||||
|
{ "sku": "B90777", "quantity": 1 },
|
||||||
|
{ "sku": "B110627", "quantity": 2 },
|
||||||
|
{ "sku": "B110629", "quantity": 3 },
|
||||||
|
{ "sku": "B110632", "quantity": 64 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recipe 3 — Autonomous Database Serverless (ADB-S) + Block Volume + FastConnect
|
||||||
|
|
||||||
|
**When it matches:** "ADB-S ATP, N ECPUs, M TB data + block storage for app tier +
|
||||||
|
FastConnect to on-prem" — the typical "app on OCI Compute, DB on ADB-S" pattern.
|
||||||
|
|
||||||
|
**SKUs:**
|
||||||
|
|
||||||
|
| SKU | Component | Metric |
|
||||||
|
|-----|-----------|--------|
|
||||||
|
| `B95701` | ADB ECPU (ATP or ADW) — license-included | ECPU/hour |
|
||||||
|
| `B95703` | ADB ECPU BYOL — use instead of B95701 if BYOL | ECPU/hour |
|
||||||
|
| `B95706` | ADB ATP data storage | GB/month |
|
||||||
|
| `B95754` | ADB ADW data storage — use instead of B95706 for warehouse | GB/month |
|
||||||
|
| `B95754a` | ADB backup storage | GB/month |
|
||||||
|
| `B91961` | Block Volume storage (app tier) | GB/month |
|
||||||
|
| `B91962` | Block Volume performance (VPU) — optional, for higher IOPS tiers | VPU/GB/month |
|
||||||
|
| `B88326` | FastConnect 10 Gbps port | port-hour |
|
||||||
|
|
||||||
|
Swap `B88326` → `B88325` (1 Gbps) or `B93126` (100 Gbps) as needed.
|
||||||
|
|
||||||
|
**Payload (16 ECPUs ATP license-included, 2 TB data + 1 TB backup, 500 GB block, 10 G FastConnect):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"customer_id": "acme-adbs",
|
||||||
|
"discount_pct": 0.35,
|
||||||
|
"currency": "USD",
|
||||||
|
"services": [
|
||||||
|
{ "sku": "B95701", "quantity": 16 },
|
||||||
|
{ "sku": "B95706", "quantity": 2048 },
|
||||||
|
{ "sku": "B95754a", "quantity": 1024 },
|
||||||
|
{ "sku": "B91961", "quantity": 500 },
|
||||||
|
{ "sku": "B88326", "quantity": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recipe 4 — Big Data Service + Data Science + Data Flow (analytics stack)
|
||||||
|
|
||||||
|
**When it matches:** "HDFS-managed cluster for ETL", "notebooks + model deployment",
|
||||||
|
"managed Spark pipelines" — typical OCI analytics/AI workload mix.
|
||||||
|
|
||||||
|
**SKUs:**
|
||||||
|
|
||||||
|
| SKU | Component | Metric |
|
||||||
|
|-----|-----------|--------|
|
||||||
|
| `B91128` | Big Data Service — Compute Standard (real) | OCPU/hour |
|
||||||
|
| `B91129` | Big Data Service — Compute Dense I/O (real) | OCPU/hour |
|
||||||
|
| `B91130` | Big Data Service — Compute HPC (real) | OCPU/hour |
|
||||||
|
| `B93555` | Big Data Service — Core (real) | OCPU/hour |
|
||||||
|
| `EST-DS-NOTEBOOK` | **[Estimate]** Data Science notebook session — priced as underlying VM.Standard OCPU | OCPU/hour |
|
||||||
|
| `EST-DS-MODEL` | **[Estimate]** Data Science model-deployment endpoint — priced as underlying VM.Standard OCPU | OCPU/hour |
|
||||||
|
| `EST-DF-SPARK` | **[Estimate]** Data Flow managed Spark runtime — priced as underlying VM.Standard OCPU | OCPU/hour |
|
||||||
|
|
||||||
|
OCI Data Science and OCI Data Flow have no dedicated SKUs in the Oracle public
|
||||||
|
pricing API — both are billed via the underlying Compute shape + Block/Object
|
||||||
|
Storage consumed. The `EST-*` entries are placeholder estimates (VM.Standard3.Flex
|
||||||
|
OCPU rate) so BOMs can include them as line items with an explicit "estimate"
|
||||||
|
flag; confirm the actual shape and hours with the customer before quoting.
|
||||||
|
|
||||||
|
**Payload (16 BDS Standard + 8 BDS Dense I/O + 4 notebook + 2 model + 8 Spark):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"customer_id": "acme-analytics",
|
||||||
|
"discount_pct": 0.30,
|
||||||
|
"currency": "USD",
|
||||||
|
"services": [
|
||||||
|
{ "sku": "B91128", "quantity": 16 },
|
||||||
|
{ "sku": "B91129", "quantity": 8 },
|
||||||
|
{ "sku": "EST-DS-NOTEBOOK", "quantity": 4 },
|
||||||
|
{ "sku": "EST-DS-MODEL", "quantity": 2 },
|
||||||
|
{ "sku": "EST-DF-SPARK", "quantity": 8 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `B91961` (Block Volume) and `B91628` (Object Storage Standard) when the
|
||||||
|
customer needs dedicated storage beyond the compute default.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## When a recipe doesn't fit
|
||||||
|
|
||||||
|
1. Grep the catalog: `grep -n "<product keyword>" kb/pricing/oci-sku-catalog.yaml`
|
||||||
|
2. Check service docs under `kb/services/` for deployment-specific guidance.
|
||||||
|
3. Check `kb/field-knowledge/` for known sizing/pricing gotchas.
|
||||||
|
4. If a SKU still cannot be located, surface the gap to the user rather than guessing — fabricated SKUs break the BOM silently.
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
# OCI Deal Accelerator — BOM Spec
|
|
||||||
# Demo: PharmaCorp Mexico — ExaCS X11M + networking + security
|
|
||||||
# SKUs verified against kb/pricing/oci-sku-catalog.yaml
|
|
||||||
|
|
||||||
metadata:
|
|
||||||
customer: "PharmaCorp Mexico"
|
|
||||||
prepared_by: "Carlos Mendoza — Solutions Architect"
|
|
||||||
date: "2026-04-12"
|
|
||||||
currency: "USD"
|
|
||||||
contract_months: 12
|
|
||||||
discount_pct: 0.45
|
|
||||||
notes: "ExaCS BYOL — customer has existing Oracle DB EE licenses with active support"
|
|
||||||
|
|
||||||
line_items:
|
|
||||||
# ExaCS X11M — DB Servers (Queretaro Production)
|
|
||||||
- category: "Exadata Cloud Infrastructure"
|
|
||||||
product: "ExaCS X11M DB Server"
|
|
||||||
sku: "B110627"
|
|
||||||
qty: 2
|
|
||||||
unit: "Hosted Environment Per Hour"
|
|
||||||
hours_units: 744
|
|
||||||
months: 12
|
|
||||||
notes: "2 DB servers for Quarter Rack — Queretaro (Prod)"
|
|
||||||
|
|
||||||
# ExaCS X11M — Storage Servers (Queretaro Production)
|
|
||||||
- category: "Exadata Cloud Infrastructure"
|
|
||||||
product: "ExaCS X11M Storage Server"
|
|
||||||
sku: "B110629"
|
|
||||||
qty: 3
|
|
||||||
unit: "Hosted Environment Per Hour"
|
|
||||||
hours_units: 744
|
|
||||||
months: 12
|
|
||||||
notes: "3 storage servers — Quarter Rack minimum"
|
|
||||||
|
|
||||||
# ExaCS X11M — ECPU BYOL (Queretaro)
|
|
||||||
- category: "Exadata Cloud Infrastructure"
|
|
||||||
product: "ExaCS X11M ECPU BYOL"
|
|
||||||
sku: "B110632"
|
|
||||||
qty: 128
|
|
||||||
unit: "ECPU Per Hour"
|
|
||||||
hours_units: 744
|
|
||||||
months: 12
|
|
||||||
notes: "128 ECPUs BYOL — EBS + SAP + DW"
|
|
||||||
|
|
||||||
# ExaCS X11M — DB Servers (Sao Paulo DR Standby)
|
|
||||||
- category: "Exadata Cloud Infrastructure"
|
|
||||||
product: "ExaCS X11M DB Server (DR)"
|
|
||||||
sku: "B110627"
|
|
||||||
qty: 2
|
|
||||||
unit: "Hosted Environment Per Hour"
|
|
||||||
hours_units: 744
|
|
||||||
months: 12
|
|
||||||
notes: "2 DB servers — Sao Paulo (DR Standby)"
|
|
||||||
|
|
||||||
# ExaCS X11M — Storage Servers (Sao Paulo DR)
|
|
||||||
- category: "Exadata Cloud Infrastructure"
|
|
||||||
product: "ExaCS X11M Storage Server (DR)"
|
|
||||||
sku: "B110629"
|
|
||||||
qty: 3
|
|
||||||
unit: "Hosted Environment Per Hour"
|
|
||||||
hours_units: 744
|
|
||||||
months: 12
|
|
||||||
notes: "3 storage servers — DR Standby"
|
|
||||||
|
|
||||||
# ExaCS X11M — ECPU BYOL (Sao Paulo DR)
|
|
||||||
- category: "Exadata Cloud Infrastructure"
|
|
||||||
product: "ExaCS X11M ECPU BYOL (DR)"
|
|
||||||
sku: "B110632"
|
|
||||||
qty: 128
|
|
||||||
unit: "ECPU Per Hour"
|
|
||||||
hours_units: 744
|
|
||||||
months: 12
|
|
||||||
notes: "128 ECPUs BYOL — Data Guard standby"
|
|
||||||
|
|
||||||
# ADB-S for DW migration (Phase 2)
|
|
||||||
- category: "Autonomous Database"
|
|
||||||
product: "ADB-S ECPU BYOL"
|
|
||||||
sku: "B95703"
|
|
||||||
qty: 16
|
|
||||||
unit: "ECPU Per Hour"
|
|
||||||
hours_units: 744
|
|
||||||
months: 12
|
|
||||||
notes: "16 ECPU for DW migration to ADB-S (optional Phase 2)"
|
|
||||||
|
|
||||||
# ADB-S Storage
|
|
||||||
- category: "Autonomous Database"
|
|
||||||
product: "ADB-S Storage (ATP)"
|
|
||||||
sku: "B95706"
|
|
||||||
qty: 2000
|
|
||||||
unit: "Gigabyte Storage Capacity Per Month"
|
|
||||||
hours_units: 1
|
|
||||||
months: 12
|
|
||||||
notes: "2TB storage for DW"
|
|
||||||
|
|
||||||
# FastConnect 10Gbps Dual
|
|
||||||
- category: "Networking"
|
|
||||||
product: "FastConnect 10 Gbps (Primary)"
|
|
||||||
sku: "B88326"
|
|
||||||
qty: 1
|
|
||||||
unit: "Port Hour"
|
|
||||||
hours_units: 744
|
|
||||||
months: 12
|
|
||||||
notes: "Primary circuit — Mexico City to Queretaro"
|
|
||||||
|
|
||||||
- category: "Networking"
|
|
||||||
product: "FastConnect 10 Gbps (Redundant)"
|
|
||||||
sku: "B88326"
|
|
||||||
qty: 1
|
|
||||||
unit: "Port Hour"
|
|
||||||
hours_units: 744
|
|
||||||
months: 12
|
|
||||||
notes: "Redundant circuit — Mexico City to Queretaro"
|
|
||||||
|
|
||||||
# Object Storage for RMAN backups
|
|
||||||
- category: "Storage"
|
|
||||||
product: "Object Storage"
|
|
||||||
sku: "B91628"
|
|
||||||
qty: 50000
|
|
||||||
unit: "Gigabyte Storage Capacity Per Month"
|
|
||||||
hours_units: 1
|
|
||||||
months: 12
|
|
||||||
notes: "50TB for RMAN backups (DW + EBS)"
|
|
||||||
|
|
||||||
# Block Volume
|
|
||||||
- category: "Storage"
|
|
||||||
product: "Block Volume Storage"
|
|
||||||
sku: "B91961"
|
|
||||||
qty: 10000
|
|
||||||
unit: "Gigabyte Storage Capacity Per Month"
|
|
||||||
hours_units: 1
|
|
||||||
months: 12
|
|
||||||
notes: "10TB additional block storage"
|
|
||||||
|
|
||||||
# OCI Vault
|
|
||||||
- category: "Security"
|
|
||||||
product: "OCI Vault Key Versions"
|
|
||||||
sku: "B92092"
|
|
||||||
qty: 10
|
|
||||||
unit: "Key Version Per Month"
|
|
||||||
hours_units: 1
|
|
||||||
months: 12
|
|
||||||
notes: "TDE keys for 3 databases + DR + backups"
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
# OCI Deal Accelerator — Business Case
|
|
||||||
# Demo: PharmaCorp Mexico — ExaCS migration from on-prem Exadata X6
|
|
||||||
# Follows exact schema expected by oci_bizcase_gen.py
|
|
||||||
|
|
||||||
business_case:
|
|
||||||
customer_name: "PharmaCorp Mexico"
|
|
||||||
prepared_by: "Carlos Mendoza — Solutions Architect"
|
|
||||||
date: "2026-04-12"
|
|
||||||
|
|
||||||
executive_summary: >
|
|
||||||
PharmaCorp Mexico operates 3 mission-critical Oracle databases (EBS R12, SAP, DW)
|
|
||||||
on aging Exadata X6 hardware. The hardware refresh quote is $1.8M and locks the
|
|
||||||
company into 5 more years of on-prem operations with no DR capability. Migration
|
|
||||||
to OCI ExaCS X11M reduces annual infrastructure costs by 61% ($1.75M over 3 years),
|
|
||||||
adds cross-region DR (RPO<15min, RTO<1h), and resolves the pending SOX audit
|
|
||||||
finding on automated patching.
|
|
||||||
|
|
||||||
drivers:
|
|
||||||
primary: "hardware_refresh_avoidance"
|
|
||||||
urgency: "HW refresh due in 6 months — must decide now or commit $1.8M"
|
|
||||||
cost_of_inaction:
|
|
||||||
financial: "$2.88M over 3yr: $1.8M HW refresh + $300K/yr support + $80K/yr DC costs"
|
|
||||||
operational: "No DR today — single site, single point of failure for EBS production"
|
|
||||||
strategic: "SOX audit finding pending: manual quarterly patching is non-compliant"
|
|
||||||
|
|
||||||
tco:
|
|
||||||
horizon_years: 3
|
|
||||||
current_state:
|
|
||||||
annual_infrastructure: 600000
|
|
||||||
annual_licensing: 220000
|
|
||||||
annual_operations: 60000
|
|
||||||
annual_downtime_cost: 50000
|
|
||||||
annual_compliance_cost: 30000
|
|
||||||
breakdown:
|
|
||||||
- item: "Exadata X6 depreciation + refresh amortization"
|
|
||||||
annual: 600000
|
|
||||||
- item: "Oracle DB EE Support & Maintenance"
|
|
||||||
annual: 220000
|
|
||||||
- item: "DBA patching + infra management (part-time)"
|
|
||||||
annual: 60000
|
|
||||||
- item: "Data center (power, cooling, space)"
|
|
||||||
annual: 80000
|
|
||||||
|
|
||||||
proposed_oci:
|
|
||||||
migration_one_time: 50000
|
|
||||||
annual_cloud_consumption: 278400
|
|
||||||
annual_licensing: 0
|
|
||||||
annual_operations: 20000
|
|
||||||
annual_downtime_cost: 0
|
|
||||||
annual_compliance_cost: 0
|
|
||||||
breakdown:
|
|
||||||
- item: "ExaCS X11M Queretaro (Prod, BYOL)"
|
|
||||||
annual: 98400
|
|
||||||
- item: "ExaCS X11M Sao Paulo (DR Standby, BYOL)"
|
|
||||||
annual: 98400
|
|
||||||
- item: "FastConnect 10Gbps Dual"
|
|
||||||
annual: 36720
|
|
||||||
- item: "OCI Vault + Monitoring + Storage"
|
|
||||||
annual: 30000
|
|
||||||
- item: "Reduced DBA operations (automated patching)"
|
|
||||||
annual: 20000
|
|
||||||
- item: "Migration project (one-time, amortized Y1)"
|
|
||||||
annual: 50000
|
|
||||||
|
|
||||||
savings:
|
|
||||||
annual: 661600
|
|
||||||
three_year: 1749600
|
|
||||||
pct: 61
|
|
||||||
|
|
||||||
roi:
|
|
||||||
three_year_roi_pct: 3400
|
|
||||||
payback_months: 1
|
|
||||||
total_investment: 50000
|
|
||||||
annual_net_benefit: 661600
|
|
||||||
notes: >
|
|
||||||
ROI based on 3-year TCO comparison. 1-month payback driven by immediate
|
|
||||||
elimination of HW refresh commitment ($1.8M avoided). Conservative — does
|
|
||||||
not include productivity gains from automated patching or DR risk reduction.
|
|
||||||
|
|
||||||
value_drivers:
|
|
||||||
- category: "Cost Avoidance"
|
|
||||||
driver: "Avoid $1.8M hardware refresh + eliminate DC costs"
|
|
||||||
quantification: "$1.75M saved over 3 years (61% reduction)"
|
|
||||||
|
|
||||||
- category: "Risk Reduction"
|
|
||||||
driver: "Cross-region DR where none exists today"
|
|
||||||
quantification: "RPO<15min, RTO<1h with Data Guard + FSDR"
|
|
||||||
|
|
||||||
- category: "Compliance"
|
|
||||||
driver: "SOX audit finding resolved with automated patching"
|
|
||||||
quantification: "ExaCS auto-patching + OCI Audit + Vault key management"
|
|
||||||
|
|
||||||
- category: "Operational Efficiency"
|
|
||||||
driver: "DBA freed from manual patching + infrastructure management"
|
|
||||||
quantification: "60% reduction in DBA time allocated to patching"
|
|
||||||
|
|
||||||
risks:
|
|
||||||
migration_risks:
|
|
||||||
- risk: "EBS R12 customizations may require OS-level changes post-migration"
|
|
||||||
probability: "Medium"
|
|
||||||
impact: "Medium"
|
|
||||||
mitigation: "Inventory all customizations pre-migration; test in Pre-Prod"
|
|
||||||
|
|
||||||
- risk: "FastConnect provisioning delay (partner-dependent)"
|
|
||||||
probability: "Medium"
|
|
||||||
impact: "Low"
|
|
||||||
mitigation: "Start in Phase 1 week 1; IPSec VPN as interim backup"
|
|
||||||
|
|
||||||
- risk: "HW refresh deadline pressure (6 months)"
|
|
||||||
probability: "High"
|
|
||||||
impact: "High"
|
|
||||||
mitigation: "16-week migration plan fits within window with margin"
|
|
||||||
|
|
||||||
do_nothing_risks:
|
|
||||||
- risk: "No DR — single site, single point of failure for EBS production"
|
|
||||||
probability: "High"
|
|
||||||
impact: "Critical"
|
|
||||||
mitigation: "Cannot be mitigated without infrastructure investment"
|
|
||||||
|
|
||||||
- risk: "SOX audit finding remains unresolved (manual patching)"
|
|
||||||
probability: "High"
|
|
||||||
impact: "High"
|
|
||||||
mitigation: "Remediation requires automated patching — on-prem can't deliver"
|
|
||||||
|
|
||||||
- risk: "$1.8M HW refresh locks in 5 more years of on-prem with no cloud ROI"
|
|
||||||
probability: "High"
|
|
||||||
impact: "High"
|
|
||||||
mitigation: "Sunk cost — delays any cloud migration benefit for 5+ years"
|
|
||||||
|
|
||||||
recommendation:
|
|
||||||
summary: >
|
|
||||||
Proceed with OCI ExaCS migration. 61% cost reduction over 3 years ($1.75M saved),
|
|
||||||
cross-region DR capability, and SOX compliance resolution. The alternative
|
|
||||||
(HW refresh at $1.8M) costs more and leaves DR + compliance gaps unresolved.
|
|
||||||
next_steps:
|
|
||||||
- "Board approval with TCO presentation — May 2026"
|
|
||||||
- "Provision OCI tenancy + ExaCS in Queretaro — June 2026"
|
|
||||||
- "Start FastConnect provisioning with partner — June 2026"
|
|
||||||
- "EBS customization inventory and Pre-Prod testing — June 2026"
|
|
||||||
- "Production cutover — September 2026"
|
|
||||||
|
|
||||||
roadmap:
|
|
||||||
total_duration: "16 weeks (May — September 2026)"
|
|
||||||
phases:
|
|
||||||
- name: "Board Approval"
|
|
||||||
duration: "2 weeks"
|
|
||||||
investment: 0
|
|
||||||
description: "Present TCO + risk analysis to board"
|
|
||||||
|
|
||||||
- name: "Foundation (networking + ExaCS)"
|
|
||||||
duration: "4 weeks"
|
|
||||||
investment: 15000
|
|
||||||
description: "Provision OCI tenancy, ExaCS, FastConnect"
|
|
||||||
|
|
||||||
- name: "EBS Migration + Data Guard"
|
|
||||||
duration: "6 weeks"
|
|
||||||
investment: 25000
|
|
||||||
description: "Migrate EBS R12 with ZDM, configure Data Guard to Sao Paulo"
|
|
||||||
|
|
||||||
- name: "SAP + DW Migration"
|
|
||||||
duration: "4 weeks"
|
|
||||||
investment: 10000
|
|
||||||
description: "Migrate remaining databases, RMAN backup for DW"
|
|
||||||
|
|
||||||
- name: "Cutover + Decommission"
|
|
||||||
duration: "2 weeks"
|
|
||||||
investment: 0
|
|
||||||
description: "Production cutover, decommission Exadata X6"
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
# OCI Deal Accelerator — Architecture Diagram Spec
|
|
||||||
# Demo: PharmaCorp Mexico — ExaCS dual-region with Data Guard
|
|
||||||
# Layout: Left-to-right flow, ref arch style
|
|
||||||
# Mexico DC → FastConnect → Queretaro (DB subnet) → DR Sao Paulo
|
|
||||||
#
|
|
||||||
# This example only includes components the user explicitly requested:
|
|
||||||
# ExaCS (prod + DR), Hub-Spoke networking with DRG, FastConnect 10Gbps,
|
|
||||||
# Bastion in public subnet.
|
|
||||||
# DRG and Service Gateway are auto-added per technical dependency whitelist.
|
|
||||||
# Monitoring, Logging, Events, WAF, Vault, EBS — NOT included (not requested).
|
|
||||||
|
|
||||||
title: "PharmaCorp Mexico — ExaCS Migration Architecture"
|
|
||||||
|
|
||||||
# External: Mexico City DC (left side)
|
|
||||||
external:
|
|
||||||
- id: "mexico_dc"
|
|
||||||
label: "Mexico City\nData Center"
|
|
||||||
icon: "user"
|
|
||||||
x: 30
|
|
||||||
y: 320
|
|
||||||
w: 60
|
|
||||||
h: 70
|
|
||||||
|
|
||||||
# OCI Tenancy
|
|
||||||
tenancy:
|
|
||||||
label: "Oracle Cloud Infrastructure"
|
|
||||||
x: 200
|
|
||||||
y: 60
|
|
||||||
w: 1650
|
|
||||||
h: 780
|
|
||||||
|
|
||||||
regions:
|
|
||||||
# ── Primary Region: Queretaro ──
|
|
||||||
- id: "region_qro"
|
|
||||||
label: "Region — Queretaro (Primary)"
|
|
||||||
primary: true
|
|
||||||
x: 15
|
|
||||||
y: 40
|
|
||||||
w: 1050
|
|
||||||
h: 720
|
|
||||||
|
|
||||||
vcns:
|
|
||||||
- id: "vcn_hub"
|
|
||||||
label: "Hub VCN (10.0.0.0/16)"
|
|
||||||
x: 15
|
|
||||||
y: 45
|
|
||||||
w: 1020
|
|
||||||
h: 650
|
|
||||||
|
|
||||||
subnets:
|
|
||||||
# Public subnet — bastion access
|
|
||||||
- id: "subnet_pub"
|
|
||||||
label: "Public Subnet — 10.0.0.0/24"
|
|
||||||
h: 120
|
|
||||||
services:
|
|
||||||
- id: "bastion"
|
|
||||||
label: "Bastion\nService"
|
|
||||||
type: "bastion"
|
|
||||||
w: 120
|
|
||||||
h: 70
|
|
||||||
|
|
||||||
# DB subnet — main workload
|
|
||||||
- id: "subnet_db"
|
|
||||||
label: "Database Subnet (Private) — 10.0.1.0/24"
|
|
||||||
h: 150
|
|
||||||
services:
|
|
||||||
- id: "exacs_prod"
|
|
||||||
label: "ExaCS X11M\nQuarter Rack\n(Production)"
|
|
||||||
type: "exacs"
|
|
||||||
w: 200
|
|
||||||
h: 90
|
|
||||||
|
|
||||||
gateways:
|
|
||||||
# Auto-added per whitelist: FastConnect requires DRG
|
|
||||||
- id: "drg"
|
|
||||||
label: "DRG"
|
|
||||||
type: "drg"
|
|
||||||
w: 90
|
|
||||||
h: 60
|
|
||||||
# Auto-added per whitelist: ExaCS backup requires SGW
|
|
||||||
- id: "sgw"
|
|
||||||
label: "Service\nGateway"
|
|
||||||
type: "service_gateway"
|
|
||||||
w: 100
|
|
||||||
h: 60
|
|
||||||
|
|
||||||
# ── DR Region: Sao Paulo (right side) ──
|
|
||||||
- id: "region_gru"
|
|
||||||
label: "Region — São Paulo (DR)"
|
|
||||||
x: 1090
|
|
||||||
y: 40
|
|
||||||
w: 540
|
|
||||||
h: 540
|
|
||||||
|
|
||||||
vcns:
|
|
||||||
- id: "vcn_dr"
|
|
||||||
label: "DR VCN (10.1.0.0/16)"
|
|
||||||
x: 15
|
|
||||||
y: 45
|
|
||||||
w: 510
|
|
||||||
h: 470
|
|
||||||
|
|
||||||
subnets:
|
|
||||||
- id: "subnet_dr_db"
|
|
||||||
label: "DB Subnet (Private) — 10.1.1.0/24"
|
|
||||||
h: 200
|
|
||||||
services:
|
|
||||||
- id: "exacs_dr"
|
|
||||||
label: "ExaCS Standby\n(Data Guard)"
|
|
||||||
type: "exacs"
|
|
||||||
w: 200
|
|
||||||
h: 100
|
|
||||||
|
|
||||||
# Connections
|
|
||||||
connections:
|
|
||||||
# Entry: DC → DRG (FastConnect)
|
|
||||||
- id: "conn_fc"
|
|
||||||
label: "FastConnect 10Gbps\n(Dual Redundant)"
|
|
||||||
type: "network"
|
|
||||||
from: "mexico_dc"
|
|
||||||
to: "drg"
|
|
||||||
|
|
||||||
# DR: ExaCS Prod → ExaCS Standby (Data Guard)
|
|
||||||
- id: "conn_dg"
|
|
||||||
label: "Data Guard\n(Redo Shipping)"
|
|
||||||
type: "data"
|
|
||||||
from: "exacs_prod"
|
|
||||||
to: "exacs_dr"
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# OPTIONAL — add if the user approves during pre-generation review:
|
|
||||||
#
|
|
||||||
# Security:
|
|
||||||
# - id: "vault"
|
|
||||||
# label: "OCI Vault\nTDE Keys"
|
|
||||||
# type: "vault"
|
|
||||||
#
|
|
||||||
# - id: "waf"
|
|
||||||
# label: "WAF"
|
|
||||||
# type: "waf"
|
|
||||||
#
|
|
||||||
# Observability:
|
|
||||||
# - id: "monitoring"
|
|
||||||
# label: "OCI Monitoring"
|
|
||||||
# type: "monitoring"
|
|
||||||
#
|
|
||||||
# - id: "logging"
|
|
||||||
# label: "OCI Audit + Logging"
|
|
||||||
# type: "logging"
|
|
||||||
#
|
|
||||||
# - id: "events"
|
|
||||||
# label: "OCI Events + Notifications"
|
|
||||||
# type: "events"
|
|
||||||
#
|
|
||||||
# Application tier (only if customer has app servers):
|
|
||||||
# - id: "ebs_app"
|
|
||||||
# label: "EBS R12 App Tier"
|
|
||||||
# type: "compute"
|
|
||||||
#
|
|
||||||
# DR orchestration:
|
|
||||||
# - id: "fsdr"
|
|
||||||
# label: "Full Stack DR (FSDR)"
|
|
||||||
# type: "fsdr"
|
|
||||||
#
|
|
||||||
# Backup:
|
|
||||||
# - id: "obj_storage"
|
|
||||||
# label: "Object Storage\nRMAN Backups"
|
|
||||||
# type: "object-storage"
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,111 +0,0 @@
|
|||||||
{
|
|
||||||
"cost": {
|
|
||||||
"line_items": [
|
|
||||||
{
|
|
||||||
"component": "ExaCS X11M Quarter Rack \u2014 Queretaro (Prod)",
|
|
||||||
"monthly_payg": "$18,500",
|
|
||||||
"monthly_byol": "$8,200",
|
|
||||||
"notes": "2 DB servers, 3 storage servers, BYOL"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"component": "ExaCS X11M Quarter Rack \u2014 Sao Paulo (DR)",
|
|
||||||
"monthly_payg": "$18,500",
|
|
||||||
"monthly_byol": "$8,200",
|
|
||||||
"notes": "ADG standby, BYOL"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"component": "ADB-S 16 ECPU (DW alternative \u2014 future)",
|
|
||||||
"monthly_payg": "$2,400",
|
|
||||||
"monthly_byol": "$1,200",
|
|
||||||
"notes": "Optional \u2014 migrate DW to ADB-S in Phase 2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"component": "FastConnect 10Gbps Dual",
|
|
||||||
"monthly_payg": "$3,060",
|
|
||||||
"notes": "2 x $1,530/month"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"component": "OCI Vault",
|
|
||||||
"monthly_payg": "$500",
|
|
||||||
"notes": "HSM-backed key management"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"component": "Object Storage (50TB backup)",
|
|
||||||
"monthly_payg": "$1,200",
|
|
||||||
"notes": "RMAN backups + DW backup"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"component": "WAF + Logging + Monitoring",
|
|
||||||
"monthly_payg": "$800",
|
|
||||||
"notes": "WAF for EBS web tier + audit logging"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"component": "TOTAL",
|
|
||||||
"monthly_payg": "$45,000",
|
|
||||||
"monthly_byol": "$23,200",
|
|
||||||
"notes": "BYOL saves 48%"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"assumptions": [
|
|
||||||
"BYOL assumes existing Oracle Database EE licenses with active support",
|
|
||||||
"ExaCS pricing based on current OCI list prices",
|
|
||||||
"DR standby at full rate (ADG requires active compute)",
|
|
||||||
"No middleware licensing included (EBS app tier on compute VMs)",
|
|
||||||
"FastConnect partner charges not included"
|
|
||||||
],
|
|
||||||
"show_byol": true
|
|
||||||
},
|
|
||||||
"cost_comparison": {
|
|
||||||
"title": "Cost Comparison: On-Prem HW Refresh vs OCI Migration",
|
|
||||||
"col_headers": [
|
|
||||||
"Component",
|
|
||||||
"On-Prem Refresh (5yr)",
|
|
||||||
"OCI ExaCS BYOL",
|
|
||||||
"Delta"
|
|
||||||
],
|
|
||||||
"rows": [
|
|
||||||
{
|
|
||||||
"item": "Infrastructure (hardware/cloud)",
|
|
||||||
"current": "$1,800,000 upfront",
|
|
||||||
"oci": "$278,400/yr",
|
|
||||||
"savings": "Break-even at 3.2 years"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"item": "Annual Support & Maintenance",
|
|
||||||
"current": "$220,000/yr",
|
|
||||||
"oci": "Included in OCI",
|
|
||||||
"savings": "-100%"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"item": "DR Infrastructure",
|
|
||||||
"current": "$0 (no DR today)",
|
|
||||||
"oci": "$98,400/yr",
|
|
||||||
"savings": "New capability \u2014 no DR today"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"item": "Data Center (power/cooling/space)",
|
|
||||||
"current": "$80,000/yr",
|
|
||||||
"oci": "$0",
|
|
||||||
"savings": "-100%"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"item": "DBA Operations (patching)",
|
|
||||||
"current": "$60,000/yr (part-time DBA)",
|
|
||||||
"oci": "$0 (automated)",
|
|
||||||
"savings": "-100%"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"item": "TOTAL ANNUAL (year 1)",
|
|
||||||
"current": "$2,160,000",
|
|
||||||
"oci": "$376,800",
|
|
||||||
"savings": "-83%"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"item": "TOTAL 3-YEAR TCO",
|
|
||||||
"current": "$2,880,000",
|
|
||||||
"oci": "$1,130,400",
|
|
||||||
"savings": "-$1.75M (61%)"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
well_architected_scorecard:
|
|
||||||
overall_status: GAPS_IDENTIFIED
|
|
||||||
generated_date: '2026-04-12T23:15:18.437115'
|
|
||||||
architecture_name: /tmp/demo-flawed-arch.yaml
|
|
||||||
customer: Demo Flawed Arch
|
|
||||||
summary:
|
|
||||||
total_checks: 23
|
|
||||||
total_passed: 1
|
|
||||||
total_gaps: 22
|
|
||||||
high_severity_gaps: 10
|
|
||||||
medium_severity_gaps: 10
|
|
||||||
low_severity_gaps: 2
|
|
||||||
pillars:
|
|
||||||
security_compliance:
|
|
||||||
status: GAPS_IDENTIFIED
|
|
||||||
checks_passed: 0
|
|
||||||
checks_total: 15
|
|
||||||
categories:
|
|
||||||
identity_access:
|
|
||||||
passed: 0
|
|
||||||
total: 4
|
|
||||||
gaps:
|
|
||||||
- check_id: SEC-IAM-001
|
|
||||||
area: Identity & Access
|
|
||||||
finding: no IAM policy strategy defined or broad permissions used
|
|
||||||
severity: HIGH
|
|
||||||
recommendation: architecture specifies granular IAM policies per compartment/service
|
|
||||||
wa_reference: https://docs.oracle.com/en/solutions/oci-best-practices/effective-strategies-security-and-compliance1.html
|
|
||||||
- check_id: SEC-IAM-002
|
|
||||||
area: Identity & Access
|
|
||||||
finding: no MFA mentioned in identity setup
|
|
||||||
severity: HIGH
|
|
||||||
recommendation: MFA is specified in identity configuration
|
|
||||||
wa_reference: https://docs.oracle.com/en/solutions/oci-best-practices/effective-strategies-security-and-compliance1.html
|
|
||||||
- check_id: SEC-IAM-003
|
|
||||||
area: Identity & Access
|
|
||||||
finding: no mention of admin account restrictions
|
|
||||||
severity: HIGH
|
|
||||||
recommendation: separate admin groups defined for operations
|
|
||||||
wa_reference: https://docs.oracle.com/en/solutions/oci-best-practices/effective-strategies-security-and-compliance1.html
|
|
||||||
- check_id: SEC-IAM-004
|
|
||||||
area: Identity & Access
|
|
||||||
finding: stored credentials or API keys used for service accounts
|
|
||||||
severity: HIGH
|
|
||||||
recommendation: instance principals or resource principals specified for
|
|
||||||
service-to-service auth
|
|
||||||
wa_reference: https://docs.oracle.com/en/solutions/oci-best-practices/effective-strategies-security-and-compliance1.html
|
|
||||||
resource_isolation:
|
|
||||||
passed: 0
|
|
||||||
total: 3
|
|
||||||
gaps:
|
|
||||||
- check_id: SEC-ISO-001
|
|
||||||
area: Resource Isolation
|
|
||||||
finding: flat compartment structure or no compartment strategy
|
|
||||||
severity: HIGH
|
|
||||||
recommendation: hierarchical compartment structure defined
|
|
||||||
wa_reference: https://docs.oracle.com/en/solutions/oci-best-practices/effective-strategies-security-and-compliance1.html
|
|
||||||
- check_id: SEC-ISO-002
|
|
||||||
area: Resource Isolation
|
|
||||||
finding: no tagging strategy defined
|
|
||||||
severity: MEDIUM
|
|
||||||
recommendation: defined tag namespaces with cost, environment, and owner
|
|
||||||
tags
|
|
||||||
wa_reference: https://docs.oracle.com/en/solutions/oci-best-practices/effective-strategies-security-and-compliance1.html
|
|
||||||
- check_id: SEC-ISO-004
|
|
||||||
area: Resource Isolation
|
|
||||||
finding: user credentials used for cross-resource access
|
|
||||||
severity: HIGH
|
|
||||||
recommendation: resource principals used for cross-service access
|
|
||||||
wa_reference: https://docs.oracle.com/en/solutions/oci-best-practices/effective-strategies-security-and-compliance1.html
|
|
||||||
database_security:
|
|
||||||
passed: 0
|
|
||||||
total: 0
|
|
||||||
gaps: []
|
|
||||||
data_protection:
|
|
||||||
passed: 0
|
|
||||||
total: 2
|
|
||||||
gaps:
|
|
||||||
- check_id: SEC-DAT-001
|
|
||||||
area: Data Protection
|
|
||||||
finding: any storage service without encryption at rest
|
|
||||||
severity: HIGH
|
|
||||||
recommendation: encryption at rest specified for all storage services
|
|
||||||
wa_reference: https://docs.oracle.com/en/solutions/oci-best-practices/effective-strategies-security-and-compliance1.html
|
|
||||||
- check_id: SEC-DAT-002
|
|
||||||
area: Data Protection
|
|
||||||
finding: using Oracle-managed keys or no key management strategy
|
|
||||||
severity: MEDIUM
|
|
||||||
recommendation: OCI Vault specified for key management
|
|
||||||
wa_reference: https://docs.oracle.com/en/solutions/oci-best-practices/effective-strategies-security-and-compliance1.html
|
|
||||||
network_security:
|
|
||||||
passed: 0
|
|
||||||
total: 3
|
|
||||||
gaps:
|
|
||||||
- check_id: SEC-NET-001
|
|
||||||
area: Network Security
|
|
||||||
finding: only Security Lists used without NSGs
|
|
||||||
severity: MEDIUM
|
|
||||||
recommendation: NSGs used as primary network security mechanism
|
|
||||||
wa_reference: https://docs.oracle.com/en/solutions/oci-best-practices/effective-strategies-security-and-compliance1.html
|
|
||||||
- check_id: SEC-NET-002
|
|
||||||
area: Network Security
|
|
||||||
finding: SSH open to 0.0.0.0/0
|
|
||||||
severity: HIGH
|
|
||||||
recommendation: SSH access restricted to specific CIDR ranges or bastion
|
|
||||||
wa_reference: https://docs.oracle.com/en/solutions/oci-best-practices/effective-strategies-security-and-compliance1.html
|
|
||||||
- check_id: SEC-NET-004
|
|
||||||
area: Network Security
|
|
||||||
finding: OCI services accessed via internet gateway
|
|
||||||
severity: MEDIUM
|
|
||||||
recommendation: Service Gateway configured in VCN
|
|
||||||
wa_reference: https://docs.oracle.com/en/solutions/oci-best-practices/effective-strategies-security-and-compliance1.html
|
|
||||||
monitoring_audit:
|
|
||||||
passed: 0
|
|
||||||
total: 3
|
|
||||||
gaps:
|
|
||||||
- check_id: SEC-MON-001
|
|
||||||
area: Monitoring & Audit
|
|
||||||
finding: Cloud Guard not included in architecture
|
|
||||||
severity: HIGH
|
|
||||||
recommendation: Cloud Guard configured with appropriate recipes
|
|
||||||
wa_reference: https://docs.oracle.com/en/solutions/oci-best-practices/effective-strategies-security-and-compliance1.html
|
|
||||||
- check_id: SEC-MON-002
|
|
||||||
area: Monitoring & Audit
|
|
||||||
finding: audit service not mentioned or retention not configured
|
|
||||||
severity: MEDIUM
|
|
||||||
recommendation: Audit service enabled (enabled by default, verify retention)
|
|
||||||
wa_reference: https://docs.oracle.com/en/solutions/oci-best-practices/effective-strategies-security-and-compliance1.html
|
|
||||||
- check_id: SEC-MON-003
|
|
||||||
area: Monitoring & Audit
|
|
||||||
finding: no VCN Flow Logs configured
|
|
||||||
severity: MEDIUM
|
|
||||||
recommendation: VCN Flow Logs enabled for critical subnets
|
|
||||||
wa_reference: https://docs.oracle.com/en/solutions/oci-best-practices/effective-strategies-security-and-compliance1.html
|
|
||||||
reliability_resilience:
|
|
||||||
status: PASS_WITH_RECOMMENDATIONS
|
|
||||||
checks_passed: 0
|
|
||||||
checks_total: 1
|
|
||||||
categories:
|
|
||||||
scalability:
|
|
||||||
passed: 0
|
|
||||||
total: 1
|
|
||||||
gaps:
|
|
||||||
- check_id: REL-SCL-002
|
|
||||||
area: Scalability
|
|
||||||
finding: no service limit review planned
|
|
||||||
severity: LOW
|
|
||||||
recommendation: service limit review mentioned in deployment plan
|
|
||||||
wa_reference: https://docs.oracle.com/en/solutions/oci-best-practices/reliable-and-resilient-cloud-topology-practices1.html
|
|
||||||
fault_tolerant_networking:
|
|
||||||
passed: 0
|
|
||||||
total: 0
|
|
||||||
gaps: []
|
|
||||||
data_backup:
|
|
||||||
passed: 0
|
|
||||||
total: 0
|
|
||||||
gaps: []
|
|
||||||
data_replication:
|
|
||||||
passed: 0
|
|
||||||
total: 0
|
|
||||||
gaps: []
|
|
||||||
disaster_recovery:
|
|
||||||
passed: 0
|
|
||||||
total: 0
|
|
||||||
gaps: []
|
|
||||||
performance_cost:
|
|
||||||
status: PASS_WITH_RECOMMENDATIONS
|
|
||||||
checks_passed: 0
|
|
||||||
checks_total: 2
|
|
||||||
categories:
|
|
||||||
compute_sizing:
|
|
||||||
passed: 0
|
|
||||||
total: 0
|
|
||||||
gaps: []
|
|
||||||
storage_strategy:
|
|
||||||
passed: 0
|
|
||||||
total: 0
|
|
||||||
gaps: []
|
|
||||||
network_tuning:
|
|
||||||
passed: 0
|
|
||||||
total: 0
|
|
||||||
gaps: []
|
|
||||||
cost_management:
|
|
||||||
passed: 0
|
|
||||||
total: 2
|
|
||||||
gaps:
|
|
||||||
- check_id: PERF-CST-003
|
|
||||||
area: Cost Management
|
|
||||||
finding: no budget alerts configured
|
|
||||||
severity: MEDIUM
|
|
||||||
recommendation: OCI Budgets configured with alert thresholds
|
|
||||||
wa_reference: https://docs.oracle.com/en/solutions/oci-best-practices/index.html
|
|
||||||
- check_id: PERF-CST-004
|
|
||||||
area: Cost Management
|
|
||||||
finding: no cost attribution tagging strategy
|
|
||||||
severity: MEDIUM
|
|
||||||
recommendation: cost-tracking tags defined and enforced
|
|
||||||
wa_reference: https://docs.oracle.com/en/solutions/oci-best-practices/index.html
|
|
||||||
operational_efficiency:
|
|
||||||
status: GAPS_IDENTIFIED
|
|
||||||
checks_passed: 1
|
|
||||||
checks_total: 5
|
|
||||||
categories:
|
|
||||||
deployment_strategy:
|
|
||||||
passed: 0
|
|
||||||
total: 1
|
|
||||||
gaps:
|
|
||||||
- check_id: OPS-DEP-001
|
|
||||||
area: Deployment Strategy
|
|
||||||
finding: no IaC strategy specified
|
|
||||||
severity: HIGH
|
|
||||||
recommendation: Terraform or Resource Manager specified for infrastructure
|
|
||||||
provisioning
|
|
||||||
wa_reference: https://docs.oracle.com/en/solutions/oci-best-practices/best-practices-operating-cloud-deployments-efficiency.html
|
|
||||||
workload_monitoring:
|
|
||||||
passed: 1
|
|
||||||
total: 2
|
|
||||||
gaps:
|
|
||||||
- check_id: OPS-MON-003
|
|
||||||
area: Workload Monitoring
|
|
||||||
finding: no centralized log analysis solution
|
|
||||||
severity: MEDIUM
|
|
||||||
recommendation: OCI Logging Analytics configured
|
|
||||||
wa_reference: https://docs.oracle.com/en/solutions/oci-best-practices/best-practices-operating-cloud-deployments-efficiency.html
|
|
||||||
os_management:
|
|
||||||
passed: 0
|
|
||||||
total: 0
|
|
||||||
gaps: []
|
|
||||||
operations_support:
|
|
||||||
passed: 0
|
|
||||||
total: 2
|
|
||||||
gaps:
|
|
||||||
- check_id: OPS-SUP-001
|
|
||||||
area: Operations Support
|
|
||||||
finding: no support plan consideration
|
|
||||||
severity: LOW
|
|
||||||
recommendation: Oracle support plan level specified and matches criticality
|
|
||||||
wa_reference: https://docs.oracle.com/en/solutions/oci-best-practices/best-practices-operating-cloud-deployments-efficiency.html
|
|
||||||
- check_id: OPS-SUP-002
|
|
||||||
area: Operations Support
|
|
||||||
finding: no notification configuration
|
|
||||||
severity: MEDIUM
|
|
||||||
recommendation: OCI Notifications configured for operational events
|
|
||||||
wa_reference: https://docs.oracle.com/en/solutions/oci-best-practices/best-practices-operating-cloud-deployments-efficiency.html
|
|
||||||
distributed_cloud:
|
|
||||||
status: NOT_APPLICABLE
|
|
||||||
checks_passed: 0
|
|
||||||
checks_total: 0
|
|
||||||
categories: {}
|
|
||||||
action_items:
|
|
||||||
high_priority:
|
|
||||||
- check_id: SEC-IAM-001
|
|
||||||
pillar: Identity & Access
|
|
||||||
finding: no IAM policy strategy defined or broad permissions used
|
|
||||||
recommendation: architecture specifies granular IAM policies per compartment/service
|
|
||||||
- check_id: SEC-IAM-002
|
|
||||||
pillar: Identity & Access
|
|
||||||
finding: no MFA mentioned in identity setup
|
|
||||||
recommendation: MFA is specified in identity configuration
|
|
||||||
- check_id: SEC-IAM-003
|
|
||||||
pillar: Identity & Access
|
|
||||||
finding: no mention of admin account restrictions
|
|
||||||
recommendation: separate admin groups defined for operations
|
|
||||||
- check_id: SEC-IAM-004
|
|
||||||
pillar: Identity & Access
|
|
||||||
finding: stored credentials or API keys used for service accounts
|
|
||||||
recommendation: instance principals or resource principals specified for service-to-service
|
|
||||||
auth
|
|
||||||
- check_id: SEC-ISO-001
|
|
||||||
pillar: Resource Isolation
|
|
||||||
finding: flat compartment structure or no compartment strategy
|
|
||||||
recommendation: hierarchical compartment structure defined
|
|
||||||
- check_id: SEC-ISO-004
|
|
||||||
pillar: Resource Isolation
|
|
||||||
finding: user credentials used for cross-resource access
|
|
||||||
recommendation: resource principals used for cross-service access
|
|
||||||
- check_id: SEC-DAT-001
|
|
||||||
pillar: Data Protection
|
|
||||||
finding: any storage service without encryption at rest
|
|
||||||
recommendation: encryption at rest specified for all storage services
|
|
||||||
- check_id: SEC-NET-002
|
|
||||||
pillar: Network Security
|
|
||||||
finding: SSH open to 0.0.0.0/0
|
|
||||||
recommendation: SSH access restricted to specific CIDR ranges or bastion
|
|
||||||
- check_id: SEC-MON-001
|
|
||||||
pillar: Monitoring & Audit
|
|
||||||
finding: Cloud Guard not included in architecture
|
|
||||||
recommendation: Cloud Guard configured with appropriate recipes
|
|
||||||
- check_id: OPS-DEP-001
|
|
||||||
pillar: Deployment Strategy
|
|
||||||
finding: no IaC strategy specified
|
|
||||||
recommendation: Terraform or Resource Manager specified for infrastructure provisioning
|
|
||||||
medium_priority:
|
|
||||||
- check_id: SEC-ISO-002
|
|
||||||
pillar: Resource Isolation
|
|
||||||
finding: no tagging strategy defined
|
|
||||||
recommendation: defined tag namespaces with cost, environment, and owner tags
|
|
||||||
- check_id: SEC-DAT-002
|
|
||||||
pillar: Data Protection
|
|
||||||
finding: using Oracle-managed keys or no key management strategy
|
|
||||||
recommendation: OCI Vault specified for key management
|
|
||||||
- check_id: SEC-NET-001
|
|
||||||
pillar: Network Security
|
|
||||||
finding: only Security Lists used without NSGs
|
|
||||||
recommendation: NSGs used as primary network security mechanism
|
|
||||||
- check_id: SEC-NET-004
|
|
||||||
pillar: Network Security
|
|
||||||
finding: OCI services accessed via internet gateway
|
|
||||||
recommendation: Service Gateway configured in VCN
|
|
||||||
- check_id: SEC-MON-002
|
|
||||||
pillar: Monitoring & Audit
|
|
||||||
finding: audit service not mentioned or retention not configured
|
|
||||||
recommendation: Audit service enabled (enabled by default, verify retention)
|
|
||||||
- check_id: SEC-MON-003
|
|
||||||
pillar: Monitoring & Audit
|
|
||||||
finding: no VCN Flow Logs configured
|
|
||||||
recommendation: VCN Flow Logs enabled for critical subnets
|
|
||||||
- check_id: PERF-CST-003
|
|
||||||
pillar: Cost Management
|
|
||||||
finding: no budget alerts configured
|
|
||||||
recommendation: OCI Budgets configured with alert thresholds
|
|
||||||
- check_id: PERF-CST-004
|
|
||||||
pillar: Cost Management
|
|
||||||
finding: no cost attribution tagging strategy
|
|
||||||
recommendation: cost-tracking tags defined and enforced
|
|
||||||
- check_id: OPS-MON-003
|
|
||||||
pillar: Workload Monitoring
|
|
||||||
finding: no centralized log analysis solution
|
|
||||||
recommendation: OCI Logging Analytics configured
|
|
||||||
- check_id: OPS-SUP-002
|
|
||||||
pillar: Operations Support
|
|
||||||
finding: no notification configuration
|
|
||||||
recommendation: OCI Notifications configured for operational events
|
|
||||||
low_priority:
|
|
||||||
- check_id: REL-SCL-002
|
|
||||||
pillar: Scalability
|
|
||||||
finding: no service limit review planned
|
|
||||||
recommendation: service limit review mentioned in deployment plan
|
|
||||||
- check_id: OPS-SUP-001
|
|
||||||
pillar: Operations Support
|
|
||||||
finding: no support plan consideration
|
|
||||||
recommendation: Oracle support plan level specified and matches criticality
|
|
||||||
references:
|
|
||||||
framework: https://docs.oracle.com/en/solutions/oci-best-practices/index.html
|
|
||||||
security: https://docs.oracle.com/en/solutions/oci-best-practices/effective-strategies-security-and-compliance1.html
|
|
||||||
reliability: https://docs.oracle.com/en/solutions/oci-best-practices/reliable-and-resilient-cloud-topology-practices1.html
|
|
||||||
operations: https://docs.oracle.com/en/solutions/oci-best-practices/best-practices-operating-cloud-deployments-efficiency.html
|
|
||||||
distributed: https://docs.oracle.com/en/solutions/oci-best-practices/effective-strategies-distributed-cloud-implementation1.html
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"feature": "Auto Indexing",
|
|
||||||
"category": "auto_management",
|
|
||||||
"compatibility": [
|
|
||||||
{
|
|
||||||
"deployment": "ADB-S Serverless",
|
|
||||||
"version": "23ai",
|
|
||||||
"status": "GA_CAVEAT",
|
|
||||||
"notes": "Creates indexes HIDDEN by default. Instantly reversible. DML costing prevents over-indexing. Deduplication with manually created indexes."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"deployment": "ADB-S Serverless",
|
|
||||||
"version": "26ai",
|
|
||||||
"status": "GA",
|
|
||||||
"notes": "Improved in 26ai \u2014 better cost model, fewer false positives."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"deployment": "ADB-S Elastic Pool",
|
|
||||||
"version": "23ai",
|
|
||||||
"status": "GA_CAVEAT",
|
|
||||||
"notes": "Same as ADB-S Serverless."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"deployment": "ADB Dedicated Elastic Pool (DEP)",
|
|
||||||
"version": "23ai",
|
|
||||||
"status": "GA_CAVEAT",
|
|
||||||
"notes": "Available but less tested in DEP."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"deployment": "ADB on Dedicated Exadata",
|
|
||||||
"version": "23ai",
|
|
||||||
"status": "GA",
|
|
||||||
"notes": "Fully supported."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"deployment": "Exadata Cloud Service",
|
|
||||||
"version": "23ai",
|
|
||||||
"status": "NOT_AVAIL",
|
|
||||||
"notes": "Not an ADB feature. Use manual indexing."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"deployment": "Base DB Service (EE)",
|
|
||||||
"version": "23ai",
|
|
||||||
"status": "NOT_AVAIL",
|
|
||||||
"notes": ""
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"feature": "AI Vector Search (HNSW)",
|
|
||||||
"category": "developer",
|
|
||||||
"compatibility": [
|
|
||||||
{
|
|
||||||
"deployment": "ADB-S Serverless",
|
|
||||||
"version": "23ai",
|
|
||||||
"status": "GA_CAVEAT",
|
|
||||||
"notes": "HNSW indexes are node-local on multi-node RAC. Works well up to\n~50M vectors on single node (\u226424 ECPUs). At 100M+ on multi-node\n(\u226564 ECPUs), requires hash-partitioned vector table with LOCAL\nHNSW index for <50ms P95. See FF-202603-008.\n"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"deployment": "ADB-S Serverless",
|
|
||||||
"version": "26ai",
|
|
||||||
"status": "UNTESTED",
|
|
||||||
"notes": "Check if distributed HNSW is added in 26ai. Expected to remain node-local."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"deployment": "ADB-S Elastic Pool",
|
|
||||||
"version": "23ai",
|
|
||||||
"status": "GA_CAVEAT",
|
|
||||||
"notes": "Same node-local limitation as ADB-S Serverless."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"deployment": "ADB on Dedicated Exadata",
|
|
||||||
"version": "23ai",
|
|
||||||
"status": "GA_CAVEAT",
|
|
||||||
"notes": "Same limitation. Dedicated infra doesn't change HNSW locality."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"deployment": "Exadata Cloud Service",
|
|
||||||
"version": "23ai",
|
|
||||||
"status": "GA_CAVEAT",
|
|
||||||
"notes": "RAC with HNSW has same node-local constraint. Partitioning workaround applies."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"deployment": "Base DB Service (EE)",
|
|
||||||
"version": "23ai",
|
|
||||||
"status": "NOT_AVAIL",
|
|
||||||
"notes": "AI Vector Search requires 23ai. Available in EE but HNSW performance limited without Exadata storage."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"query": "ADB-S vs AWS Aurora PostgreSQL",
|
|
||||||
"aws_database_mappings": [],
|
|
||||||
"relevant_objections": [
|
|
||||||
{
|
|
||||||
"id": "OBJ-001",
|
|
||||||
"category": "market",
|
|
||||||
"objection": "OCI is a niche cloud \u2014 AWS/Azure are the leaders",
|
|
||||||
"response": "OCI is the #4 cloud globally and growing fastest in database PaaS.\nFor Oracle database workloads specifically, OCI is the leader:\n- Only cloud with Exadata hardware\n- Only cloud with Autonomous Database\n- Best Oracle license portability\nThe question isn't \"best cloud overall\" but \"best cloud for this workload.\"\n",
|
|
||||||
"evidence": "Gartner, IDC reports on cloud database market"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "OBJ-004",
|
|
||||||
"category": "features",
|
|
||||||
"objection": "OCI doesn't have as many services as AWS",
|
|
||||||
"response": "True for breadth (AWS has 200+ services). For database and enterprise\nworkloads, OCI feature parity is strong. Key services are GA:\n- Compute, networking, storage, Kubernetes, serverless\n- Monitoring, logging, IAM, security\n- Queue, Streaming, API Gateway\nWhat matters is whether OCI has what *you* need, not a raw count.\n"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "OBJ-005",
|
|
||||||
"category": "cost",
|
|
||||||
"objection": "We can get better pricing from AWS/Azure",
|
|
||||||
"response": "OCI list prices are already 30-50% below AWS for equivalent configs.\nFor Oracle specifically:\n- No Oracle license surcharge on OCI\n- BYOL discount is deeper on OCI\n- Data egress is 10x cheaper\nRequest a detailed cost comparison with matched configurations.\n"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"id": "FF-202603-002",
|
|
||||||
"date": "2026-03-08",
|
|
||||||
"contributor": {
|
|
||||||
"name": "Diego Cabrera",
|
|
||||||
"team": "Field Architecture",
|
|
||||||
"client": "Strategic Migration Customer",
|
|
||||||
"context": "Production migration",
|
|
||||||
"confidence": "validated"
|
|
||||||
},
|
|
||||||
"product": "ADB-S Dedicated Elastic Pool (DEP)",
|
|
||||||
"version": "23ai",
|
|
||||||
"severity": "HIGH",
|
|
||||||
"category": "limitation",
|
|
||||||
"summary": "ADG must be disabled before joining Dedicated Elastic Pool",
|
|
||||||
"detail": "When attempting to add an ADB-S instance with active Autonomous Data Guard\nto a Dedicated Elastic Pool, the operation fails. ADG must be explicitly\ndisabled before the instance can join the DEP. This is a known issue\nacknowledged by Oracle but not prominently documented.\n\nImpact: requires a brief HA gap during DEP onboarding. Must coordinate\nwith the customer's change window.\n",
|
|
||||||
"workaround": "Disable ADG \u2192 join DEP \u2192 re-enable ADG. Plan for ~15 min HA gap.",
|
|
||||||
"oracle_sr": "",
|
|
||||||
"status": "acknowledged",
|
|
||||||
"resolved_date": null,
|
|
||||||
"resolution": null,
|
|
||||||
"affects_matrix": "Elastic Pool Membership",
|
|
||||||
"tags": [
|
|
||||||
"dep",
|
|
||||||
"adg",
|
|
||||||
"elastic-pool",
|
|
||||||
"ha",
|
|
||||||
"limitation"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "FF-202603-003",
|
|
||||||
"date": "2026-03-05",
|
|
||||||
"contributor": {
|
|
||||||
"name": "Diego Cabrera",
|
|
||||||
"team": "Field Architecture",
|
|
||||||
"client": "Strategic Migration Customer",
|
|
||||||
"context": "Production migration",
|
|
||||||
"confidence": "validated"
|
|
||||||
},
|
|
||||||
"product": "ADB-S Dedicated Elastic Pool (DEP)",
|
|
||||||
"version": "23ai",
|
|
||||||
"severity": "MEDIUM",
|
|
||||||
"category": "undocumented",
|
|
||||||
"summary": "DEP maintenance window immutable post-creation \u2014 change only via SR",
|
|
||||||
"detail": "The maintenance window for a Dedicated Elastic Pool cannot be changed\nafter creation via Console, CLI, or API. The only way to change it is\nby filing a Service Request with Oracle Support.\n\nDefault window is Saturday/Sunday. Other days require specifying at\ncreation time (which itself has the CLI bug \u2014 see FF-202603-001) or\nrequesting via SR.\n",
|
|
||||||
"workaround": "Set desired window at DEP creation time (via Console, not CLI). If missed, file SR.",
|
|
||||||
"oracle_sr": "",
|
|
||||||
"status": "acknowledged",
|
|
||||||
"resolved_date": null,
|
|
||||||
"resolution": null,
|
|
||||||
"affects_matrix": "Auto Patching",
|
|
||||||
"tags": [
|
|
||||||
"dep",
|
|
||||||
"maintenance-window",
|
|
||||||
"immutable",
|
|
||||||
"sr-required"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "FF-202603-004",
|
|
||||||
"date": "2026-03-01",
|
|
||||||
"contributor": {
|
|
||||||
"name": "Diego Cabrera",
|
|
||||||
"team": "Field Architecture",
|
|
||||||
"client": "Strategic Migration Customer",
|
|
||||||
"context": "Production migration",
|
|
||||||
"confidence": "validated"
|
|
||||||
},
|
|
||||||
"product": "ADB-S",
|
|
||||||
"version": "23ai",
|
|
||||||
"severity": "MEDIUM",
|
|
||||||
"category": "gotcha",
|
|
||||||
"summary": "DEP provisioning takes days to weeks depending on capacity",
|
|
||||||
"detail": "Dedicated Elastic Pool provisioning is NOT instant like ADB-S Serverless.\nIt requires physical Exadata infrastructure allocation. Lead time varies\nfrom 3 days (if capacity available in region) to 2-3 weeks (if capacity\nneeds to be provisioned).\n\nBilling starts only at AVAILABLE state, not at request time.\nCo-location of existing ADB-S instances happens at the next maintenance\nwindow after joining, not immediately.\n",
|
|
||||||
"workaround": "Request DEP early in the project timeline. Don't make it a critical-path dependency in week 1.",
|
|
||||||
"oracle_sr": "",
|
|
||||||
"status": "acknowledged",
|
|
||||||
"resolved_date": null,
|
|
||||||
"resolution": null,
|
|
||||||
"affects_matrix": "Elastic Pool Membership",
|
|
||||||
"tags": [
|
|
||||||
"dep",
|
|
||||||
"provisioning",
|
|
||||||
"lead-time",
|
|
||||||
"capacity",
|
|
||||||
"planning"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "FF-202603-005",
|
|
||||||
"date": "2026-02-20",
|
|
||||||
"contributor": {
|
|
||||||
"name": "Diego Cabrera",
|
|
||||||
"team": "Field Architecture",
|
|
||||||
"client": "Strategic Migration Customer",
|
|
||||||
"context": "Production migration",
|
|
||||||
"confidence": "observed"
|
|
||||||
},
|
|
||||||
"product": "ADB-S",
|
|
||||||
"version": "23ai",
|
|
||||||
"severity": "LOW",
|
|
||||||
"category": "gotcha",
|
|
||||||
"summary": "Refreshable Clone failed refresh leaves clone in AVAILABLE state \u2014 detection delay",
|
|
||||||
"detail": "When a Refreshable Clone's auto-refresh fails, the clone remains in\nAVAILABLE state (not DISCONNECTED). The Elastic Pool leader monitors\nlifecycle state changes, not refresh success/failure.\n\nThis means a failed refresh is NOT immediately detected by the\ninfrastructure. The stale data window equals the ADG RTO for\nunplanned scenarios.\n",
|
|
||||||
"workaround": "Planned switchovers: DBMS_PROXY_SQL.DISABLE_READ_ONLY_OFFLOAD proactively.\nUnplanned: OCI Events + OCI Function that monitors refresh state and\ndisables read offload automatically. Reduces stale window to ~ADG RTO.\n",
|
|
||||||
"oracle_sr": "",
|
|
||||||
"status": "monitoring",
|
|
||||||
"resolved_date": null,
|
|
||||||
"resolution": null,
|
|
||||||
"affects_matrix": "Refreshable Clone",
|
|
||||||
"tags": [
|
|
||||||
"refreshable-clone",
|
|
||||||
"stale-reads",
|
|
||||||
"elastic-pool",
|
|
||||||
"detection-delay",
|
|
||||||
"proxysql"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"title": "Oracle MAA for Oracle Database@Azure",
|
|
||||||
"url": "https://docs.oracle.com/en/solutions/oracle-maa-db-at-azure/index.html",
|
|
||||||
"type": "reference-architecture",
|
|
||||||
"date": "2025-05",
|
|
||||||
"services": [
|
|
||||||
"exacs",
|
|
||||||
"adg",
|
|
||||||
"vault",
|
|
||||||
"azure"
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"database",
|
|
||||||
"multicloud",
|
|
||||||
"azure",
|
|
||||||
"ha-dr",
|
|
||||||
"autonomous"
|
|
||||||
],
|
|
||||||
"summary": "Cross-AZ Data Guard on ExaCS in Database@Azure. Active Data Guard recommended for cross-AZ replication (block repair, app continuity, read offload). Backups to Autonomous Recovery Service.\n",
|
|
||||||
"terraform": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Implement disaster recovery with local and regional standbys on Oracle Database@Azure",
|
|
||||||
"url": "https://docs.oracle.com/en/solutions/local-regional-standby-dr-db-at-azure/index.html",
|
|
||||||
"type": "reference-architecture",
|
|
||||||
"date": "2025-05",
|
|
||||||
"services": [
|
|
||||||
"exacs",
|
|
||||||
"adg",
|
|
||||||
"azure",
|
|
||||||
"fsdr"
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"database",
|
|
||||||
"multicloud",
|
|
||||||
"azure",
|
|
||||||
"ha-dr"
|
|
||||||
],
|
|
||||||
"summary": "Local standby for HA within same region, regional standby for DR across Azure regions. Uses Active Data Guard with Fast-Start Failover. Recommends Full Stack DR service for automated failover orchestration.\n",
|
|
||||||
"terraform": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Implement disaster recovery with multi-region standby on Oracle Database@Azure",
|
|
||||||
"url": "https://docs.oracle.com/en/solutions/multi-region-standby-dr-db-at-azure/index.html",
|
|
||||||
"type": "reference-architecture",
|
|
||||||
"date": "2025-04",
|
|
||||||
"services": [
|
|
||||||
"exacs",
|
|
||||||
"adg",
|
|
||||||
"azure"
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"database",
|
|
||||||
"multicloud",
|
|
||||||
"azure",
|
|
||||||
"ha-dr"
|
|
||||||
],
|
|
||||||
"summary": "Multi-region DR for ExaCS on Database@Azure using Data Guard. Cross-region standby with switchover/failover capabilities. Includes network topology for Azure-to-Azure cross-region connectivity.\n",
|
|
||||||
"terraform": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Implement cross-region disaster recovery for Exadata Database on Oracle Database@Azure",
|
|
||||||
"url": "https://docs.oracle.com/en/solutions/exadb-dr-on-db-azure/index.html",
|
|
||||||
"type": "reference-architecture",
|
|
||||||
"date": "2025-01",
|
|
||||||
"services": [
|
|
||||||
"exacs",
|
|
||||||
"adg",
|
|
||||||
"azure",
|
|
||||||
"fsdr"
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"database",
|
|
||||||
"multicloud",
|
|
||||||
"azure",
|
|
||||||
"ha-dr"
|
|
||||||
],
|
|
||||||
"summary": "Cross-region DR for ExaCS on Database@Azure. OCI-managed networks for peering (better performance, first 10 TB/month free). Data Guard for cross-region replication, FSDR for automated failover orchestration.\n",
|
|
||||||
"terraform": "https://github.com/oracle-quickstart/oci-multicloud-azure"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Deploy Active Data Guard Far Sync to protect data across Oracle Database@Azure regions",
|
|
||||||
"url": "https://docs.oracle.com/en/solutions/active-data-guard-far-sync-oracle-db-at-azure/index.html",
|
|
||||||
"type": "reference-architecture",
|
|
||||||
"date": "2025-02",
|
|
||||||
"services": [
|
|
||||||
"exacs",
|
|
||||||
"adg",
|
|
||||||
"azure"
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"database",
|
|
||||||
"multicloud",
|
|
||||||
"azure",
|
|
||||||
"ha-dr"
|
|
||||||
],
|
|
||||||
"summary": "Far Sync instance for zero data loss protection across Azure regions. Synchronous redo transport to Far Sync, async to remote standby. Eliminates performance impact of synchronous shipping over long distances.\n",
|
|
||||||
"terraform": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Implement Oracle Database Autonomous Recovery Service with Oracle Database@Azure",
|
|
||||||
"url": "https://docs.oracle.com/en/solutions/implement-recovery-service-db-at-azure/index.html",
|
|
||||||
"type": "reference-architecture",
|
|
||||||
"date": "2025-01",
|
|
||||||
"services": [
|
|
||||||
"exacs",
|
|
||||||
"azure"
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"database",
|
|
||||||
"multicloud",
|
|
||||||
"azure",
|
|
||||||
"ha-dr"
|
|
||||||
],
|
|
||||||
"summary": "Autonomous Recovery Service for ExaCS backups on Database@Azure. Automated backup management with real-time protection and point-in-time recovery capabilities.\n",
|
|
||||||
"terraform": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Deploy Active Data Guard Far Sync to protect data across Oracle Database@AWS regions",
|
|
||||||
"url": "https://docs.oracle.com/en/solutions/dr-active-dg-farsync-db-at-aws/index.html",
|
|
||||||
"type": "reference-architecture",
|
|
||||||
"date": "2025-09",
|
|
||||||
"services": [
|
|
||||||
"exacs",
|
|
||||||
"adg",
|
|
||||||
"aws"
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"database",
|
|
||||||
"multicloud",
|
|
||||||
"aws",
|
|
||||||
"ha-dr"
|
|
||||||
],
|
|
||||||
"summary": "Far Sync instance for zero data loss protection across AWS regions. Synchronous redo to local Far Sync, asynchronous to remote standby. Requires cross-region AWS networking for redo transport.\n",
|
|
||||||
"terraform": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Implement disaster recovery with local and regional standbys on Oracle Database@AWS",
|
|
||||||
"url": "https://docs.oracle.com/en/solutions/dr-local-regional-standby-db-at-aws/index.html",
|
|
||||||
"type": "reference-architecture",
|
|
||||||
"date": "2025-09",
|
|
||||||
"services": [
|
|
||||||
"exacs",
|
|
||||||
"adg",
|
|
||||||
"aws",
|
|
||||||
"fsdr"
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"database",
|
|
||||||
"multicloud",
|
|
||||||
"aws",
|
|
||||||
"ha-dr"
|
|
||||||
],
|
|
||||||
"summary": "Local standby for HA within same AWS region, regional standby for DR. Active Data Guard with Fast-Start Failover for automated failover. Full Stack DR service for cross-service failover orchestration.\n",
|
|
||||||
"terraform": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Implement disaster recovery with cross-zonal Data Guard on Oracle Database@AWS",
|
|
||||||
"url": "https://docs.oracle.com/en/solutions/cross-zone-dr-db-at-aws/index.html",
|
|
||||||
"type": "reference-architecture",
|
|
||||||
"date": "2025-08",
|
|
||||||
"services": [
|
|
||||||
"exacs",
|
|
||||||
"adg",
|
|
||||||
"aws"
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"database",
|
|
||||||
"multicloud",
|
|
||||||
"aws",
|
|
||||||
"ha-dr"
|
|
||||||
],
|
|
||||||
"summary": "Cross-AZ Data Guard on Database@AWS for HA. Synchronous redo transport between AZs for zero data loss. Fast-Start Failover for automatic database switchover on failure.\n",
|
|
||||||
"terraform": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Implement disaster recovery with cross-regional Data Guard on Oracle Database@AWS",
|
|
||||||
"url": "https://docs.oracle.com/en/solutions/implement-dr-cross-region-dg-odb-aws/index.html",
|
|
||||||
"type": "reference-architecture",
|
|
||||||
"date": "2025-08",
|
|
||||||
"services": [
|
|
||||||
"exacs",
|
|
||||||
"adg",
|
|
||||||
"aws"
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"database",
|
|
||||||
"multicloud",
|
|
||||||
"aws",
|
|
||||||
"ha-dr"
|
|
||||||
],
|
|
||||||
"summary": "Cross-region Data Guard on Database@AWS for DR. Asynchronous redo transport for cross-region replication. Planned switchover for maintenance, forced failover for disaster scenarios.\n",
|
|
||||||
"terraform": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Migrate Oracle RAC Databases to OCI",
|
|
||||||
"url": "https://docs.oracle.com/en/solutions/ensure-ha-migrate-vmware-workloads-to-oci/migrate-oracle-rac-databases1.html",
|
|
||||||
"type": "reference-architecture",
|
|
||||||
"date": "2025-01",
|
|
||||||
"services": [
|
|
||||||
"exacs",
|
|
||||||
"exascale",
|
|
||||||
"adb-d",
|
|
||||||
"base-db"
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"database",
|
|
||||||
"migration",
|
|
||||||
"ha-dr"
|
|
||||||
],
|
|
||||||
"summary": "Compares 4 RAC migration targets: ExaCS Dedicated, Exascale, ADB-D, and 2-node RAC on Base DB. ExaCS recommended for full RAC feature parity. Exascale for 23ai-only without dedicated infra commitment.\n",
|
|
||||||
"terraform": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Configure Data Guard for Oracle Exadata Database Service on Dedicated Infrastructure",
|
|
||||||
"url": "https://docs.oracle.com/en/solutions/dataguard-exadata-dedicated-infrastructure/index.html",
|
|
||||||
"type": "reference-architecture",
|
|
||||||
"date": "2025-07",
|
|
||||||
"services": [
|
|
||||||
"exacs",
|
|
||||||
"adg"
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"database",
|
|
||||||
"ha-dr"
|
|
||||||
],
|
|
||||||
"summary": "Data Guard configuration for ExaCS Dedicated. Active Data Guard for read offload and automatic failover. Cross-region standby for DR with Fast-Start Failover for automated switchover.\n",
|
|
||||||
"terraform": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Back up from Exadata on Dedicated Infrastructure to Zero Data Loss Autonomous Recovery",
|
|
||||||
"url": "https://docs.oracle.com/en/solutions/back-up-exadata-dedicated-zero-data-loss-recovery/index.html",
|
|
||||||
"type": "reference-architecture",
|
|
||||||
"date": "2025-07",
|
|
||||||
"services": [
|
|
||||||
"exacs"
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"database",
|
|
||||||
"ha-dr"
|
|
||||||
],
|
|
||||||
"summary": "Autonomous Recovery Service for ExaCS backups. Automated backup management with real-time redo protection. Validates backup recoverability automatically.\n",
|
|
||||||
"terraform": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Configure a standby database for disaster recovery using Oracle Exadata Database Service on Cloud@Customer",
|
|
||||||
"url": "https://docs.oracle.com/en/solutions/exadata-cloud-at-customer-standby/index.html",
|
|
||||||
"type": "reference-architecture",
|
|
||||||
"date": "2025-07",
|
|
||||||
"services": [
|
|
||||||
"exacs",
|
|
||||||
"adg"
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"database",
|
|
||||||
"ha-dr"
|
|
||||||
],
|
|
||||||
"summary": "Data Guard standby on ExaCS Cloud@Customer for hybrid DR. On-premises primary with cloud standby, or cloud primary with on-premises standby for data sovereignty requirements.\n",
|
|
||||||
"terraform": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Learn about maximum availability architecture for PeopleSoft",
|
|
||||||
"url": "https://docs.oracle.com/en/solutions/learn-about-maa-for-peoplesoft/index.html",
|
|
||||||
"type": "reference-architecture",
|
|
||||||
"date": "2024-08",
|
|
||||||
"services": [
|
|
||||||
"exacs",
|
|
||||||
"adg",
|
|
||||||
"load-balancer",
|
|
||||||
"compute"
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"application",
|
|
||||||
"ha-dr",
|
|
||||||
"peoplesoft"
|
|
||||||
],
|
|
||||||
"summary": "MAA for PeopleSoft on OCI. Active Data Guard for database HA/DR, multi-instance PIA behind Load Balancer, Process Scheduler on multiple nodes for batch processing resilience.\n",
|
|
||||||
"terraform": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"title": "Provision and deploy a maximum availability solution for PeopleSoft on Oracle Cloud",
|
|
||||||
"url": "https://docs.oracle.com/en/solutions/deploy-maa-for-peoplesoft-on-oci/index.html",
|
|
||||||
"type": "reference-architecture",
|
|
||||||
"date": "2024-08",
|
|
||||||
"services": [
|
|
||||||
"exacs",
|
|
||||||
"adg",
|
|
||||||
"load-balancer",
|
|
||||||
"compute"
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"application",
|
|
||||||
"ha-dr",
|
|
||||||
"peoplesoft"
|
|
||||||
],
|
|
||||||
"summary": "Step-by-step PeopleSoft MAA deployment on OCI. Terraform stacks for infrastructure provisioning, Data Guard configuration, and PeopleSoft application tier setup.\n",
|
|
||||||
"terraform": null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"id": "FF-202604-001",
|
|
||||||
"date": "2026-04-12",
|
|
||||||
"contributor": {
|
|
||||||
"name": "Carlos Mendoza",
|
|
||||||
"team": "Field Architecture",
|
|
||||||
"date": "2026-04-12",
|
|
||||||
"confidence": "validated",
|
|
||||||
"client": "PharmaCorp Mexico",
|
|
||||||
"context": "ExaCS migration"
|
|
||||||
},
|
|
||||||
"reported_by": "Carlos Mendoza",
|
|
||||||
"product": "ExaCS",
|
|
||||||
"version": "23ai",
|
|
||||||
"severity": "MEDIUM",
|
|
||||||
"category": "gotcha",
|
|
||||||
"summary": "ExaCS iormcal fails silently when storage server count < 3 \u2014 validate IORM plan after apply",
|
|
||||||
"detail": "IORM resource plan apply succeeds but actual I/O shares are not enforced when storage count < 3. Check v$iorm_plans for actual vs requested allocation.",
|
|
||||||
"workaround": "Always validate IORM plan output after apply. Check v$iorm_plans for actual vs requested shares. Minimum 3 storage servers for reliable IORM.",
|
|
||||||
"oracle_sr": "",
|
|
||||||
"status": "open",
|
|
||||||
"resolved_date": null,
|
|
||||||
"resolution": null,
|
|
||||||
"affects_matrix": null,
|
|
||||||
"tags": [
|
|
||||||
"exacs",
|
|
||||||
"iorm",
|
|
||||||
"storage"
|
|
||||||
],
|
|
||||||
"confirmations": []
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
{
|
|
||||||
"summary": {
|
|
||||||
"total_artefacts": 18,
|
|
||||||
"mentioned": 2,
|
|
||||||
"likely_relevant": 0,
|
|
||||||
"possibly_relevant": 9,
|
|
||||||
"not_covered": 7,
|
|
||||||
"skill_auto_generate": 5,
|
|
||||||
"skill_assist": 2
|
|
||||||
},
|
|
||||||
"artefacts": [
|
|
||||||
{
|
|
||||||
"id": "DEF-01",
|
|
||||||
"name": "Customer Profile",
|
|
||||||
"phase": "define",
|
|
||||||
"step": "ideate",
|
|
||||||
"skill_support": "assist",
|
|
||||||
"customer_facing": false,
|
|
||||||
"relevance": "possibly_relevant"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "DEF-02",
|
|
||||||
"name": "Strategy Map",
|
|
||||||
"phase": "define",
|
|
||||||
"step": "ideate",
|
|
||||||
"skill_support": "assist",
|
|
||||||
"customer_facing": false,
|
|
||||||
"relevance": "none"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "DEF-03",
|
|
||||||
"name": "Point of View (PoV)",
|
|
||||||
"phase": "define",
|
|
||||||
"step": "ideate",
|
|
||||||
"skill_support": "auto_generate",
|
|
||||||
"customer_facing": false,
|
|
||||||
"relevance": "possibly_relevant"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "DEF-04",
|
|
||||||
"name": "Ideation Workshop",
|
|
||||||
"phase": "define",
|
|
||||||
"step": "ideate",
|
|
||||||
"skill_support": "reference",
|
|
||||||
"customer_facing": true,
|
|
||||||
"relevance": "possibly_relevant"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "DEF-05",
|
|
||||||
"name": "Business Case",
|
|
||||||
"phase": "define",
|
|
||||||
"step": "ideate",
|
|
||||||
"skill_support": "auto_generate",
|
|
||||||
"customer_facing": true,
|
|
||||||
"relevance": "mentioned"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "DEF-06",
|
|
||||||
"name": "Value Story",
|
|
||||||
"phase": "define",
|
|
||||||
"step": "ideate",
|
|
||||||
"skill_support": "auto_generate",
|
|
||||||
"customer_facing": true,
|
|
||||||
"relevance": "mentioned"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "DEF-07",
|
|
||||||
"name": "Value Story Delivery",
|
|
||||||
"phase": "define",
|
|
||||||
"step": "validate",
|
|
||||||
"skill_support": "assist",
|
|
||||||
"customer_facing": true,
|
|
||||||
"relevance": "possibly_relevant"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "DEF-08",
|
|
||||||
"name": "Joint Engagement Plan (JEP)",
|
|
||||||
"phase": "define",
|
|
||||||
"step": "plan",
|
|
||||||
"skill_support": "auto_generate",
|
|
||||||
"customer_facing": true,
|
|
||||||
"relevance": "possibly_relevant"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "DES-35",
|
|
||||||
"name": "Solution Proposal",
|
|
||||||
"phase": "design",
|
|
||||||
"step": "confirm",
|
|
||||||
"skill_support": "auto_generate",
|
|
||||||
"customer_facing": true,
|
|
||||||
"relevance": "none"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "DES-36",
|
|
||||||
"name": "Partner / Services Proposal",
|
|
||||||
"phase": "design",
|
|
||||||
"step": "confirm",
|
|
||||||
"skill_support": "reference",
|
|
||||||
"customer_facing": true,
|
|
||||||
"relevance": "none"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "DES-37",
|
|
||||||
"name": "RFx Response",
|
|
||||||
"phase": "design",
|
|
||||||
"step": "confirm",
|
|
||||||
"skill_support": "reference",
|
|
||||||
"customer_facing": true,
|
|
||||||
"relevance": "none"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "DES-38",
|
|
||||||
"name": "Commercial Agreement",
|
|
||||||
"phase": "design",
|
|
||||||
"step": "confirm",
|
|
||||||
"skill_support": "reference",
|
|
||||||
"customer_facing": true,
|
|
||||||
"relevance": "none"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "DEL-17",
|
|
||||||
"name": "Cloud Operating Framework",
|
|
||||||
"phase": "deliver",
|
|
||||||
"step": "operate",
|
|
||||||
"skill_support": "reference",
|
|
||||||
"customer_facing": true,
|
|
||||||
"relevance": "possibly_relevant"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "DEL-18",
|
|
||||||
"name": "OCI Operationalization Framework",
|
|
||||||
"phase": "deliver",
|
|
||||||
"step": "operate",
|
|
||||||
"skill_support": "reference",
|
|
||||||
"customer_facing": true,
|
|
||||||
"relevance": "none"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "DEL-19",
|
|
||||||
"name": "Retrospective",
|
|
||||||
"phase": "deliver",
|
|
||||||
"step": "improve",
|
|
||||||
"skill_support": "auto_generate",
|
|
||||||
"customer_facing": false,
|
|
||||||
"relevance": "none"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "DEL-20",
|
|
||||||
"name": "Value Realization",
|
|
||||||
"phase": "deliver",
|
|
||||||
"step": "improve",
|
|
||||||
"skill_support": "auto_generate",
|
|
||||||
"customer_facing": true,
|
|
||||||
"relevance": "possibly_relevant"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "DEL-21",
|
|
||||||
"name": "Customer Success Story",
|
|
||||||
"phase": "deliver",
|
|
||||||
"step": "improve",
|
|
||||||
"skill_support": "reference",
|
|
||||||
"customer_facing": true,
|
|
||||||
"relevance": "possibly_relevant"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "DEL-22",
|
|
||||||
"name": "Improvement Actions",
|
|
||||||
"phase": "deliver",
|
|
||||||
"step": "improve",
|
|
||||||
"skill_support": "reference",
|
|
||||||
"customer_facing": true,
|
|
||||||
"relevance": "possibly_relevant"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,401 +0,0 @@
|
|||||||
# OCI Deal Accelerator — Proposal Spec
|
|
||||||
# Demo: Pharma Mexico — ExaCS migration from on-prem Exadata X6
|
|
||||||
# Prompt 1: Full proposal
|
|
||||||
|
|
||||||
metadata:
|
|
||||||
customer: "PharmaCorp Mexico"
|
|
||||||
project: "Oracle Database Migration to OCI"
|
|
||||||
subtitle: "ExaCS Migration — Exadata X6 to OCI"
|
|
||||||
architect: "Carlos Mendoza"
|
|
||||||
firm: "Oracle Cloud Infrastructure"
|
|
||||||
date: "2026-04-12"
|
|
||||||
|
|
||||||
summary:
|
|
||||||
why: >
|
|
||||||
PharmaCorp Mexico operates 3 Oracle databases (EBS R12, SAP, DW) on on-premises
|
|
||||||
Exadata X6 hardware approaching end-of-life. Hardware refresh costs $1.8M and
|
|
||||||
locks into 5 more years of on-prem operations. Migration to OCI ExaCS provides
|
|
||||||
30%+ cost reduction, eliminates hardware lifecycle management, improves DR posture
|
|
||||||
(RPO<15min, RTO<1h), and enables SOX compliance alignment with Oracle-managed
|
|
||||||
infrastructure patching.
|
|
||||||
current_state:
|
|
||||||
- "3 Oracle DBs on Exadata X6 (EBS R12 prod, SAP, Data Warehouse)"
|
|
||||||
- "200 concurrent users, peak during fiscal close"
|
|
||||||
- "2TB total data across all databases"
|
|
||||||
- "No DR — single site in Mexico City data center"
|
|
||||||
- "Manual patching quarterly — SOX audit finding pending"
|
|
||||||
- "HW refresh due in 6 months ($1.8M quote)"
|
|
||||||
target_state: >
|
|
||||||
OCI ExaCS X11M in Queretaro (prod) with Data Guard standby in Sao Paulo for
|
|
||||||
cross-region DR. Hub-spoke networking via DRG, dual FastConnect 10Gbps for
|
|
||||||
redundancy, WAF for web applications, OCI Vault for TDE key management.
|
|
||||||
SOX compliance via automated patching and audit logging.
|
|
||||||
timeline: "Migration Q3-Q4 2026 (16 weeks) — conditional on board approval Q2"
|
|
||||||
|
|
||||||
service_tiering:
|
|
||||||
- name: "EBS R12 Production"
|
|
||||||
tier: "Platinum"
|
|
||||||
uptime: "99.99%"
|
|
||||||
rto: "1 hour"
|
|
||||||
rpo: "15 minutes"
|
|
||||||
justification: "Business-critical ERP — fiscal close depends on this"
|
|
||||||
- name: "SAP Database"
|
|
||||||
tier: "Gold"
|
|
||||||
uptime: "99.95%"
|
|
||||||
rto: "4 hours"
|
|
||||||
rpo: "1 hour"
|
|
||||||
justification: "Critical but lower user count"
|
|
||||||
- name: "Data Warehouse"
|
|
||||||
tier: "Silver"
|
|
||||||
uptime: "99.9%"
|
|
||||||
rto: "8 hours"
|
|
||||||
rpo: "4 hours"
|
|
||||||
justification: "Reporting — can tolerate longer recovery"
|
|
||||||
|
|
||||||
architecture_principles:
|
|
||||||
design:
|
|
||||||
- id: "D-01"
|
|
||||||
name: "Operational Simplicity"
|
|
||||||
summary: "Prefer managed services with automated patching"
|
|
||||||
rationale: "SOX requires documented patching — ExaCS automates this"
|
|
||||||
- id: "D-05"
|
|
||||||
name: "HA for Business Continuity"
|
|
||||||
summary: "Design for HA proportional to business impact"
|
|
||||||
rationale: "EBS is Platinum tier — needs cross-region DR"
|
|
||||||
- id: "D-06"
|
|
||||||
name: "Defense in Depth"
|
|
||||||
summary: "Multiple security layers, not single perimeter"
|
|
||||||
rationale: "SOX compliance requires network segmentation + encryption + audit"
|
|
||||||
deployment:
|
|
||||||
- id: "P-01"
|
|
||||||
name: "Infrastructure as Code"
|
|
||||||
summary: "All infrastructure defined in Terraform"
|
|
||||||
rationale: "Reproducible environments, audit trail for SOX"
|
|
||||||
|
|
||||||
architecture:
|
|
||||||
description: >
|
|
||||||
Dual-region ExaCS deployment: Queretaro (primary) + Sao Paulo (DR standby).
|
|
||||||
Hub-spoke networking with DRG provides segmented VCNs for each workload tier.
|
|
||||||
Dual FastConnect 10Gbps from Mexico City provides redundant connectivity.
|
|
||||||
WAF protects EBS web tier. OCI Vault manages TDE encryption keys externally.
|
|
||||||
|
|
||||||
Key components:
|
|
||||||
- ExaCS X11M Quarter Rack in Queretaro (3 databases)
|
|
||||||
- Data Guard to ExaCS standby in Sao Paulo (EBS + SAP)
|
|
||||||
- Hub VCN with DRG for inter-spoke routing
|
|
||||||
- Dual FastConnect 10Gbps from Mexico City DC
|
|
||||||
- WAF for EBS R12 web access
|
|
||||||
- OCI Vault for TDE key management (SOX)
|
|
||||||
- OCI Logging + Audit for compliance
|
|
||||||
visual:
|
|
||||||
regions:
|
|
||||||
- name: "Queretaro (Primary)"
|
|
||||||
services:
|
|
||||||
- name: "ExaCS X11M (3 DBs)"
|
|
||||||
type: "database"
|
|
||||||
- name: "Hub VCN + DRG"
|
|
||||||
type: "networking"
|
|
||||||
- name: "WAF"
|
|
||||||
type: "security"
|
|
||||||
- name: "OCI Vault"
|
|
||||||
type: "security"
|
|
||||||
- name: "Sao Paulo (DR)"
|
|
||||||
services:
|
|
||||||
- name: "ExaCS Standby (Data Guard)"
|
|
||||||
type: "database"
|
|
||||||
- name: "Spoke VCN"
|
|
||||||
type: "networking"
|
|
||||||
|
|
||||||
decisions:
|
|
||||||
- title: "ExaCS over ADB-S"
|
|
||||||
decision: "Use ExaCS instead of ADB-S for all 3 databases"
|
|
||||||
rationale: >
|
|
||||||
EBS R12 requires OS-level access for patches and customizations.
|
|
||||||
SAP on Oracle requires specific Oracle features not available on ADB-S.
|
|
||||||
DW benefits from ExaCS storage performance. All 3 share the same
|
|
||||||
Exadata infrastructure for cost efficiency.
|
|
||||||
alternatives: "ADB-S (rejected — EBS needs OS access), DBCS (rejected — no Exadata performance)"
|
|
||||||
|
|
||||||
- title: "Cross-Region DR with Data Guard"
|
|
||||||
decision: "Active Data Guard from Queretaro to Sao Paulo"
|
|
||||||
rationale: >
|
|
||||||
RPO<15min requires real-time redo shipping. Sao Paulo provides geographic
|
|
||||||
separation for disaster scenarios. Data Guard supports automatic failover
|
|
||||||
with FSDR for RTO<1h.
|
|
||||||
alternatives: "Backup/restore (rejected — RPO 24h), Same-region standby (no geo separation)"
|
|
||||||
|
|
||||||
- title: "Hub-Spoke Networking"
|
|
||||||
decision: "Hub VCN with DRG for inter-spoke routing and segmentation"
|
|
||||||
rationale: >
|
|
||||||
SOX requires network segmentation between environments. Hub-spoke with DRG
|
|
||||||
provides centralized security controls (WAF, NSG) while isolating workloads.
|
|
||||||
alternatives: "Flat VCN (rejected — no segmentation for SOX)"
|
|
||||||
|
|
||||||
- title: "Dual FastConnect"
|
|
||||||
decision: "Two 10Gbps FastConnect circuits from Mexico City"
|
|
||||||
rationale: >
|
|
||||||
Single FastConnect is a documented single point of failure. Dual circuits
|
|
||||||
provide redundancy for the EBS production workload.
|
|
||||||
alternatives: "Single FastConnect + IPSec VPN backup (acceptable for lower tiers)"
|
|
||||||
|
|
||||||
- title: "OCI Vault for TDE Keys"
|
|
||||||
decision: "External key management via OCI Vault instead of Oracle Wallet"
|
|
||||||
rationale: >
|
|
||||||
SOX requires separation of duties for encryption key management.
|
|
||||||
OCI Vault provides FIPS 140-2 Level 3 HSM-backed key storage.
|
|
||||||
alternatives: "Oracle Wallet on ExaCS (simpler but no separation of duties)"
|
|
||||||
|
|
||||||
ha_dr:
|
|
||||||
description: >
|
|
||||||
Primary in Queretaro with Active Data Guard to Sao Paulo. FSDR orchestrates
|
|
||||||
failover for RTO<1h. EBS and SAP databases replicated; DW rebuilt from backup
|
|
||||||
in DR scenario (Silver tier — 8h RTO acceptable).
|
|
||||||
tiers:
|
|
||||||
- tier: "Platinum — EBS R12"
|
|
||||||
technology: "ExaCS + Active Data Guard + FSDR automatic failover"
|
|
||||||
rto: "1 hour"
|
|
||||||
rpo: "15 minutes"
|
|
||||||
- tier: "Gold — SAP"
|
|
||||||
technology: "ExaCS + Active Data Guard"
|
|
||||||
rto: "4 hours"
|
|
||||||
rpo: "1 hour"
|
|
||||||
- tier: "Silver — DW"
|
|
||||||
technology: "ExaCS + RMAN backup to Object Storage"
|
|
||||||
rto: "8 hours"
|
|
||||||
rpo: "4 hours"
|
|
||||||
|
|
||||||
security:
|
|
||||||
controls:
|
|
||||||
identity:
|
|
||||||
- "OCI IAM with federated identity (SAML 2.0 to corporate AD)"
|
|
||||||
- "Separate admin compartments per environment"
|
|
||||||
- "MFA required for all admin operations"
|
|
||||||
- "Break-glass emergency access procedures"
|
|
||||||
network:
|
|
||||||
- "Hub-spoke VCN with DRG — workload isolation"
|
|
||||||
- "WAF for EBS R12 web tier"
|
|
||||||
- "NSG rules per spoke — deny-all default"
|
|
||||||
- "Dual FastConnect — no public internet exposure"
|
|
||||||
data_protection:
|
|
||||||
- "TDE encryption at rest with OCI Vault (FIPS 140-2 L3)"
|
|
||||||
- "Encryption in transit (TLS 1.2+ / native Oracle Net encryption)"
|
|
||||||
- "Data Guard redo encryption"
|
|
||||||
monitoring:
|
|
||||||
- "OCI Audit for all API calls (SOX)"
|
|
||||||
- "OCI Logging for network flows"
|
|
||||||
- "Database Audit with Unified Auditing"
|
|
||||||
- "OCI Events + Notifications for security alerts"
|
|
||||||
compliance:
|
|
||||||
- "SOX — automated patching, audit logging, separation of duties"
|
|
||||||
|
|
||||||
environment_catalogue:
|
|
||||||
environments:
|
|
||||||
- name: "Production (Queretaro)"
|
|
||||||
sizing: "ExaCS X11M Quarter Rack — 3 databases (EBS, SAP, DW)"
|
|
||||||
isolation: "Dedicated Exadata infrastructure"
|
|
||||||
cost_pct: 100
|
|
||||||
- name: "DR Standby (Sao Paulo)"
|
|
||||||
sizing: "ExaCS X11M Quarter Rack — 2 databases (EBS, SAP standby)"
|
|
||||||
isolation: "Dedicated Exadata infrastructure"
|
|
||||||
cost_pct: 70
|
|
||||||
- name: "Pre-Production"
|
|
||||||
sizing: "DBCS VM — reduced sizing for testing"
|
|
||||||
isolation: "Shared infrastructure, separate compartment"
|
|
||||||
cost_pct: 15
|
|
||||||
cost_notes:
|
|
||||||
- "DR standby runs at 70% — Active Data Guard requires matching shape"
|
|
||||||
- "Pre-prod uses DBCS VMs, not ExaCS — sufficient for functional testing"
|
|
||||||
- "Dev/Test environment excluded — customer uses on-prem for dev"
|
|
||||||
|
|
||||||
cost:
|
|
||||||
line_items:
|
|
||||||
- component: "ExaCS X11M Quarter Rack — Queretaro (Prod)"
|
|
||||||
monthly_payg: "$18,500"
|
|
||||||
monthly_byol: "$8,200"
|
|
||||||
notes: "2 DB servers, 3 storage servers, BYOL"
|
|
||||||
- component: "ExaCS X11M Quarter Rack — Sao Paulo (DR)"
|
|
||||||
monthly_payg: "$18,500"
|
|
||||||
monthly_byol: "$8,200"
|
|
||||||
notes: "ADG standby, BYOL"
|
|
||||||
- component: "ADB-S 16 ECPU (DW alternative — future)"
|
|
||||||
monthly_payg: "$2,400"
|
|
||||||
monthly_byol: "$1,200"
|
|
||||||
notes: "Optional — migrate DW to ADB-S in Phase 2"
|
|
||||||
- component: "FastConnect 10Gbps Dual"
|
|
||||||
monthly_payg: "$3,060"
|
|
||||||
notes: "2 x $1,530/month"
|
|
||||||
- component: "OCI Vault"
|
|
||||||
monthly_payg: "$500"
|
|
||||||
notes: "HSM-backed key management"
|
|
||||||
- component: "Object Storage (50TB backup)"
|
|
||||||
monthly_payg: "$1,200"
|
|
||||||
notes: "RMAN backups + DW backup"
|
|
||||||
- component: "WAF + Logging + Monitoring"
|
|
||||||
monthly_payg: "$800"
|
|
||||||
notes: "WAF for EBS web tier + audit logging"
|
|
||||||
- component: "TOTAL"
|
|
||||||
monthly_payg: "$45,000"
|
|
||||||
monthly_byol: "$23,200"
|
|
||||||
notes: "BYOL saves 48%"
|
|
||||||
assumptions:
|
|
||||||
- "BYOL assumes existing Oracle Database EE licenses with active support"
|
|
||||||
- "ExaCS pricing based on current OCI list prices"
|
|
||||||
- "DR standby at full rate (ADG requires active compute)"
|
|
||||||
- "No middleware licensing included (EBS app tier on compute VMs)"
|
|
||||||
- "FastConnect partner charges not included"
|
|
||||||
show_byol: true
|
|
||||||
|
|
||||||
cost_comparison:
|
|
||||||
title: "Cost Comparison: On-Prem HW Refresh vs OCI Migration"
|
|
||||||
col_headers: ["Component", "On-Prem Refresh (5yr)", "OCI ExaCS BYOL", "Delta"]
|
|
||||||
rows:
|
|
||||||
- item: "Infrastructure (hardware/cloud)"
|
|
||||||
current: "$1,800,000 upfront"
|
|
||||||
oci: "$278,400/yr"
|
|
||||||
savings: "Break-even at 3.2 years"
|
|
||||||
- item: "Annual Support & Maintenance"
|
|
||||||
current: "$220,000/yr"
|
|
||||||
oci: "Included in OCI"
|
|
||||||
savings: "-100%"
|
|
||||||
- item: "DR Infrastructure"
|
|
||||||
current: "$0 (no DR today)"
|
|
||||||
oci: "$98,400/yr"
|
|
||||||
savings: "New capability — no DR today"
|
|
||||||
- item: "Data Center (power/cooling/space)"
|
|
||||||
current: "$80,000/yr"
|
|
||||||
oci: "$0"
|
|
||||||
savings: "-100%"
|
|
||||||
- item: "DBA Operations (patching)"
|
|
||||||
current: "$60,000/yr (part-time DBA)"
|
|
||||||
oci: "$0 (automated)"
|
|
||||||
savings: "-100%"
|
|
||||||
- item: "TOTAL ANNUAL (year 1)"
|
|
||||||
current: "$2,160,000"
|
|
||||||
oci: "$376,800"
|
|
||||||
savings: "-83%"
|
|
||||||
- item: "TOTAL 3-YEAR TCO"
|
|
||||||
current: "$2,880,000"
|
|
||||||
oci: "$1,130,400"
|
|
||||||
savings: "-$1.75M (61%)"
|
|
||||||
|
|
||||||
migration:
|
|
||||||
phases:
|
|
||||||
- name: "Phase 1: Foundation"
|
|
||||||
duration: "4 weeks"
|
|
||||||
milestones:
|
|
||||||
- "OCI tenancy + networking provisioned"
|
|
||||||
- "ExaCS Quarter Rack deployed in Queretaro"
|
|
||||||
- "FastConnect dual circuits active"
|
|
||||||
- "OCI Vault + IAM configured"
|
|
||||||
- name: "Phase 2: EBS Migration"
|
|
||||||
duration: "6 weeks"
|
|
||||||
milestones:
|
|
||||||
- "EBS R12 database migrated via ZDM"
|
|
||||||
- "Data Guard to Sao Paulo configured"
|
|
||||||
- "WAF + network security validated"
|
|
||||||
- "SOX audit controls verified"
|
|
||||||
- name: "Phase 3: SAP + DW Migration"
|
|
||||||
duration: "4 weeks"
|
|
||||||
milestones:
|
|
||||||
- "SAP database migrated"
|
|
||||||
- "DW migrated + Data Guard for SAP"
|
|
||||||
- "RMAN backup for DW to Object Storage"
|
|
||||||
- name: "Phase 4: Cutover + Decommission"
|
|
||||||
duration: "2 weeks"
|
|
||||||
milestones:
|
|
||||||
- "Production cutover (planned downtime window)"
|
|
||||||
- "On-prem Exadata X6 decommissioned"
|
|
||||||
- "Go-live monitoring active"
|
|
||||||
tools:
|
|
||||||
- "Oracle Zero Downtime Migration (ZDM)"
|
|
||||||
- "Data Guard Broker for DR setup"
|
|
||||||
- "FSDR for failover orchestration"
|
|
||||||
- "Terraform OCI provider for IaC"
|
|
||||||
downtime: "Planned 4-hour maintenance window for final cutover per database"
|
|
||||||
|
|
||||||
operational_raci:
|
|
||||||
model: "co_managed"
|
|
||||||
raci_items:
|
|
||||||
- activity: "ExaCS Infrastructure Patching"
|
|
||||||
oracle: "R"
|
|
||||||
customer: "I"
|
|
||||||
- activity: "Database Patching (quarterly)"
|
|
||||||
oracle: "C"
|
|
||||||
customer: "R"
|
|
||||||
- activity: "Data Guard Monitoring"
|
|
||||||
oracle: "C"
|
|
||||||
customer: "R"
|
|
||||||
- activity: "Backup & Recovery"
|
|
||||||
oracle: "R"
|
|
||||||
customer: "C"
|
|
||||||
- activity: "Security (IAM, NSG, Vault)"
|
|
||||||
oracle: "C"
|
|
||||||
customer: "R"
|
|
||||||
- activity: "SOX Audit Evidence"
|
|
||||||
oracle: "C"
|
|
||||||
customer: "R"
|
|
||||||
- activity: "Capacity Planning"
|
|
||||||
oracle: "C"
|
|
||||||
customer: "R"
|
|
||||||
- activity: "Incident Response (Sev1)"
|
|
||||||
oracle: "C"
|
|
||||||
customer: "R"
|
|
||||||
|
|
||||||
risks:
|
|
||||||
- risk: "EBS R12 customizations may require OS-level changes post-migration"
|
|
||||||
severity: "HIGH"
|
|
||||||
probability: "Medium"
|
|
||||||
mitigation: "Inventory all customizations pre-migration; test in Pre-Prod"
|
|
||||||
- risk: "FastConnect provisioning delay (partner-dependent)"
|
|
||||||
severity: "MEDIUM"
|
|
||||||
probability: "Medium"
|
|
||||||
mitigation: "Start FastConnect provisioning in Phase 1 week 1; IPSec VPN as interim"
|
|
||||||
- risk: "SAP certification on ExaCS version"
|
|
||||||
severity: "MEDIUM"
|
|
||||||
probability: "Low"
|
|
||||||
mitigation: "Verify SAP Note for ExaCS X11M support before migration"
|
|
||||||
- risk: "Data Guard network latency Queretaro-Sao Paulo"
|
|
||||||
severity: "LOW"
|
|
||||||
probability: "Low"
|
|
||||||
mitigation: "Test redo apply lag; async mode acceptable for SAP (Gold tier)"
|
|
||||||
- risk: "SOX audit during migration window"
|
|
||||||
severity: "MEDIUM"
|
|
||||||
probability: "Medium"
|
|
||||||
mitigation: "Maintain dual-write during transition; both systems auditable"
|
|
||||||
|
|
||||||
scorecard:
|
|
||||||
pillars:
|
|
||||||
- name: "Security & Compliance"
|
|
||||||
status: "PASS"
|
|
||||||
passed: 24
|
|
||||||
total: 26
|
|
||||||
- name: "Reliability & Resilience"
|
|
||||||
status: "PASS_WITH_RECOMMENDATIONS"
|
|
||||||
passed: 7
|
|
||||||
total: 8
|
|
||||||
- name: "Performance & Cost"
|
|
||||||
status: "PASS"
|
|
||||||
passed: 5
|
|
||||||
total: 5
|
|
||||||
- name: "Operational Efficiency"
|
|
||||||
status: "PASS_WITH_RECOMMENDATIONS"
|
|
||||||
passed: 9
|
|
||||||
total: 10
|
|
||||||
- name: "Distributed Cloud"
|
|
||||||
status: "NOT_APPLICABLE"
|
|
||||||
passed: 0
|
|
||||||
total: 0
|
|
||||||
recommendations:
|
|
||||||
- "45/49 checks passed — strong architecture for regulated workload"
|
|
||||||
- "Add runbook for Data Guard failover testing (quarterly DR drill)"
|
|
||||||
- "Consider OCI Database Management for centralized fleet monitoring"
|
|
||||||
- "Define capacity growth plan for fiscal close peak periods"
|
|
||||||
|
|
||||||
next_steps:
|
|
||||||
steps:
|
|
||||||
- "Board approval for OCI migration budget — CFO — by 2026-05-15"
|
|
||||||
- "Provision OCI tenancy + ExaCS in Queretaro — Carlos Mendoza — by 2026-06-01"
|
|
||||||
- "Start FastConnect provisioning with partner — Network team — by 2026-06-01"
|
|
||||||
- "EBS customization inventory and Pre-Prod testing — DBA team — by 2026-06-15"
|
|
||||||
- "Schedule SOX audit coordination meeting — Compliance — by 2026-06-01"
|
|
||||||
contact_info: "Carlos Mendoza — Solutions Architect, Oracle Cloud Infrastructure"
|
|
||||||
Binary file not shown.
19
kb/CHANGELOG.md
Normal file
19
kb/CHANGELOG.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# KB Changelog
|
||||||
|
|
||||||
|
Recent changes to the Knowledge Base. The skill shows the latest entry in the welcome banner.
|
||||||
|
|
||||||
|
## 2026-04-14
|
||||||
|
- Diagram generator: icon sizing calibrated from 37 Oracle Architecture Center .drawio files (63px services, 42px gateways)
|
||||||
|
- Diagram generator: auto-sizing containers, DRG placement outside VCN, edge label offsets
|
||||||
|
- Diagram generator: VCN/subnet dash pattern corrected to "4 2" (Oracle ref standard)
|
||||||
|
- New: `scripts/validate-diagram.py` — automated diagram quality validation
|
||||||
|
- New: `--check-links` in refresh_arch_catalog.py — detects broken URLs in catalog
|
||||||
|
- New: weekly KB health CI workflow (`.gitea/workflows/kb-health.yaml`)
|
||||||
|
- SKILL.md: anti-hallucination guardrails (closed whitelist + mandatory pre-generation review)
|
||||||
|
- SKILL.md: structured data intake (Extraction Receipt + Completeness Gate)
|
||||||
|
|
||||||
|
## 2026-04-01
|
||||||
|
- KB validation: 30 corrections across 16 files (ExaCS storage, ECPU transition, IOPS fixes)
|
||||||
|
|
||||||
|
## 2026-03-16
|
||||||
|
- Architecture Center catalog refreshed: 123 entries verified
|
||||||
295
kb/diagram/oracle-ref-measurements.md
Normal file
295
kb/diagram/oracle-ref-measurements.md
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
# Oracle Architecture Center Reference Diagram Measurements
|
||||||
|
|
||||||
|
Extracted from official Oracle .drawio source files (draw.io/28.0.4, Visio-exported).
|
||||||
|
|
||||||
|
Sources analyzed:
|
||||||
|
1. Hub-and-Spoke DRG (hub-spoke-network-drg) - single-region, 3 VCNs, hub topology
|
||||||
|
2. Essbase Cross-Region DR (cross-region-dr-essbase-oci) - dual-region, 3 ADs each, DR topology
|
||||||
|
|
||||||
|
Tool: draw.io 28.0.4 (Electron), exported from Visio (.vsdx)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Canvas / Page Settings
|
||||||
|
|
||||||
|
| Property | Hub-Spoke DRG | Essbase DR |
|
||||||
|
|----------|--------------|------------|
|
||||||
|
| pageWidth | 850 | 850 |
|
||||||
|
| pageHeight | 1100 | 1100 |
|
||||||
|
| gridSize | 10 | 10 |
|
||||||
|
| Actual content W | 693 | 2339 |
|
||||||
|
| Actual content H | 846 | 1154 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Font Specifications
|
||||||
|
|
||||||
|
| Usage | Font Family | Font Size | Style |
|
||||||
|
|-------|------------|-----------|-------|
|
||||||
|
| Region/VCN/service labels | Oracle Sans | 16.93px (~17px) | Bold for region title |
|
||||||
|
| Annotation text | Oracle Sans | 14.82px (~15px) | Regular |
|
||||||
|
| Label box height | - | 20px | - |
|
||||||
|
| Annotation box height | - | 17px | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Color Palette
|
||||||
|
|
||||||
|
| Element | Hex |
|
||||||
|
|---------|-----|
|
||||||
|
| OCI Region fill | #f5f4f2 |
|
||||||
|
| OCI Region stroke | #9e9892 |
|
||||||
|
| VCN / Subnet border (dashed) | #aa643b |
|
||||||
|
| Connector lines / solid borders | #312d2a |
|
||||||
|
| Service icon primary (teal) | #2d5967 |
|
||||||
|
| Connector lines (Essbase) | #161513 |
|
||||||
|
| AD fill (multi-AD diagrams) | #e4e1dd |
|
||||||
|
| Background | #FFFFFF |
|
||||||
|
| Accent brown | #ae562c |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Container Dimensions
|
||||||
|
|
||||||
|
### 4.1 OCI Region
|
||||||
|
- Hub-Spoke: 456x845 at (236,0), fill=#f5f4f2, stroke=#9e9892
|
||||||
|
- Essbase Left: 995x849 at (0,305)
|
||||||
|
- Essbase Right: 1080x849 at (1258,304)
|
||||||
|
- Pattern: Double-rendered (fill layer + stroke layer as separate shapes)
|
||||||
|
|
||||||
|
### 4.2 VCN
|
||||||
|
- DMZ VCN (Hub-Spoke): 226x354 at (456,26)
|
||||||
|
- Spoke VCN 1: 226x205 at (456,406)
|
||||||
|
- Spoke VCN 2: 226x202 at (457,634)
|
||||||
|
- Essbase left VCN: 894x766 at (30,355)
|
||||||
|
- Essbase right VCN: 872x766 at (1328,354)
|
||||||
|
- Style: strokeColor=#aa643b, strokeWidth=2, dashed=1, dashPattern=4.00 2.00
|
||||||
|
|
||||||
|
### 4.3 Subnet
|
||||||
|
- Management Subnet: 176x134 at (498,76)
|
||||||
|
- Services Subnet: 176x134 at (498,238)
|
||||||
|
- Workload Subnet 1: 175x143 at (500,458)
|
||||||
|
- Workload Subnet 2: 175x142 at (500,686)
|
||||||
|
- Style: Same as VCN (strokeColor=#aa643b, strokeWidth=2, dashed)
|
||||||
|
|
||||||
|
### 4.4 Availability Domain (Essbase)
|
||||||
|
- Width: 188, Height: 815
|
||||||
|
- Positions: x=211, 425, 632 (left region), x=1508, 1724, 1930 (right region)
|
||||||
|
- AD-to-AD gap: 18-28px (~25px avg)
|
||||||
|
- Style: fill=#e4e1dd, stroke=#9e9892
|
||||||
|
|
||||||
|
### 4.5 On-Premises Box (Essbase)
|
||||||
|
- 260x251 at (997,0) - centered between two regions
|
||||||
|
- Style: fill=#f5f4f2, stroke=#9e9892
|
||||||
|
|
||||||
|
### 4.6 Workload Rows (Essbase)
|
||||||
|
- Standard row: 807x111
|
||||||
|
- Bottom row (expanded): 807x149
|
||||||
|
- Y positions: 415, 552, 690, 828, 962
|
||||||
|
- Row-to-row gap: 23-27px (~25px)
|
||||||
|
|
||||||
|
### 4.7 Compartment Boxes (Essbase)
|
||||||
|
- 189x49 - placed at AD/row intersections
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Icon Dimensions
|
||||||
|
|
||||||
|
### 5.1 Service Icons (VM, Bastion, Database, etc.)
|
||||||
|
- 3-layer nesting: 63x63 outer > 61x61 middle > 60x60 inner
|
||||||
|
- Standard service icon = 60px rendered in 63px box
|
||||||
|
- Internal details: 13x13 grid cells, 43x13 bottom bar, 3x3 dots
|
||||||
|
|
||||||
|
### 5.2 Gateway Icons (IGW, NAT, SGW, DRG attachments)
|
||||||
|
- 3-layer nesting: 42x42 outer > 41x41 middle > 40x40 inner
|
||||||
|
- Central symbol: 29x26, internal detail: 22x29
|
||||||
|
- Status indicators: 4x4 dots, 5x1 bars
|
||||||
|
|
||||||
|
### 5.3 OCI Toolkit Icons (Bastion-type)
|
||||||
|
- 40x63 outer > 39x61 middle > 34x58 inner (portrait orientation)
|
||||||
|
|
||||||
|
### 5.4 Database/Storage Icons
|
||||||
|
- 59x63 outer > 58x61 middle > inner symbol 39x26 + 21x21
|
||||||
|
|
||||||
|
### 5.5 External Icons (Internet, On-Premises)
|
||||||
|
- Same 63x63 as service icons
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Spacing Rules
|
||||||
|
|
||||||
|
### 6.1 Vertical Spacing
|
||||||
|
| Measurement | Value |
|
||||||
|
|-------------|-------|
|
||||||
|
| Region label Y offset | 7px from region top |
|
||||||
|
| VCN/Subnet label Y offset | 8px from container top |
|
||||||
|
| VCN top to first subnet | 50px |
|
||||||
|
| Subnet-to-subnet gap | 28px |
|
||||||
|
| VCN-to-VCN gap | 26px |
|
||||||
|
| Workload row gap | 23-27px (~25px) |
|
||||||
|
| Gateway Y within VCN | 29px from VCN top |
|
||||||
|
|
||||||
|
### 6.2 Horizontal Spacing
|
||||||
|
| Measurement | Value |
|
||||||
|
|-------------|-------|
|
||||||
|
| VCN to subnet left indent | 42-44px |
|
||||||
|
| Service icon X within subnet | 80px from subnet left |
|
||||||
|
| Gateway X from VCN left | 168px |
|
||||||
|
| AD-to-AD gap | 18-28px (~25px) |
|
||||||
|
| Inter-region gap (Essbase) | 263px |
|
||||||
|
|
||||||
|
### 6.3 Label Spacing
|
||||||
|
| Measurement | Value |
|
||||||
|
|-------------|-------|
|
||||||
|
| Label box height | 20px (standard) |
|
||||||
|
| Annotation box height | 17px |
|
||||||
|
| Line-to-line spacing | 17px |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Connector Styles
|
||||||
|
|
||||||
|
Oracle Visio-exported .drawio files do NOT use draw.io native edges. Connectors are thin rectangles:
|
||||||
|
- Horizontal: Wx1 rectangles, stroke=#312d2a
|
||||||
|
- Vertical: 2xH or 1xH rectangles
|
||||||
|
- Cross-region (Essbase): stroke=#161513
|
||||||
|
- Examples:
|
||||||
|
- Internet-to-Gateway: 350x1 at (80,153)
|
||||||
|
- Mid-connections: 156x1 at (98,524)
|
||||||
|
- DR replication: 549x1 at (391,1045)
|
||||||
|
- Vertical link: 1x65 at (1128,365)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Border/Stroke Styles
|
||||||
|
|
||||||
|
| Element | strokeColor | strokeWidth | dashed | dashPattern |
|
||||||
|
|---------|------------|-------------|--------|-------------|
|
||||||
|
| Region | #9e9892 | 1 | No | - |
|
||||||
|
| VCN | #aa643b | 2 | Yes | 4.00 2.00 |
|
||||||
|
| Subnet | #aa643b | 2 | Yes | 4.00 2.00 |
|
||||||
|
| AD | #9e9892 | 1 | No | - |
|
||||||
|
| Solid grouping | #312d2a | 2 | No | - |
|
||||||
|
| Connector | #312d2a/#161513 | 1 | No | - |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Double-Layer Container Rendering
|
||||||
|
|
||||||
|
Oracle renders each container as TWO overlapping shapes:
|
||||||
|
1. Fill layer: fillColor=<color>, strokeColor=none
|
||||||
|
2. Stroke layer: fillColor=none, strokeColor=<color>
|
||||||
|
|
||||||
|
This pattern applies to Region, VCN, Subnet, and AD containers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Key Proportional Ratios
|
||||||
|
|
||||||
|
| Ratio | Value | Description |
|
||||||
|
|-------|-------|-------------|
|
||||||
|
| Gateway:Service icon | 42:63 = 0.67 | Gateways 2/3 size of service icons |
|
||||||
|
| VCN:Subnet width | 226:176 = 1.28 | VCN ~28% wider than subnet |
|
||||||
|
| VCN left padding | 42-44px | Subnet indent |
|
||||||
|
| VCN top padding | 50px | To first subnet |
|
||||||
|
| VCN bottom padding | ~22-26px | After last subnet |
|
||||||
|
| Region aspect (hub-spoke) | 456:845 = 0.54 | Portrait |
|
||||||
|
| AD aspect (multi-AD) | 188:815 = 0.23 | Very tall/narrow |
|
||||||
|
| Workload row aspect | 807:111 = 7.27 | Very wide/short |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Recommended Calibration for oci_diagram_gen.py
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
region:
|
||||||
|
min_width: 456
|
||||||
|
min_height: 845
|
||||||
|
fill: "#f5f4f2"
|
||||||
|
stroke: "#9e9892"
|
||||||
|
stroke_width: 1
|
||||||
|
corner_radius: 0
|
||||||
|
|
||||||
|
vcn:
|
||||||
|
min_width: 226
|
||||||
|
typical_height_hub: 205
|
||||||
|
typical_height_dmz: 354
|
||||||
|
stroke: "#aa643b"
|
||||||
|
stroke_width: 2
|
||||||
|
dashed: true
|
||||||
|
dash_pattern: "4 2"
|
||||||
|
|
||||||
|
subnet:
|
||||||
|
width: 176
|
||||||
|
typical_height: 134-143
|
||||||
|
stroke: "#aa643b"
|
||||||
|
stroke_width: 2
|
||||||
|
dashed: true
|
||||||
|
dash_pattern: "4 2"
|
||||||
|
|
||||||
|
availability_domain:
|
||||||
|
width: 188
|
||||||
|
height: 815
|
||||||
|
fill: "#e4e1dd"
|
||||||
|
stroke: "#9e9892"
|
||||||
|
|
||||||
|
service_icon:
|
||||||
|
outer: 63
|
||||||
|
rendered: 60
|
||||||
|
|
||||||
|
gateway_icon:
|
||||||
|
outer: 42
|
||||||
|
rendered: 40
|
||||||
|
inner_symbol: 29x26
|
||||||
|
|
||||||
|
spacing:
|
||||||
|
vcn_to_vcn_gap: 26
|
||||||
|
subnet_to_subnet_gap: 28
|
||||||
|
vcn_top_to_first_subnet: 50
|
||||||
|
vcn_left_to_subnet: 42
|
||||||
|
subnet_to_service_icon: 80
|
||||||
|
gateway_x_from_vcn_left: 168
|
||||||
|
gateway_y_from_vcn_top: 29
|
||||||
|
label_y_offset: 8
|
||||||
|
region_label_y: 7
|
||||||
|
workload_row_gap: 25
|
||||||
|
ad_to_ad_gap: 25
|
||||||
|
inter_region_gap: 263
|
||||||
|
|
||||||
|
fonts:
|
||||||
|
family: "Oracle Sans"
|
||||||
|
label_size: 17
|
||||||
|
annotation_size: 15
|
||||||
|
label_box_height: 20
|
||||||
|
annotation_box_height: 17
|
||||||
|
|
||||||
|
colors:
|
||||||
|
region_fill: "#f5f4f2"
|
||||||
|
region_stroke: "#9e9892"
|
||||||
|
vcn_stroke: "#aa643b"
|
||||||
|
subnet_stroke: "#aa643b"
|
||||||
|
ad_fill: "#e4e1dd"
|
||||||
|
icon_primary: "#2d5967"
|
||||||
|
text_dark: "#312d2a"
|
||||||
|
connector: "#312d2a"
|
||||||
|
background: "#FFFFFF"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Layout Patterns
|
||||||
|
|
||||||
|
### Single-Region Hub-Spoke
|
||||||
|
- VCNs stacked vertically
|
||||||
|
- External icons (Internet, On-Prem) at far left
|
||||||
|
- Gateways between external and VCN
|
||||||
|
- DRG centered with route table
|
||||||
|
- Service icons right-aligned within subnets
|
||||||
|
|
||||||
|
### Dual-Region DR
|
||||||
|
- Regions side by side horizontally
|
||||||
|
- On-Premises box centered above/between regions
|
||||||
|
- ADs as vertical columns within each region
|
||||||
|
- Workload tiers as horizontal rows crossing all ADs
|
||||||
|
- Compartment boxes at AD-row intersections
|
||||||
|
- DR arrows as horizontal lines between matching rows
|
||||||
@@ -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:
|
||||||
@@ -1262,6 +1274,54 @@ categories:
|
|||||||
list_price_usd: 0.0003
|
list_price_usd: 0.0003
|
||||||
default_hours_units: 1
|
default_hours_units: 1
|
||||||
billing_type: per_unit
|
billing_type: per_unit
|
||||||
|
data_services:
|
||||||
|
display_name: Oracle Cloud Infrastructure - Data & Analytics Services
|
||||||
|
skus:
|
||||||
|
- sku: B91128
|
||||||
|
product: Oracle Big Data Service - Compute - Standard
|
||||||
|
metric: OCPU Per Hour
|
||||||
|
list_price_usd: 0.1344
|
||||||
|
default_hours_units: 730
|
||||||
|
billing_type: hourly
|
||||||
|
- sku: B91129
|
||||||
|
product: Oracle Big Data Service - Compute - Dense I/O
|
||||||
|
metric: OCPU Per Hour
|
||||||
|
list_price_usd: 0.214
|
||||||
|
default_hours_units: 730
|
||||||
|
billing_type: hourly
|
||||||
|
- sku: B91130
|
||||||
|
product: Oracle Big Data Service - Compute - HPC
|
||||||
|
metric: OCPU Per Hour
|
||||||
|
list_price_usd: 0.1536
|
||||||
|
default_hours_units: 730
|
||||||
|
billing_type: hourly
|
||||||
|
- sku: B93555
|
||||||
|
product: Oracle Big Data Service - Core
|
||||||
|
metric: OCPU Per Hour
|
||||||
|
list_price_usd: 0.015
|
||||||
|
default_hours_units: 730
|
||||||
|
billing_type: hourly
|
||||||
|
- sku: EST-DS-NOTEBOOK
|
||||||
|
product: '[ESTIMATE] OCI Data Science - Notebook Session (billed via underlying VM.Standard compute OCPU)'
|
||||||
|
metric: OCPU Per Hour
|
||||||
|
list_price_usd: 0.0255
|
||||||
|
default_hours_units: 730
|
||||||
|
billing_type: hourly
|
||||||
|
estimate: true
|
||||||
|
- sku: EST-DS-MODEL
|
||||||
|
product: '[ESTIMATE] OCI Data Science - Model Deployment endpoint (billed via underlying VM.Standard compute OCPU)'
|
||||||
|
metric: OCPU Per Hour
|
||||||
|
list_price_usd: 0.0255
|
||||||
|
default_hours_units: 730
|
||||||
|
billing_type: hourly
|
||||||
|
estimate: true
|
||||||
|
- sku: EST-DF-SPARK
|
||||||
|
product: '[ESTIMATE] OCI Data Flow - Managed Spark runtime (billed via underlying VM.Standard compute OCPU)'
|
||||||
|
metric: OCPU Per Hour
|
||||||
|
list_price_usd: 0.0255
|
||||||
|
default_hours_units: 730
|
||||||
|
billing_type: hourly
|
||||||
|
estimate: true
|
||||||
roving_edge:
|
roving_edge:
|
||||||
display_name: Oracle Roving Edge Infrastructure
|
display_name: Oracle Roving Edge Infrastructure
|
||||||
skus:
|
skus:
|
||||||
|
|||||||
@@ -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:
|
||||||
type: "Extreme Flash (EF)"
|
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:
|
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.
|
||||||
storage_servers: 3
|
OCI scales storage by adding individual storage servers, not in racks.
|
||||||
half_rack:
|
|
||||||
ocpus: "504 (X9M: 4 × 126/server)"
|
base_configuration:
|
||||||
memory: "5.43 TB"
|
description: "Minimum elastic configuration — what you get on day-1 before scaling."
|
||||||
storage: "Up to 383 TB usable"
|
db_servers: 2
|
||||||
db_nodes: 4
|
storage_servers: 3
|
||||||
storage_servers: 6
|
|
||||||
full_rack:
|
x11m_elastic_base:
|
||||||
ocpus: "1,008 (X9M: 8 × 126/server)"
|
description: "Datasheet 'Elastic Configuration Example 1' (2 DB + 3 SS, all-elastic SKUs)."
|
||||||
memory: "10.86 TB"
|
source: "ADB-D X11M Datasheet v1.0, Jan 2025, Table 1"
|
||||||
storage: "Up to 766 TB usable"
|
db_servers: 2
|
||||||
db_nodes: 8
|
storage_servers: 3
|
||||||
storage_servers: 12
|
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:
|
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."
|
||||||
|
|||||||
184
scripts/validate-diagram.py
Normal file
184
scripts/validate-diagram.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Diagram quality validator — checks generated .drawio files against
|
||||||
|
Oracle Architecture Center visual standards.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 scripts/validate-diagram.py examples/output-demo-pharma-mx/opt02-architecture.drawio
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
MIN_ICON_H = 45 # Minimum icon height (px) — Oracle refs use 50-60
|
||||||
|
MIN_ICON_W = 30 # Minimum icon width (px)
|
||||||
|
MAX_EMPTY_RATIO = 0.7 # Max empty space ratio in a container
|
||||||
|
MIN_LABEL_FONT = 10 # Minimum label font size
|
||||||
|
MIN_GAP_PX = 5 # Minimum gap between sibling elements
|
||||||
|
|
||||||
|
|
||||||
|
def validate(filepath: str) -> list:
|
||||||
|
"""Return list of (severity, message) tuples."""
|
||||||
|
issues = []
|
||||||
|
tree = ET.parse(filepath)
|
||||||
|
root = tree.getroot()
|
||||||
|
|
||||||
|
icon_groups = []
|
||||||
|
containers = []
|
||||||
|
all_cells = {}
|
||||||
|
|
||||||
|
for cell in root.iter('mxCell'):
|
||||||
|
cid = cell.get('id', '')
|
||||||
|
style = cell.get('style', '')
|
||||||
|
value = cell.get('value', '')
|
||||||
|
geo = cell.find('mxGeometry')
|
||||||
|
all_cells[cid] = cell
|
||||||
|
|
||||||
|
if geo is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
w = float(geo.get('width', 0))
|
||||||
|
h = float(geo.get('height', 0))
|
||||||
|
x = float(geo.get('x', 0))
|
||||||
|
y = float(geo.get('y', 0))
|
||||||
|
|
||||||
|
# Icon groups
|
||||||
|
if 'group' in style and 'connectable=1' in style:
|
||||||
|
icon_groups.append({
|
||||||
|
'id': cid, 'x': x, 'y': y, 'w': w, 'h': h,
|
||||||
|
'parent': cell.get('parent', ''),
|
||||||
|
})
|
||||||
|
if h < MIN_ICON_H:
|
||||||
|
issues.append(('WARN', f'Icon {cid} too short: {h:.0f}px (min {MIN_ICON_H})'))
|
||||||
|
if w < MIN_ICON_W:
|
||||||
|
issues.append(('WARN', f'Icon {cid} too narrow: {w:.0f}px (min {MIN_ICON_W})'))
|
||||||
|
|
||||||
|
# Containers (regions, VCNs, subnets)
|
||||||
|
if value and any(kw in value for kw in ['Region', 'VCN', 'Subnet', 'Oracle Cloud']):
|
||||||
|
containers.append({
|
||||||
|
'id': cid, 'name': value[:40], 'x': x, 'y': y, 'w': w, 'h': h,
|
||||||
|
'parent': cell.get('parent', ''),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check children overflow parents
|
||||||
|
container_map = {}
|
||||||
|
for cell in root.iter('mxCell'):
|
||||||
|
cid = cell.get('id', '')
|
||||||
|
val = cell.get('value', '')
|
||||||
|
geo = cell.find('mxGeometry')
|
||||||
|
if geo is not None and val:
|
||||||
|
container_map[cid] = {
|
||||||
|
'name': val[:50], 'x': float(geo.get('x', 0)),
|
||||||
|
'y': float(geo.get('y', 0)), 'w': float(geo.get('width', 0)),
|
||||||
|
'h': float(geo.get('height', 0)), 'parent': cell.get('parent', ''),
|
||||||
|
}
|
||||||
|
|
||||||
|
for cid, c in container_map.items():
|
||||||
|
parent = container_map.get(c['parent'])
|
||||||
|
if parent:
|
||||||
|
child_right = c['x'] + c['w']
|
||||||
|
child_bottom = c['y'] + c['h']
|
||||||
|
overflow_r = child_right - parent['w']
|
||||||
|
overflow_b = child_bottom - parent['h']
|
||||||
|
if overflow_r > 2:
|
||||||
|
issues.append(('ERROR', f'{c["name"]} overflows {parent["name"]} RIGHT by {overflow_r:.0f}px'))
|
||||||
|
if overflow_b > 2:
|
||||||
|
issues.append(('ERROR', f'{c["name"]} overflows {parent["name"]} BOTTOM by {overflow_b:.0f}px'))
|
||||||
|
|
||||||
|
# Check for overlapping siblings (same parent)
|
||||||
|
by_parent = {}
|
||||||
|
for ig in icon_groups:
|
||||||
|
by_parent.setdefault(ig['parent'], []).append(ig)
|
||||||
|
|
||||||
|
for parent_id, siblings in by_parent.items():
|
||||||
|
siblings.sort(key=lambda s: (s['y'], s['x']))
|
||||||
|
for i in range(len(siblings) - 1):
|
||||||
|
a = siblings[i]
|
||||||
|
b = siblings[i + 1]
|
||||||
|
# Check vertical overlap (same column, x ranges overlap)
|
||||||
|
x_overlap = not (a['x'] + a['w'] < b['x'] or b['x'] + b['w'] < a['x'])
|
||||||
|
if x_overlap:
|
||||||
|
gap = b['y'] - (a['y'] + a['h'])
|
||||||
|
if gap < MIN_GAP_PX:
|
||||||
|
issues.append(('ERROR', f'Overlap between icons in parent {parent_id}: gap={gap:.0f}px'))
|
||||||
|
|
||||||
|
# Check container utilization (warn on mostly empty containers)
|
||||||
|
for cont in containers:
|
||||||
|
children_area = 0
|
||||||
|
for ig in icon_groups:
|
||||||
|
if ig['parent'] == cont['id']:
|
||||||
|
children_area += ig['w'] * ig['h']
|
||||||
|
for child_cont in containers:
|
||||||
|
if child_cont['parent'] == cont['id']:
|
||||||
|
children_area += child_cont['w'] * child_cont['h']
|
||||||
|
cont_area = cont['w'] * cont['h']
|
||||||
|
if cont_area > 0:
|
||||||
|
fill_ratio = children_area / cont_area
|
||||||
|
if fill_ratio < (1 - MAX_EMPTY_RATIO):
|
||||||
|
issues.append(('INFO', f'{cont["name"]}: {fill_ratio:.0%} filled ({cont["w"]:.0f}x{cont["h"]:.0f})'))
|
||||||
|
|
||||||
|
# Check edge labels have offset (mxPoint as="offset")
|
||||||
|
edges_with_label = 0
|
||||||
|
edges_with_offset = 0
|
||||||
|
for cell in root.iter('mxCell'):
|
||||||
|
if cell.get('edge') == '1' and cell.get('value', '').strip():
|
||||||
|
edges_with_label += 1
|
||||||
|
geo = cell.find('mxGeometry')
|
||||||
|
if geo is not None:
|
||||||
|
offset = geo.find('.//mxPoint[@as="offset"]')
|
||||||
|
if offset is not None:
|
||||||
|
edges_with_offset += 1
|
||||||
|
|
||||||
|
if edges_with_label > 0 and edges_with_offset < edges_with_label:
|
||||||
|
issues.append(('WARN', f'{edges_with_label - edges_with_offset}/{edges_with_label} edge labels missing offset'))
|
||||||
|
|
||||||
|
# Check jettySize=auto present
|
||||||
|
has_jetty = False
|
||||||
|
for cell in root.iter('mxCell'):
|
||||||
|
if 'jettySize=auto' in cell.get('style', ''):
|
||||||
|
has_jetty = True
|
||||||
|
break
|
||||||
|
if not has_jetty:
|
||||||
|
issues.append(('WARN', 'No edges with jettySize=auto'))
|
||||||
|
|
||||||
|
# Check container=1 present
|
||||||
|
has_container = False
|
||||||
|
for cell in root.iter('mxCell'):
|
||||||
|
if 'container=1' in cell.get('style', ''):
|
||||||
|
has_container = True
|
||||||
|
break
|
||||||
|
if not has_container:
|
||||||
|
issues.append(('WARN', 'No containers with explicit container=1 style'))
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print(f"Usage: {sys.argv[0]} <diagram.drawio>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
filepath = sys.argv[1]
|
||||||
|
issues = validate(filepath)
|
||||||
|
|
||||||
|
if not issues:
|
||||||
|
print(f"✅ {filepath}: All checks passed")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
errors = [i for i in issues if i[0] == 'ERROR']
|
||||||
|
warns = [i for i in issues if i[0] == 'WARN']
|
||||||
|
infos = [i for i in issues if i[0] == 'INFO']
|
||||||
|
|
||||||
|
print(f"📋 Diagram validation: {filepath}")
|
||||||
|
print(f" {len(errors)} errors, {len(warns)} warnings, {len(infos)} info")
|
||||||
|
print()
|
||||||
|
|
||||||
|
for severity, msg in issues:
|
||||||
|
icon = {'ERROR': '❌', 'WARN': '⚠️', 'INFO': 'ℹ️'}.get(severity, '?')
|
||||||
|
print(f" {icon} [{severity}] {msg}")
|
||||||
|
|
||||||
|
sys.exit(1 if errors else 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
BIN
templates/Oracle_PPT-template_FY26.pptx
Normal file
BIN
templates/Oracle_PPT-template_FY26.pptx
Normal file
Binary file not shown.
@@ -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."
|
||||||
|
|||||||
@@ -151,12 +151,13 @@ class TestKBLinter:
|
|||||||
# (like 'cli', 'maintenance-window', etc.)
|
# (like 'cli', 'maintenance-window', etc.)
|
||||||
|
|
||||||
def test_owner_check(self):
|
def test_owner_check(self):
|
||||||
"""Owner check returns issues for TBD owners."""
|
"""Owner check returns a list and matches the current owners config."""
|
||||||
issues = kb_linter.check_owners()
|
issues = kb_linter.check_owners()
|
||||||
assert isinstance(issues, list)
|
assert isinstance(issues, list)
|
||||||
# We know some owners are TBD
|
# Current config may be fully assigned; if issues exist they should be
|
||||||
tbd_issues = [i for i in issues if "TBD" in i]
|
# the explicit TBD-owner messages emitted by the linter.
|
||||||
assert len(tbd_issues) > 0, "Expected TBD owner issues"
|
for issue in issues:
|
||||||
|
assert "TBD" in issue
|
||||||
|
|
||||||
def test_freshness_check(self):
|
def test_freshness_check(self):
|
||||||
"""Freshness check returns a list of file freshness reports."""
|
"""Freshness check returns a list of file freshness reports."""
|
||||||
@@ -207,34 +208,44 @@ class TestKBCLI:
|
|||||||
# Service changelog tests
|
# Service changelog tests
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
class TestServiceChangelogs:
|
class TestServiceChangelogs:
|
||||||
"""Verify that service files have changelog sections."""
|
"""Verify that service files load correctly and validate changelogs when present."""
|
||||||
|
|
||||||
@pytest.fixture(scope="class")
|
@pytest.fixture(scope="class")
|
||||||
def service_files(self):
|
def service_files(self):
|
||||||
services_dir = KB_DIR / "services"
|
services_dir = KB_DIR / "services"
|
||||||
return list(services_dir.glob("*.yaml"))
|
return list(services_dir.glob("*.yaml"))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _load_service_yaml(filepath):
|
||||||
|
merged = {}
|
||||||
|
with open(filepath, "r", encoding="utf-8") as fh:
|
||||||
|
for doc in yaml.safe_load_all(fh):
|
||||||
|
if isinstance(doc, dict):
|
||||||
|
merged.update(doc)
|
||||||
|
return merged
|
||||||
|
|
||||||
def test_service_files_exist(self, service_files):
|
def test_service_files_exist(self, service_files):
|
||||||
assert len(service_files) > 0
|
assert len(service_files) > 0
|
||||||
|
|
||||||
def test_service_files_have_changelog(self, service_files):
|
def test_service_files_load_as_merged_documents(self, service_files):
|
||||||
"""Each service file should have a changelog section."""
|
"""Service files may be multi-document YAML and should load via safe_load_all."""
|
||||||
for filepath in service_files:
|
for filepath in service_files:
|
||||||
with open(filepath, "r") as fh:
|
data = self._load_service_yaml(filepath)
|
||||||
data = yaml.safe_load(fh)
|
assert isinstance(data, dict), f"Service file '{filepath.name}' did not merge into a dict"
|
||||||
assert "changelog" in data, (
|
assert any(key in data for key in ("service", "description", "swagger_ui", "kb_target_file")), (
|
||||||
f"Service file '{filepath.name}' missing 'changelog' section"
|
f"Service file '{filepath.name}' is missing an identifying top-level key"
|
||||||
)
|
|
||||||
assert len(data["changelog"]) > 0, (
|
|
||||||
f"Service file '{filepath.name}' has empty changelog"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_changelog_entries_have_required_fields(self, service_files):
|
def test_changelog_entries_have_required_fields(self, service_files):
|
||||||
"""Changelog entries must have date, contributor, change."""
|
"""Changelog entries must have date, contributor, change when a changelog exists."""
|
||||||
|
changelog_files = 0
|
||||||
for filepath in service_files:
|
for filepath in service_files:
|
||||||
with open(filepath, "r") as fh:
|
data = self._load_service_yaml(filepath)
|
||||||
data = yaml.safe_load(fh)
|
if "changelog" not in data:
|
||||||
|
continue
|
||||||
|
changelog_files += 1
|
||||||
for entry in data.get("changelog", []):
|
for entry in data.get("changelog", []):
|
||||||
assert "date" in entry, f"Changelog entry in {filepath.name} missing 'date'"
|
assert "date" in entry, f"Changelog entry in {filepath.name} missing 'date'"
|
||||||
assert "contributor" in entry, f"Changelog entry in {filepath.name} missing 'contributor'"
|
assert "contributor" in entry, f"Changelog entry in {filepath.name} missing 'contributor'"
|
||||||
assert "change" in entry, f"Changelog entry in {filepath.name} missing 'change'"
|
assert "change" in entry, f"Changelog entry in {filepath.name} missing 'change'"
|
||||||
|
assert changelog_files > 0, "Expected at least one service file with changelog entries"
|
||||||
|
|||||||
83
tests/test_oci_bizcase_gen_input_aliases.py
Normal file
83
tests/test_oci_bizcase_gen_input_aliases.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
TOOLS_DIR = PROJECT_ROOT / "tools"
|
||||||
|
|
||||||
|
if str(TOOLS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(TOOLS_DIR))
|
||||||
|
|
||||||
|
from oci_bizcase_gen import BusinessCaseDeckGenerator
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_slide_text(gen):
|
||||||
|
texts = []
|
||||||
|
for slide in gen.prs.slides:
|
||||||
|
for shape in slide.shapes:
|
||||||
|
if hasattr(shape, "text"):
|
||||||
|
text = shape.text.strip()
|
||||||
|
if text:
|
||||||
|
texts.append(text)
|
||||||
|
if getattr(shape, "has_table", False):
|
||||||
|
for row in shape.table.rows:
|
||||||
|
for cell in row.cells:
|
||||||
|
text = cell.text.strip()
|
||||||
|
if text:
|
||||||
|
texts.append(text)
|
||||||
|
return "\n".join(texts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_spec_accepts_cover_aliases_and_recommendation_fallback():
|
||||||
|
spec = {
|
||||||
|
"customer": "Acme Corp",
|
||||||
|
"author": "Diego",
|
||||||
|
"generated_on": "2026-04-21",
|
||||||
|
"recommendation": {
|
||||||
|
"commitment_amount": "100K",
|
||||||
|
"commitment_type": "UCM",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
gen = BusinessCaseDeckGenerator.from_spec(spec)
|
||||||
|
text = _collect_slide_text(gen)
|
||||||
|
|
||||||
|
assert "Acme Corp" in text
|
||||||
|
assert "Prepared by: Diego" in text
|
||||||
|
assert "Recommended: 100K UCM commitment" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_spec_accepts_roadmap_aliases_and_mixed_next_steps():
|
||||||
|
spec = {
|
||||||
|
"customer_name": "Acme Corp",
|
||||||
|
"roadmap": {
|
||||||
|
"phases": [
|
||||||
|
{
|
||||||
|
"title": "Assess",
|
||||||
|
"weeks": "2 weeks",
|
||||||
|
"outputs": ["Blueprint"],
|
||||||
|
"milestones": ["Kickoff"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_duration": "6 weeks",
|
||||||
|
},
|
||||||
|
"recommendation": {
|
||||||
|
"summary": "Proceed with phased migration",
|
||||||
|
"next_steps": [
|
||||||
|
"Confirm scope",
|
||||||
|
{"action": "Approve budget", "owner": "Finance", "deadline": "May"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
gen = BusinessCaseDeckGenerator.from_spec(spec)
|
||||||
|
text = _collect_slide_text(gen)
|
||||||
|
|
||||||
|
assert "Assess" in text
|
||||||
|
assert "2 weeks" in text
|
||||||
|
assert "Kickoff" in text
|
||||||
|
assert "Blueprint" in text
|
||||||
|
assert "Confirm scope" in text
|
||||||
|
assert "Approve budget" in text
|
||||||
|
assert "Finance" in text
|
||||||
|
assert "May" in text
|
||||||
80
tests/test_oci_bom_gen_input_aliases.py
Normal file
80
tests/test_oci_bom_gen_input_aliases.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
TOOLS_DIR = PROJECT_ROOT / "tools"
|
||||||
|
|
||||||
|
if str(TOOLS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(TOOLS_DIR))
|
||||||
|
|
||||||
|
from oci_bom_gen import OCIBomGenerator
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_spec_accepts_metadata_and_line_item_aliases():
|
||||||
|
spec = {
|
||||||
|
"customer": "Acme Corp",
|
||||||
|
"project": "Database Migration",
|
||||||
|
"author": "Diego",
|
||||||
|
"reference": "REF-1",
|
||||||
|
"conversion": {
|
||||||
|
"currency": "BRL",
|
||||||
|
"exchange_rate": 5.2,
|
||||||
|
"tax_rate": 0.1,
|
||||||
|
},
|
||||||
|
"line_items": [
|
||||||
|
{
|
||||||
|
"part_number": "SKU-001",
|
||||||
|
"quantity": 2,
|
||||||
|
"units": 730,
|
||||||
|
"discount_pct": 15,
|
||||||
|
"label": "Database",
|
||||||
|
"note": "Primary environment",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
gen = OCIBomGenerator.from_spec(spec)
|
||||||
|
item = gen.line_items[0]
|
||||||
|
|
||||||
|
assert gen.customer == "Acme Corp"
|
||||||
|
assert gen.project == "Database Migration"
|
||||||
|
assert gen.prepared_by == "Diego"
|
||||||
|
assert gen.reference_label == "REF-1"
|
||||||
|
assert gen.conversion["target_currency"] == "BRL"
|
||||||
|
assert item["sku"] == "SKU-001"
|
||||||
|
assert item["qty"] == 2
|
||||||
|
assert item["hours_units"] == 730
|
||||||
|
assert item["discount"] == 15
|
||||||
|
assert item["custom_label"] == "Database"
|
||||||
|
assert item["custom_note"] == "Primary environment"
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_spec_accepts_canonical_sku_and_custom_note():
|
||||||
|
spec = {
|
||||||
|
"line_items": [
|
||||||
|
{
|
||||||
|
"sku": "SKU-002",
|
||||||
|
"qty": 1,
|
||||||
|
"hours_units": 100,
|
||||||
|
"custom_note": "Canonical form",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
gen = OCIBomGenerator.from_spec(spec)
|
||||||
|
item = gen.line_items[0]
|
||||||
|
|
||||||
|
assert item["sku"] == "SKU-002"
|
||||||
|
assert item["qty"] == 1
|
||||||
|
assert item["hours_units"] == 100
|
||||||
|
assert item["custom_note"] == "Canonical form"
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_spec_requires_sku_or_part_number():
|
||||||
|
spec = {"line_items": [{"quantity": 1}]}
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="sku"):
|
||||||
|
OCIBomGenerator.from_spec(spec)
|
||||||
98
tests/test_oci_deck_gen_input_aliases.py
Normal file
98
tests/test_oci_deck_gen_input_aliases.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
TOOLS_DIR = PROJECT_ROOT / "tools"
|
||||||
|
|
||||||
|
if str(TOOLS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(TOOLS_DIR))
|
||||||
|
|
||||||
|
from oci_deck_gen import OCIDeckGenerator
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_slide_text(gen):
|
||||||
|
texts = []
|
||||||
|
for slide in gen.prs.slides:
|
||||||
|
for shape in slide.shapes:
|
||||||
|
if hasattr(shape, "text"):
|
||||||
|
text = shape.text.strip()
|
||||||
|
if text:
|
||||||
|
texts.append(text)
|
||||||
|
if getattr(shape, "has_table", False):
|
||||||
|
for row in shape.table.rows:
|
||||||
|
for cell in row.cells:
|
||||||
|
text = cell.text.strip()
|
||||||
|
if text:
|
||||||
|
texts.append(text)
|
||||||
|
return "\n".join(texts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_spec_accepts_metadata_aliases_and_current_state_string():
|
||||||
|
spec = {
|
||||||
|
"metadata": {
|
||||||
|
"customer_name": "Acme Corp",
|
||||||
|
"project_name": "DB Migration",
|
||||||
|
"prepared_by": "Diego",
|
||||||
|
"title": "Target Architecture",
|
||||||
|
},
|
||||||
|
"summary": {
|
||||||
|
"why": "Modernize platform",
|
||||||
|
"current_state": "Legacy estate",
|
||||||
|
"target_state": "OCI landing zone",
|
||||||
|
"timeline": "Q3",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
gen = OCIDeckGenerator.from_spec(spec)
|
||||||
|
text = _collect_slide_text(gen)
|
||||||
|
|
||||||
|
assert gen.customer == "Acme Corp"
|
||||||
|
assert gen.project == "DB Migration"
|
||||||
|
assert gen.architect == "Diego"
|
||||||
|
assert "Legacy estate" in text
|
||||||
|
assert "OCI landing zone" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_spec_accepts_aliases_for_cost_migration_risk_scorecard_and_next_steps():
|
||||||
|
spec = {
|
||||||
|
"metadata": {"customer": "Acme"},
|
||||||
|
"cost": {
|
||||||
|
"line_items": [
|
||||||
|
{"name": "DB Service", "monthly": "USD 100", "note": "Includes storage"}
|
||||||
|
],
|
||||||
|
"assumptions": ["1 region"],
|
||||||
|
"show_byol": False,
|
||||||
|
},
|
||||||
|
"migration": {
|
||||||
|
"phases": [
|
||||||
|
{"title": "Discover", "weeks": "2 weeks", "tasks": ["Inventory", "Assess"]}
|
||||||
|
],
|
||||||
|
"tools": ["ZDM"],
|
||||||
|
"downtime": "Near-zero",
|
||||||
|
},
|
||||||
|
"risks": [
|
||||||
|
{"title": "Cutover risk", "action": "Pilot first", "severity": "high"}
|
||||||
|
],
|
||||||
|
"scorecard": {
|
||||||
|
"pillars": [
|
||||||
|
{"pillar": "Security", "status": "PASS", "passed": 2, "total": 2}
|
||||||
|
],
|
||||||
|
"recommendations": ["Enable auditing"],
|
||||||
|
},
|
||||||
|
"next_steps": {"next_steps": ["Validate scope"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
gen = OCIDeckGenerator.from_spec(spec)
|
||||||
|
text = _collect_slide_text(gen)
|
||||||
|
|
||||||
|
assert "DB Service" in text
|
||||||
|
assert "USD 100" in text
|
||||||
|
assert "Includes storage" in text
|
||||||
|
assert "Discover" in text
|
||||||
|
assert "Inventory" in text
|
||||||
|
assert "Cutover risk" in text
|
||||||
|
assert "Pilot first" in text
|
||||||
|
assert "Security" in text
|
||||||
|
assert "2/2" in text
|
||||||
|
assert "Validate scope" in text
|
||||||
76
tests/test_oci_diagram_gen_input_aliases.py
Normal file
76
tests/test_oci_diagram_gen_input_aliases.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
TOOLS_DIR = PROJECT_ROOT / "tools"
|
||||||
|
|
||||||
|
if str(TOOLS_DIR) not in sys.path:
|
||||||
|
sys.path.insert(0, str(TOOLS_DIR))
|
||||||
|
|
||||||
|
from oci_diagram_gen import OCIDiagramGenerator
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_spec_accepts_name_aliases_and_generates_ids():
|
||||||
|
spec = {
|
||||||
|
"external": [{"name": "Users"}],
|
||||||
|
"clouds": [{"name": "AWS", "services": [{"name": "EC2 App", "service_type": "ec2"}]}],
|
||||||
|
"tenancy": {
|
||||||
|
"regions": [
|
||||||
|
{
|
||||||
|
"name": "Ashburn",
|
||||||
|
"vcns": [
|
||||||
|
{
|
||||||
|
"name": "App VCN",
|
||||||
|
"subnets": [
|
||||||
|
{"name": "App Subnet", "services": [{"name": "App VM"}]}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"on_prem": {"name": "Primary DC", "services": [{"name": "Legacy DB"}]},
|
||||||
|
}
|
||||||
|
|
||||||
|
gen = OCIDiagramGenerator.from_spec(spec)
|
||||||
|
values = [getattr(obj, "value", "") for obj in gen._objects.values()]
|
||||||
|
|
||||||
|
assert "Users" in values
|
||||||
|
assert "AWS" in values
|
||||||
|
assert "Ashburn" in values
|
||||||
|
assert "VCN App VCN" in values
|
||||||
|
assert "App Subnet" in values
|
||||||
|
assert "App VM" in values
|
||||||
|
assert "Primary DC" in values
|
||||||
|
assert "Legacy DB" in values
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_spec_accepts_source_target_and_omits_unresolved_connections():
|
||||||
|
spec = {
|
||||||
|
"external": [{"name": "Users"}],
|
||||||
|
"tenancy": {
|
||||||
|
"regions": [
|
||||||
|
{
|
||||||
|
"name": "Ashburn",
|
||||||
|
"vcns": [
|
||||||
|
{
|
||||||
|
"name": "App VCN",
|
||||||
|
"subnets": [
|
||||||
|
{"name": "App Subnet", "services": [{"name": "App VM"}]}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"connections": [
|
||||||
|
{"source": "Users", "target": "App VM", "type": "standard", "label": "HTTPS"},
|
||||||
|
{"from": "Users", "to": "App VM", "type": "standard", "label": "Duplicate"},
|
||||||
|
{"source": "Users", "target": "Missing VM", "type": "standard", "label": "Broken"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
gen = OCIDiagramGenerator.from_spec(spec)
|
||||||
|
|
||||||
|
assert gen._connection_count == 1
|
||||||
@@ -28,6 +28,37 @@ from pptx.enum.shapes import MSO_SHAPE
|
|||||||
from oci_pptx_base import Colors, Layouts, OraclePresBase
|
from oci_pptx_base import Colors, Layouts, OraclePresBase
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Defensive input helpers
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def pick(mapping: dict, *keys, default=""):
|
||||||
|
"""Return the first non-empty value for any of the provided keys."""
|
||||||
|
if not isinstance(mapping, dict):
|
||||||
|
return default
|
||||||
|
for key in keys:
|
||||||
|
if key in mapping:
|
||||||
|
value = mapping.get(key)
|
||||||
|
if value is not None:
|
||||||
|
return value
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def pick_list(mapping: dict, *keys):
|
||||||
|
"""Return the first list-like field normalized to a list."""
|
||||||
|
if not isinstance(mapping, dict):
|
||||||
|
return []
|
||||||
|
for key in keys:
|
||||||
|
if key in mapping:
|
||||||
|
value = mapping.get(key)
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
return [value]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Business Case Deck Generator
|
# Business Case Deck Generator
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -461,10 +492,10 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
|||||||
for i, phase in enumerate(phases[:4]):
|
for i, phase in enumerate(phases[:4]):
|
||||||
color = phase_colors[i % len(phase_colors)]
|
color = phase_colors[i % len(phase_colors)]
|
||||||
y = content_start + i * row_h
|
y = content_start + i * row_h
|
||||||
name = phase.get("name", f"Phase {i+1}")
|
name = pick(phase, "name", "title", default=f"Phase {i+1}")
|
||||||
duration = phase.get("duration", "")
|
duration = pick(phase, "duration", "weeks")
|
||||||
deliverables = phase.get("deliverables", [])
|
deliverables = pick_list(phase, "deliverables", "outputs")
|
||||||
quick_wins = phase.get("quick_wins", [])
|
quick_wins = pick_list(phase, "quick_wins", "milestones")
|
||||||
|
|
||||||
# Alternating row background
|
# Alternating row background
|
||||||
if i % 2 == 0:
|
if i % 2 == 0:
|
||||||
@@ -593,10 +624,10 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
|||||||
|
|
||||||
# Slide 1: Cover
|
# Slide 1: Cover
|
||||||
gen.add_cover_slide(
|
gen.add_cover_slide(
|
||||||
customer=bc.get("customer_name", ""),
|
customer=pick(bc, "customer_name", "customer"),
|
||||||
subtitle="Business Case for Oracle Cloud Infrastructure",
|
subtitle="Business Case for Oracle Cloud Infrastructure",
|
||||||
prepared_by=bc.get("prepared_by", ""),
|
prepared_by=pick(bc, "prepared_by", "author"),
|
||||||
date=bc.get("date", ""),
|
date=pick(bc, "date", "generated_on"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Slide 2: Executive Summary
|
# Slide 2: Executive Summary
|
||||||
@@ -606,20 +637,56 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
|||||||
|
|
||||||
# Slide 3: Business Drivers
|
# Slide 3: Business Drivers
|
||||||
drivers_data = bc.get("drivers", {})
|
drivers_data = bc.get("drivers", {})
|
||||||
if drivers_data:
|
# Accept primary_driver at the top level as well (common user shape)
|
||||||
|
top_primary = pick(bc, "primary_driver", "main_driver")
|
||||||
|
# Accept a plain string or list for drivers
|
||||||
|
if isinstance(drivers_data, str):
|
||||||
|
drivers_data = {"primary": drivers_data}
|
||||||
|
elif isinstance(drivers_data, list):
|
||||||
|
drivers_data = {"items": drivers_data}
|
||||||
|
elif not isinstance(drivers_data, dict):
|
||||||
|
drivers_data = {}
|
||||||
|
|
||||||
|
if drivers_data or top_primary:
|
||||||
driver_statements = []
|
driver_statements = []
|
||||||
primary = drivers_data.get("primary", "")
|
primary = pick(drivers_data, "primary", "primary_driver", "main_driver") or top_primary
|
||||||
urgency = drivers_data.get("urgency", "")
|
urgency = pick(drivers_data, "urgency", "why_now")
|
||||||
coi = drivers_data.get("cost_of_inaction", {})
|
coi = drivers_data.get("cost_of_inaction", {}) or {}
|
||||||
|
|
||||||
if primary:
|
if primary:
|
||||||
driver_statements.append(f"Primary Driver: {primary.replace('_', ' ').title()}\n{urgency}" if urgency else primary.replace('_', ' ').title())
|
primary_text = primary if isinstance(primary, str) else str(primary)
|
||||||
if coi.get("financial"):
|
# Only rewrite snake_case enum tokens ("compliance_driven"); leave natural
|
||||||
driver_statements.append(f"Financial Impact of Inaction\n{coi['financial']}")
|
# language intact so values like "Soberanía de datos + compliance regulatorio"
|
||||||
if coi.get("operational"):
|
# render verbatim.
|
||||||
driver_statements.append(f"Operational Impact\n{coi['operational']}")
|
if "_" in primary_text and " " not in primary_text and len(primary_text) <= 40:
|
||||||
elif coi.get("strategic"):
|
headline = f"Primary Driver: {primary_text.replace('_', ' ').title()}"
|
||||||
driver_statements.append(f"Strategic Risk\n{coi['strategic']}")
|
else:
|
||||||
|
headline = primary_text
|
||||||
|
driver_statements.append(f"{headline}\n{urgency}" if urgency else headline)
|
||||||
|
|
||||||
|
# Spec-provided additional cards take precedence over the inaction fallback
|
||||||
|
extra_items = pick_list(drivers_data, "items", "additional", "secondary", "cards")
|
||||||
|
for item in extra_items:
|
||||||
|
if isinstance(item, str) and item.strip():
|
||||||
|
driver_statements.append(item.strip())
|
||||||
|
elif isinstance(item, dict):
|
||||||
|
label = pick(item, "label", "headline", "title", "name")
|
||||||
|
desc = pick(item, "detail", "description", "text", "body")
|
||||||
|
if label and desc:
|
||||||
|
driver_statements.append(f"{label}\n{desc}")
|
||||||
|
elif label or desc:
|
||||||
|
driver_statements.append(label or desc)
|
||||||
|
|
||||||
|
if not extra_items:
|
||||||
|
financial = pick(coi, "financial")
|
||||||
|
operational = pick(coi, "operational")
|
||||||
|
strategic = pick(coi, "strategic")
|
||||||
|
if financial:
|
||||||
|
driver_statements.append(f"Financial Impact of Inaction\n{financial}")
|
||||||
|
if operational:
|
||||||
|
driver_statements.append(f"Operational Impact\n{operational}")
|
||||||
|
elif strategic:
|
||||||
|
driver_statements.append(f"Strategic Risk\n{strategic}")
|
||||||
|
|
||||||
if driver_statements:
|
if driver_statements:
|
||||||
gen.add_business_drivers_slide(driver_statements)
|
gen.add_business_drivers_slide(driver_statements)
|
||||||
@@ -641,8 +708,8 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
|||||||
|
|
||||||
# Slide 7: Risk Assessment
|
# Slide 7: Risk Assessment
|
||||||
risks = bc.get("risks", {})
|
risks = bc.get("risks", {})
|
||||||
migration_risks = risks.get("migration_risks", [])
|
migration_risks = pick_list(risks, "migration_risks")
|
||||||
do_nothing_risks = risks.get("do_nothing_risks", [])
|
do_nothing_risks = pick_list(risks, "do_nothing_risks")
|
||||||
if migration_risks or do_nothing_risks:
|
if migration_risks or do_nothing_risks:
|
||||||
gen.add_risk_slide(migration_risks, do_nothing_risks)
|
gen.add_risk_slide(migration_risks, do_nothing_risks)
|
||||||
|
|
||||||
@@ -650,26 +717,22 @@ class BusinessCaseDeckGenerator(OraclePresBase):
|
|||||||
roadmap = bc.get("roadmap", {})
|
roadmap = bc.get("roadmap", {})
|
||||||
if roadmap.get("phases"):
|
if roadmap.get("phases"):
|
||||||
gen.add_roadmap_slide(
|
gen.add_roadmap_slide(
|
||||||
phases=roadmap["phases"],
|
phases=pick_list(roadmap, "phases"),
|
||||||
total_duration=roadmap.get("total_duration", ""),
|
total_duration=pick(roadmap, "total_duration"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Slide 9: Recommendation
|
# Slide 9: Recommendation
|
||||||
rec = bc.get("recommendation", {})
|
rec = bc.get("recommendation", {})
|
||||||
if rec:
|
if rec:
|
||||||
summary = rec.get("summary", "")
|
summary = pick(rec, "summary")
|
||||||
if not summary and rec.get("commitment_amount"):
|
if not summary and rec.get("commitment_amount"):
|
||||||
summary = f"Recommended: {rec['commitment_amount']} {rec.get('commitment_type', 'UCM')} commitment"
|
summary = f"Recommended: {rec['commitment_amount']} {rec.get('commitment_type', 'UCM')} commitment"
|
||||||
if summary:
|
if summary:
|
||||||
gen.add_recommendation_slide(
|
gen.add_recommendation_slide(
|
||||||
summary=summary,
|
summary=summary,
|
||||||
next_steps=rec.get("next_steps", []),
|
next_steps=pick_list(rec, "next_steps"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Closing slides
|
|
||||||
prepared_by = bc.get("prepared_by", "")
|
|
||||||
gen.add_closing_slide(name=prepared_by)
|
|
||||||
|
|
||||||
return gen
|
return gen
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,18 @@ TAIL_COLS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def pick(mapping: dict, *keys, default=""):
|
||||||
|
"""Return the first non-empty value for any of the provided keys."""
|
||||||
|
if not isinstance(mapping, dict):
|
||||||
|
return default
|
||||||
|
for key in keys:
|
||||||
|
if key in mapping:
|
||||||
|
value = mapping.get(key)
|
||||||
|
if value is not None:
|
||||||
|
return value
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
class OCIBomGenerator:
|
class OCIBomGenerator:
|
||||||
"""Generate OCI Bill of Materials (.xlsx) with Oracle Redwood styling."""
|
"""Generate OCI Bill of Materials (.xlsx) with Oracle Redwood styling."""
|
||||||
|
|
||||||
@@ -151,6 +163,16 @@ class OCIBomGenerator:
|
|||||||
sku = str(sku)
|
sku = str(sku)
|
||||||
cat_entry = self.catalog.get(sku, {})
|
cat_entry = self.catalog.get(sku, {})
|
||||||
|
|
||||||
|
note = custom_note
|
||||||
|
if not cat_entry:
|
||||||
|
warn = f"[WARN] SKU {sku!r} not in catalog — included as line item with $0 price and warning note"
|
||||||
|
print(warn, file=sys.stderr)
|
||||||
|
flag = "⚠ SKU not in catalog — estimated $0, must be confirmed before quoting"
|
||||||
|
note = f"{note}. {flag}" if note else flag
|
||||||
|
elif cat_entry.get("estimate"):
|
||||||
|
flag = "⚠ Estimate only — billed via underlying compute/storage; confirm actual shape + hours"
|
||||||
|
note = f"{note}. {flag}" if note else flag
|
||||||
|
|
||||||
item = {
|
item = {
|
||||||
"sku": sku,
|
"sku": sku,
|
||||||
"product": cat_entry.get("product", f"Unknown SKU ({sku})"),
|
"product": cat_entry.get("product", f"Unknown SKU ({sku})"),
|
||||||
@@ -164,7 +186,7 @@ class OCIBomGenerator:
|
|||||||
"discountable": cat_entry.get("discountable", True),
|
"discountable": cat_entry.get("discountable", True),
|
||||||
"billing_type": cat_entry.get("billing_type", "hourly"),
|
"billing_type": cat_entry.get("billing_type", "hourly"),
|
||||||
"custom_label": custom_label,
|
"custom_label": custom_label,
|
||||||
"custom_note": custom_note,
|
"custom_note": note,
|
||||||
}
|
}
|
||||||
self.line_items.append(item)
|
self.line_items.append(item)
|
||||||
|
|
||||||
@@ -182,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"
|
||||||
@@ -295,13 +328,29 @@ 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)
|
||||||
|
|
||||||
for cat_key in self.category_order:
|
# Ensure categories present in items but missing from catalog order
|
||||||
|
# (e.g., "other" from unknown SKUs) still render at the end.
|
||||||
|
render_order = list(self.category_order)
|
||||||
|
for cat_key in items_by_cat:
|
||||||
|
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:
|
if cat_key not in items_by_cat:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
cat_items = items_by_cat[cat_key]
|
cat_items = items_by_cat[cat_key]
|
||||||
display_name = self.category_names.get(cat_key, cat_key)
|
display_name = self.category_names.get(
|
||||||
|
cat_key,
|
||||||
|
"Uncategorized — confirm SKUs" if cat_key == "other" else cat_key,
|
||||||
|
)
|
||||||
|
|
||||||
# Category header row
|
# Category header row
|
||||||
ws.cell(row=row, column=COL_PRODUCT, value=display_name).font = cat_font
|
ws.cell(row=row, column=COL_PRODUCT, value=display_name).font = cat_font
|
||||||
@@ -312,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
|
||||||
@@ -324,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
|
||||||
@@ -382,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)
|
||||||
@@ -416,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'
|
||||||
|
|
||||||
@@ -444,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
|
||||||
@@ -463,17 +494,15 @@ 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):
|
mw_l = get_column_letter(COL_M_W)
|
||||||
cell_val = ws.cell(row=item_row, column=COL_M_W).value
|
for item_row in data_item_rows:
|
||||||
if cell_val and str(cell_val).startswith("=") and "SUM" not in str(cell_val):
|
ws.cell(
|
||||||
mw_l = get_column_letter(COL_M_W)
|
row=item_row, column=COL_PCT,
|
||||||
ws.cell(
|
value=f"=IF({mw_l}{total_row}=0,0,{mw_l}{item_row}/{mw_l}{total_row})",
|
||||||
row=item_row, column=COL_PCT,
|
)
|
||||||
value=f"=IF({mw_l}{total_row}=0,0,{mw_l}{item_row}/{mw_l}{total_row})",
|
ws.cell(row=item_row, column=COL_PCT).number_format = '0.0%'
|
||||||
)
|
ws.cell(row=item_row, column=COL_PCT).font = data_font
|
||||||
ws.cell(row=item_row, column=COL_PCT).number_format = '0.0%'
|
ws.cell(row=item_row, column=COL_PCT).border = thin_border
|
||||||
ws.cell(row=item_row, column=COL_PCT).font = data_font
|
|
||||||
ws.cell(row=item_row, column=COL_PCT).border = thin_border
|
|
||||||
|
|
||||||
# ── Notes ─────────────────────────────────────────────────
|
# ── Notes ─────────────────────────────────────────────────
|
||||||
if self.notes:
|
if self.notes:
|
||||||
@@ -567,11 +596,19 @@ class OCIBomGenerator:
|
|||||||
cat = item["category"]
|
cat = item["category"]
|
||||||
items_by_cat.setdefault(cat, []).append(item)
|
items_by_cat.setdefault(cat, []).append(item)
|
||||||
|
|
||||||
for cat_key in self.category_order:
|
render_order = list(self.category_order)
|
||||||
|
for cat_key in items_by_cat:
|
||||||
|
if cat_key not in render_order:
|
||||||
|
render_order.append(cat_key)
|
||||||
|
|
||||||
|
for cat_key in render_order:
|
||||||
if cat_key not in items_by_cat:
|
if cat_key not in items_by_cat:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
display_name = self.category_names.get(cat_key, cat_key)
|
display_name = self.category_names.get(
|
||||||
|
cat_key,
|
||||||
|
"Uncategorized — confirm SKUs" if cat_key == "other" else cat_key,
|
||||||
|
)
|
||||||
|
|
||||||
# Category header
|
# Category header
|
||||||
ws_bom.cell(row=row, column=4, value=display_name).font = cat_font
|
ws_bom.cell(row=row, column=4, value=display_name).font = cat_font
|
||||||
@@ -590,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'
|
||||||
@@ -667,17 +702,22 @@ class OCIBomGenerator:
|
|||||||
def from_spec(cls, spec: dict, catalog_path: Optional[str] = None) -> "OCIBomGenerator":
|
def from_spec(cls, spec: dict, catalog_path: Optional[str] = None) -> "OCIBomGenerator":
|
||||||
"""Build BOM from a YAML specification dict."""
|
"""Build BOM from a YAML specification dict."""
|
||||||
bom = spec.get("bom", spec)
|
bom = spec.get("bom", spec)
|
||||||
|
conversion = bom.get("conversion")
|
||||||
|
if isinstance(conversion, dict):
|
||||||
|
conversion = dict(conversion)
|
||||||
|
if "target_currency" not in conversion and "currency" in conversion:
|
||||||
|
conversion["target_currency"] = conversion.get("currency")
|
||||||
|
|
||||||
gen = cls(
|
gen = cls(
|
||||||
customer=bom.get("customer_name", ""),
|
customer=pick(bom, "customer_name", "customer"),
|
||||||
project=bom.get("project_name", ""),
|
project=pick(bom, "project_name", "project"),
|
||||||
prepared_by=bom.get("prepared_by", ""),
|
prepared_by=pick(bom, "prepared_by", "author"),
|
||||||
bom_date=bom.get("date", ""),
|
bom_date=bom.get("date", ""),
|
||||||
reference_label=bom.get("reference_label", ""),
|
reference_label=pick(bom, "reference_label", "reference"),
|
||||||
currency=bom.get("currency", "USD"),
|
currency=bom.get("currency", "USD"),
|
||||||
realm=bom.get("realm", ""),
|
realm=bom.get("realm", ""),
|
||||||
service_type=bom.get("service_type", ""),
|
service_type=bom.get("service_type", ""),
|
||||||
conversion=bom.get("conversion"),
|
conversion=conversion,
|
||||||
notes=bom.get("notes"),
|
notes=bom.get("notes"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -688,14 +728,17 @@ class OCIBomGenerator:
|
|||||||
global_discount = bom_meta.get("discount_pct", bom.get("discount_pct", 0.0))
|
global_discount = bom_meta.get("discount_pct", bom.get("discount_pct", 0.0))
|
||||||
|
|
||||||
for item in bom.get("line_items", []):
|
for item in bom.get("line_items", []):
|
||||||
|
sku = pick(item, "sku", "part_number")
|
||||||
|
if sku in ("", None):
|
||||||
|
raise ValueError("BOM line item requires 'sku' or 'part_number'")
|
||||||
gen.add_line_item(
|
gen.add_line_item(
|
||||||
sku=str(item["sku"]),
|
sku=str(sku),
|
||||||
qty=item.get("qty", 0),
|
qty=pick(item, "qty", "quantity", default=0),
|
||||||
hours_units=item.get("hours_units"),
|
hours_units=pick(item, "hours_units", "units", "hours", default=None),
|
||||||
months=item.get("months", 12),
|
months=item.get("months", 12),
|
||||||
discount=item.get("discount", global_discount),
|
discount=pick(item, "discount", "discount_pct", default=global_discount),
|
||||||
custom_label=item.get("custom_label", ""),
|
custom_label=pick(item, "custom_label", "label"),
|
||||||
custom_note=item.get("custom_note", ""),
|
custom_note=pick(item, "custom_note", "note", "notes"),
|
||||||
)
|
)
|
||||||
|
|
||||||
return gen
|
return gen
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -29,6 +30,579 @@ from pptx.enum.shapes import MSO_SHAPE
|
|||||||
from oci_pptx_base import Colors, Layouts, OraclePresBase
|
from oci_pptx_base import Colors, Layouts, OraclePresBase
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Defensive input helpers
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def pick(mapping: dict, *keys, default=""):
|
||||||
|
"""Return the first non-empty value for any of the provided keys."""
|
||||||
|
if not isinstance(mapping, dict):
|
||||||
|
return default
|
||||||
|
for key in keys:
|
||||||
|
if key in mapping:
|
||||||
|
value = mapping.get(key)
|
||||||
|
if value is not None:
|
||||||
|
return value
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_list(value):
|
||||||
|
"""Accept list, string, tuple, or None and normalize to a list."""
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
return list(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [value] if value else []
|
||||||
|
return [value]
|
||||||
|
|
||||||
|
|
||||||
|
def pick_list(mapping: dict, *keys):
|
||||||
|
"""Return the first list-like field normalized to a list."""
|
||||||
|
if not isinstance(mapping, dict):
|
||||||
|
return []
|
||||||
|
for key in keys:
|
||||||
|
if key in mapping:
|
||||||
|
return coerce_list(mapping.get(key))
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# MCP flat-spec adapter
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _is_flat_spec(spec: dict) -> bool:
|
||||||
|
"""MCP payload shape: no 'metadata' key, but has customer_name/id/title."""
|
||||||
|
if not isinstance(spec, dict):
|
||||||
|
return False
|
||||||
|
if "metadata" in spec:
|
||||||
|
return False
|
||||||
|
return any(k in spec for k in ("customer_name", "customer_id", "title"))
|
||||||
|
|
||||||
|
|
||||||
|
_PATTERNS_DIR = Path(__file__).resolve().parent.parent / "kb" / "patterns"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_patterns_yaml(filename: str) -> dict:
|
||||||
|
"""Load a patterns YAML (supports 2-document layout: header + body)."""
|
||||||
|
path = _PATTERNS_DIR / filename
|
||||||
|
if not path.is_file():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(path, "r") as f:
|
||||||
|
docs = [d for d in yaml.safe_load_all(f) if isinstance(d, dict)]
|
||||||
|
return docs[-1] if docs else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _default_architecture_principles() -> dict:
|
||||||
|
"""Curated ECAL principles — 3 per category, 'applies_when: always' first."""
|
||||||
|
data = _load_patterns_yaml("architecture-principles.yaml")
|
||||||
|
principles = data.get("principles", {}) if isinstance(data, dict) else {}
|
||||||
|
result = {}
|
||||||
|
for cat in ("design", "deployment", "service"):
|
||||||
|
items = principles.get(cat, []) or []
|
||||||
|
always = [p for p in items if p.get("applies_when") == "always"]
|
||||||
|
picks = (always or items)[:3]
|
||||||
|
result[cat] = [
|
||||||
|
{
|
||||||
|
"id": p.get("id", ""),
|
||||||
|
"name": p.get("name", ""),
|
||||||
|
"summary": p.get("principle", p.get("summary", "")),
|
||||||
|
}
|
||||||
|
for p in picks
|
||||||
|
if isinstance(p, 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 {}
|
||||||
|
chosen = models.get(model) or models.get("co_managed") or {}
|
||||||
|
raci = chosen.get("raci", [])
|
||||||
|
return [r for r in raci if isinstance(r, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_environments(deployment: str, dr: str) -> list:
|
||||||
|
"""Produce a default environment catalogue from deployment model + DR flag."""
|
||||||
|
deployment_lc = (deployment or "").lower()
|
||||||
|
dr_enabled = bool(dr) and str(dr).lower() not in {"", "no", "none", "not in scope", "disabled", "false"}
|
||||||
|
envs = [
|
||||||
|
{"name": "Production", "sizing": "Full capacity per workload profile",
|
||||||
|
"isolation": "Dedicated compartment, private endpoints", "cost_pct": 55},
|
||||||
|
{"name": "Pre-Production / UAT", "sizing": "50-75% of production",
|
||||||
|
"isolation": "Shared tenancy OK, same region", "cost_pct": 20},
|
||||||
|
{"name": "Dev/Test", "sizing": "25% of production, auto-stop off-hours",
|
||||||
|
"isolation": "Shared, relaxed controls", "cost_pct": 10},
|
||||||
|
]
|
||||||
|
if dr_enabled or "multi" in deployment_lc or "dr" in deployment_lc:
|
||||||
|
envs.append({
|
||||||
|
"name": "DR (secondary region)",
|
||||||
|
"sizing": "100% of production, async replication",
|
||||||
|
"isolation": "Cross-region, same security baseline",
|
||||||
|
"cost_pct": 15,
|
||||||
|
})
|
||||||
|
return envs
|
||||||
|
|
||||||
|
|
||||||
|
_HA_DR_BY_TIER = {
|
||||||
|
"platinum": {
|
||||||
|
"technology": "RAC + cross-region Active Data Guard (sync)",
|
||||||
|
"rto": "< 1 hour",
|
||||||
|
"rpo": "< 5 minutes",
|
||||||
|
},
|
||||||
|
"gold": {
|
||||||
|
"technology": "TAC + cross-region Active Data Guard (async)",
|
||||||
|
"rto": "2-4 hours",
|
||||||
|
"rpo": "15 minutes",
|
||||||
|
},
|
||||||
|
"silver": {
|
||||||
|
"technology": "Single instance + cross-region backup restore",
|
||||||
|
"rto": "4-8 hours",
|
||||||
|
"rpo": "1 hour",
|
||||||
|
},
|
||||||
|
"bronze": {
|
||||||
|
"technology": "Single instance + Object Storage archive",
|
||||||
|
"rto": "24 hours",
|
||||||
|
"rpo": "24 hours",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_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."""
|
||||||
|
seen = []
|
||||||
|
for wl in workloads or []:
|
||||||
|
tier = str(wl.get("tier", "")).lower()
|
||||||
|
if tier in _HA_DR_BY_TIER and tier not in seen:
|
||||||
|
seen.append(tier)
|
||||||
|
return [
|
||||||
|
{"tier": t.title(), **_HA_DR_BY_TIER[t]}
|
||||||
|
for t in seen
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _default_security_baseline() -> dict:
|
||||||
|
"""Conservative OCI security baseline — customer confirms specifics."""
|
||||||
|
return {
|
||||||
|
"controls": {
|
||||||
|
"identity": [
|
||||||
|
"IAM compartments per environment + tag-based policies",
|
||||||
|
"Federated SSO via customer IdP (SAML/OIDC)",
|
||||||
|
"MFA enforced for all human users",
|
||||||
|
],
|
||||||
|
"network": [
|
||||||
|
"Private subnets with NSGs; no public IPs on data tier",
|
||||||
|
"Service Gateway for OCI APIs; no egress over public internet",
|
||||||
|
"FastConnect or Site-to-Site VPN for hybrid connectivity",
|
||||||
|
],
|
||||||
|
"database": [
|
||||||
|
"TDE at rest (customer-managed keys via OCI Vault)",
|
||||||
|
"Network encryption in transit (TLS 1.2+)",
|
||||||
|
"Data Safe for activity auditing + sensitive data discovery",
|
||||||
|
],
|
||||||
|
"monitoring": [
|
||||||
|
"OCI Logging + Service Connector Hub to SIEM",
|
||||||
|
"Cloud Guard detectors enabled (baseline recipe)",
|
||||||
|
"OCI Monitoring alarms on quota, cost, and security events",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"compliance": ["ISO 27001", "SOC 2", "PCI-DSS"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_service_tiering(services: list) -> list:
|
||||||
|
"""Map a flat services list to service-tiering workload rows.
|
||||||
|
|
||||||
|
Preserves any tier/uptime/rto/rpo the caller supplied; fills sensible
|
||||||
|
defaults so the slide always has a full table instead of blanks.
|
||||||
|
"""
|
||||||
|
workloads = []
|
||||||
|
for svc in services or []:
|
||||||
|
if not isinstance(svc, dict):
|
||||||
|
continue
|
||||||
|
name = svc.get("name") or svc.get("workload") or svc.get("sku") or "Service"
|
||||||
|
tier = svc.get("tier") or svc.get("service_tier") or "Gold"
|
||||||
|
workloads.append({
|
||||||
|
"name": name,
|
||||||
|
"tier": str(tier).title(),
|
||||||
|
"uptime": svc.get("uptime", "99.9%"),
|
||||||
|
"rto": svc.get("rto", "4 hours"),
|
||||||
|
"rpo": svc.get("rpo", "15 minutes"),
|
||||||
|
})
|
||||||
|
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 {}
|
||||||
|
tenancy = spec.get("tenancy") if isinstance(spec.get("tenancy"), dict) else {}
|
||||||
|
|
||||||
|
customer = spec.get("customer_name") or spec.get("customer_id", "")
|
||||||
|
title = spec.get("title") or arch.get("workload", "") or "Architecture Proposal"
|
||||||
|
workload = spec.get("workload_type") or arch.get("workload", "")
|
||||||
|
deployment = spec.get("deployment_model") or arch.get("deployment", "")
|
||||||
|
billing = spec.get("billing_model") or arch.get("license_model", "")
|
||||||
|
capacity = spec.get("capacity") or arch.get("capacity") or {}
|
||||||
|
services = spec.get("services", []) or []
|
||||||
|
|
||||||
|
# Region discovery — primary + DR can arrive as flat fields, under
|
||||||
|
# architecture.*, or as a tenancy.regions list (items may be plain
|
||||||
|
# strings or {region, role} dicts).
|
||||||
|
primary_region = (
|
||||||
|
spec.get("primary_region")
|
||||||
|
or spec.get("region")
|
||||||
|
or arch.get("primary_region")
|
||||||
|
or arch.get("region")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
dr_region = (
|
||||||
|
spec.get("dr_region")
|
||||||
|
or spec.get("secondary_region")
|
||||||
|
or arch.get("dr_region")
|
||||||
|
or arch.get("secondary_region")
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
tenancy_regions = tenancy.get("regions") or spec.get("regions") or arch.get("regions") or []
|
||||||
|
if isinstance(tenancy_regions, list):
|
||||||
|
for entry in tenancy_regions:
|
||||||
|
if isinstance(entry, dict):
|
||||||
|
name = entry.get("name") or entry.get("region") or entry.get("id") or ""
|
||||||
|
role = str(entry.get("role") or entry.get("type") or "").lower()
|
||||||
|
if role in ("primary", "hub", "prod", "production") and not primary_region:
|
||||||
|
primary_region = name
|
||||||
|
elif role in ("dr", "standby", "secondary", "failover") and not dr_region:
|
||||||
|
dr_region = name
|
||||||
|
elif isinstance(entry, str):
|
||||||
|
if not primary_region:
|
||||||
|
primary_region = entry
|
||||||
|
elif not dr_region:
|
||||||
|
dr_region = entry
|
||||||
|
region = primary_region
|
||||||
|
|
||||||
|
dr_raw = spec.get("disaster_recovery")
|
||||||
|
if dr_raw is None and "dr" in arch:
|
||||||
|
dr_raw = "Enabled" if arch.get("dr") else "Not in scope"
|
||||||
|
# If the spec names a DR region, treat DR as enabled even when the
|
||||||
|
# disaster_recovery flag is omitted.
|
||||||
|
if not dr_raw and dr_region:
|
||||||
|
dr_raw = f"Enabled — secondary region: {dr_region}"
|
||||||
|
|
||||||
|
target_parts = [p for p in (workload, deployment and f"deployment: {deployment}",
|
||||||
|
region and f"region: {region}") if p]
|
||||||
|
target_state = " — ".join(target_parts)
|
||||||
|
|
||||||
|
current_state = []
|
||||||
|
if isinstance(capacity, dict):
|
||||||
|
for k, v in capacity.items():
|
||||||
|
current_state.append(f"{k.replace('_', ' ').title()}: {v}")
|
||||||
|
if billing:
|
||||||
|
current_state.append(f"License model: {billing}")
|
||||||
|
if dr_raw:
|
||||||
|
current_state.append(f"Disaster recovery: {dr_raw}")
|
||||||
|
|
||||||
|
line_items = []
|
||||||
|
for svc in services:
|
||||||
|
if not isinstance(svc, dict):
|
||||||
|
continue
|
||||||
|
qty = svc.get("quantity", svc.get("qty"))
|
||||||
|
notes = svc.get("notes", "")
|
||||||
|
if qty is not None:
|
||||||
|
notes = f"Qty: {qty}" + (f" — {notes}" if notes else "")
|
||||||
|
line_items.append({"component": svc.get("name", ""), "monthly_payg": "—", "notes": notes})
|
||||||
|
|
||||||
|
cost_summary = spec.get("cost_summary") or {}
|
||||||
|
monthly = cost_summary.get("monthly_estimate")
|
||||||
|
if monthly is not None:
|
||||||
|
currency = cost_summary.get("currency", "USD")
|
||||||
|
annual = cost_summary.get("annual_estimate", monthly * 12)
|
||||||
|
line_items.append({
|
||||||
|
"component": "Total (estimated)",
|
||||||
|
"monthly_payg": f"{currency} {monthly:,.2f}",
|
||||||
|
"notes": f"Annual: {currency} {annual:,.2f}",
|
||||||
|
})
|
||||||
|
|
||||||
|
adapted = {
|
||||||
|
"metadata": {"customer": customer, "subtitle": title},
|
||||||
|
"summary": {
|
||||||
|
"why": f"{customer} — {title}" if customer and title else (customer or title),
|
||||||
|
"current_state": current_state,
|
||||||
|
"target_state": target_state,
|
||||||
|
"timeline": "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
workloads = _derive_service_tiering(services)
|
||||||
|
if workloads:
|
||||||
|
adapted["service_tiering"] = workloads
|
||||||
|
|
||||||
|
arch_desc_parts = [p for p in (
|
||||||
|
workload and f"Workload: {workload}",
|
||||||
|
deployment and f"Deployment: {deployment}",
|
||||||
|
region and f"Primary region: {region}",
|
||||||
|
dr_region and f"DR region: {dr_region}",
|
||||||
|
billing and f"Licensing: {billing}",
|
||||||
|
) if p]
|
||||||
|
architecture_block: dict = {}
|
||||||
|
# Preserve any caller-supplied visual/diagram_path instead of overwriting.
|
||||||
|
if isinstance(arch.get("visual"), dict):
|
||||||
|
architecture_block["visual"] = arch["visual"]
|
||||||
|
if arch.get("diagram_path"):
|
||||||
|
architecture_block["diagram_path"] = arch["diagram_path"]
|
||||||
|
if arch_desc_parts:
|
||||||
|
architecture_block["description"] = " • ".join(arch_desc_parts)
|
||||||
|
# When we know both regions and no visual was supplied, emit a simple
|
||||||
|
# two-region visual so the DR region renders with its real name.
|
||||||
|
if region and dr_region and "visual" not in architecture_block:
|
||||||
|
architecture_block["visual"] = {
|
||||||
|
"regions": [
|
||||||
|
{"name": region, "primary": True, "label": "PRIMARY"},
|
||||||
|
{"name": dr_region, "primary": False, "label": "DR STANDBY"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if architecture_block:
|
||||||
|
adapted["architecture"] = architecture_block
|
||||||
|
|
||||||
|
principles = _default_architecture_principles()
|
||||||
|
if principles:
|
||||||
|
adapted["architecture_principles"] = principles
|
||||||
|
|
||||||
|
ha_dr_tiers = _derive_ha_dr_tiers(workloads)
|
||||||
|
if ha_dr_tiers:
|
||||||
|
adapted["ha_dr"] = {
|
||||||
|
"tiers": ha_dr_tiers,
|
||||||
|
"description": "HA/DR posture per service tier. Confirm RTO/RPO with business stakeholders.",
|
||||||
|
}
|
||||||
|
|
||||||
|
adapted["security"] = _default_security_baseline()
|
||||||
|
|
||||||
|
environments = _derive_environments(deployment, dr_raw)
|
||||||
|
if environments:
|
||||||
|
adapted["environment_catalogue"] = {"environments": environments}
|
||||||
|
|
||||||
|
# Respect user-supplied RACI if present (string model name, list of items,
|
||||||
|
# or dict with model/raci_items); otherwise fall back to co_managed default.
|
||||||
|
user_raci = None
|
||||||
|
for key in ("operational_raci", "raci", "operations_raci"):
|
||||||
|
if spec.get(key) is not None:
|
||||||
|
user_raci = spec[key]
|
||||||
|
break
|
||||||
|
if user_raci is not None:
|
||||||
|
adapted["operational_raci"] = user_raci
|
||||||
|
else:
|
||||||
|
raci_items = _default_operational_raci("co_managed")
|
||||||
|
if raci_items:
|
||||||
|
adapted["operational_raci"] = {
|
||||||
|
"raci_items": raci_items,
|
||||||
|
"model": "co_managed",
|
||||||
|
}
|
||||||
|
|
||||||
|
adapted["next_steps"] = {
|
||||||
|
"steps": [
|
||||||
|
"Validate architecture + service tiers with technical stakeholders",
|
||||||
|
"Confirm capacity assumptions against current/expected utilization",
|
||||||
|
"Lock commitment model (PAYG vs UCM) and finalize discount",
|
||||||
|
"Define proof-of-concept / pilot scope and success criteria",
|
||||||
|
"Schedule operational handover and RACI walkthrough",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
if line_items:
|
||||||
|
adapted["cost"] = {
|
||||||
|
"line_items": line_items,
|
||||||
|
"assumptions": spec.get("assumptions", []) or [],
|
||||||
|
"show_byol": False,
|
||||||
|
}
|
||||||
|
return adapted
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Slide Generator
|
# Slide Generator
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -445,17 +1019,23 @@ class OCIDeckGenerator(OraclePresBase):
|
|||||||
for i, wl in enumerate(workloads):
|
for i, wl in enumerate(workloads):
|
||||||
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
|
||||||
self._style_table_cell(table.cell(row_idx, 0), wl["name"], font_size=10, bold=True, bg_color=bg)
|
workload_name = pick(wl, "name", "workload", "title")
|
||||||
tier_label = wl.get("tier", "Silver")
|
tier_label = pick(wl, "tier", "service_tier", default="Silver")
|
||||||
tier_color = tier_colors.get(tier_label.lower(), Colors.SECONDARY_TEXT)
|
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(
|
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.
|
||||||
@@ -466,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)
|
||||||
@@ -486,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}"
|
||||||
@@ -533,11 +1119,11 @@ 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
|
||||||
self._style_table_cell(
|
self._style_table_cell(
|
||||||
table.cell(row_idx, 0), dec["decision"],
|
table.cell(row_idx, 0), pick(dec, "decision", "title"),
|
||||||
font_size=11, bold=True, bg_color=bg,
|
font_size=11, bold=True, bg_color=bg,
|
||||||
)
|
)
|
||||||
self._style_table_cell(
|
self._style_table_cell(
|
||||||
table.cell(row_idx, 1), dec["rationale"],
|
table.cell(row_idx, 1), pick(dec, "rationale", "reason", "notes"),
|
||||||
font_size=10, bg_color=bg,
|
font_size=10, bg_color=bg,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -579,10 +1165,10 @@ class OCIDeckGenerator(OraclePresBase):
|
|||||||
for i, tier in enumerate(tiers):
|
for i, tier in enumerate(tiers):
|
||||||
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
|
||||||
self._style_table_cell(table.cell(row_idx, 0), tier["tier"], font_size=10, bold=True, bg_color=bg)
|
self._style_table_cell(table.cell(row_idx, 0), pick(tier, "tier", "name"), font_size=10, bold=True, bg_color=bg)
|
||||||
self._style_table_cell(table.cell(row_idx, 1), tier["technology"], font_size=10, bg_color=bg)
|
self._style_table_cell(table.cell(row_idx, 1), pick(tier, "technology", "architecture", "pattern"), font_size=10, bg_color=bg)
|
||||||
self._style_table_cell(table.cell(row_idx, 2), tier["rto"], font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
|
self._style_table_cell(table.cell(row_idx, 2), pick(tier, "rto"), font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
|
||||||
self._style_table_cell(table.cell(row_idx, 3), tier["rpo"], font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
|
self._style_table_cell(table.cell(row_idx, 3), pick(tier, "rpo"), font_size=10, bg_color=bg, alignment=PP_ALIGN.CENTER)
|
||||||
|
|
||||||
def add_security_slide(self, controls: dict, compliance: list = None):
|
def add_security_slide(self, controls: dict, compliance: list = None):
|
||||||
"""Slide 6: Security & Compliance.
|
"""Slide 6: Security & Compliance.
|
||||||
@@ -770,19 +1356,22 @@ class OCIDeckGenerator(OraclePresBase):
|
|||||||
|
|
||||||
for i, item in enumerate(line_items):
|
for i, item in enumerate(line_items):
|
||||||
row_idx = i + 1
|
row_idx = i + 1
|
||||||
is_total = "total" in item.get("component", "").lower()
|
component = pick(item, "component", "name", "label")
|
||||||
|
notes = pick(item, "notes", "note")
|
||||||
|
monthly_payg = pick(item, "monthly_payg", "monthly")
|
||||||
|
is_total = "total" in str(component).lower()
|
||||||
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 and not is_total else None
|
bg = Colors.TABLE_ALT_ROW if row_idx % 2 == 0 and not is_total else None
|
||||||
if is_total:
|
if is_total:
|
||||||
bg = Colors.TEAL
|
bg = Colors.TEAL
|
||||||
|
|
||||||
self._style_table_cell(
|
self._style_table_cell(
|
||||||
table.cell(row_idx, 0), item["component"],
|
table.cell(row_idx, 0), component,
|
||||||
font_size=10, bold=is_total,
|
font_size=10, bold=is_total,
|
||||||
color=Colors.WHITE if is_total else None,
|
color=Colors.WHITE if is_total else None,
|
||||||
bg_color=bg,
|
bg_color=bg,
|
||||||
)
|
)
|
||||||
self._style_table_cell(
|
self._style_table_cell(
|
||||||
table.cell(row_idx, 1), item.get("monthly_payg", ""),
|
table.cell(row_idx, 1), monthly_payg,
|
||||||
font_size=10, bold=is_total,
|
font_size=10, bold=is_total,
|
||||||
color=Colors.WHITE if is_total else None,
|
color=Colors.WHITE if is_total else None,
|
||||||
bg_color=bg, alignment=PP_ALIGN.RIGHT,
|
bg_color=bg, alignment=PP_ALIGN.RIGHT,
|
||||||
@@ -795,13 +1384,13 @@ class OCIDeckGenerator(OraclePresBase):
|
|||||||
bg_color=bg, alignment=PP_ALIGN.RIGHT,
|
bg_color=bg, alignment=PP_ALIGN.RIGHT,
|
||||||
)
|
)
|
||||||
self._style_table_cell(
|
self._style_table_cell(
|
||||||
table.cell(row_idx, 3), item.get("notes", ""),
|
table.cell(row_idx, 3), notes,
|
||||||
font_size=9, bg_color=bg,
|
font_size=9, bg_color=bg,
|
||||||
color=Colors.WHITE if is_total else Colors.SECONDARY_TEXT,
|
color=Colors.WHITE if is_total else Colors.SECONDARY_TEXT,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self._style_table_cell(
|
self._style_table_cell(
|
||||||
table.cell(row_idx, 2), item.get("notes", ""),
|
table.cell(row_idx, 2), notes,
|
||||||
font_size=9, bg_color=bg,
|
font_size=9, bg_color=bg,
|
||||||
color=Colors.WHITE if is_total else Colors.SECONDARY_TEXT,
|
color=Colors.WHITE if is_total else Colors.SECONDARY_TEXT,
|
||||||
)
|
)
|
||||||
@@ -903,14 +1492,14 @@ class OCIDeckGenerator(OraclePresBase):
|
|||||||
|
|
||||||
for i, phase in enumerate(phases):
|
for i, phase in enumerate(phases):
|
||||||
color = phase_colors[i % len(phase_colors)]
|
color = phase_colors[i % len(phase_colors)]
|
||||||
duration = phase.get("duration", phase.get("weeks", ""))
|
duration = pick(phase, "duration", "weeks")
|
||||||
milestones = phase.get("milestones", phase.get("tasks", []))
|
milestones = pick_list(phase, "milestones", "tasks")
|
||||||
|
|
||||||
# Phase label (left of bar)
|
# Phase label (left of bar)
|
||||||
self._add_textbox(
|
self._add_textbox(
|
||||||
slide, self.MARGIN, y,
|
slide, self.MARGIN, y,
|
||||||
Inches(2.3), Inches(0.4),
|
Inches(2.3), Inches(0.4),
|
||||||
text=phase["name"], font_size=11, bold=True,
|
text=pick(phase, "name", "title"), font_size=11, bold=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Colored phase bar with duration
|
# Colored phase bar with duration
|
||||||
@@ -1042,7 +1631,7 @@ 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
|
||||||
self._style_table_cell(
|
self._style_table_cell(
|
||||||
table.cell(row_idx, 0), risk["risk"],
|
table.cell(row_idx, 0), pick(risk, "risk", "title"),
|
||||||
font_size=10, bg_color=bg,
|
font_size=10, bg_color=bg,
|
||||||
)
|
)
|
||||||
sev = risk.get("severity", "MEDIUM").upper()
|
sev = risk.get("severity", "MEDIUM").upper()
|
||||||
@@ -1053,7 +1642,7 @@ class OCIDeckGenerator(OraclePresBase):
|
|||||||
bg_color=bg, alignment=PP_ALIGN.CENTER,
|
bg_color=bg, alignment=PP_ALIGN.CENTER,
|
||||||
)
|
)
|
||||||
self._style_table_cell(
|
self._style_table_cell(
|
||||||
table.cell(row_idx, 2), risk["mitigation"],
|
table.cell(row_idx, 2), pick(risk, "mitigation", "action", "response"),
|
||||||
font_size=10, bg_color=bg,
|
font_size=10, bg_color=bg,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1075,7 +1664,8 @@ class OCIDeckGenerator(OraclePresBase):
|
|||||||
|
|
||||||
y = Inches(1.4)
|
y = Inches(1.4)
|
||||||
for pillar in pillars:
|
for pillar in pillars:
|
||||||
style = status_styles.get(pillar["status"], status_styles["PASS"])
|
status = pick(pillar, "status", default="PASS")
|
||||||
|
style = status_styles.get(status, status_styles["PASS"])
|
||||||
|
|
||||||
# Status indicator pill
|
# Status indicator pill
|
||||||
pill = self._add_rect(
|
pill = self._add_rect(
|
||||||
@@ -1093,11 +1683,13 @@ class OCIDeckGenerator(OraclePresBase):
|
|||||||
self._add_textbox(
|
self._add_textbox(
|
||||||
slide, Inches(1.2), y,
|
slide, Inches(1.2), y,
|
||||||
Inches(4), Inches(0.45),
|
Inches(4), Inches(0.45),
|
||||||
text=pillar["name"], font_size=13, bold=True,
|
text=pick(pillar, "name", "pillar"), font_size=13, bold=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Score
|
# Score
|
||||||
score_text = f"{pillar['passed']}/{pillar['total']}" if pillar['total'] > 0 else "—"
|
passed = pick(pillar, "passed", default=0)
|
||||||
|
total = pick(pillar, "total", default=0)
|
||||||
|
score_text = f"{passed}/{total}" if total > 0 else "—"
|
||||||
self._add_textbox(
|
self._add_textbox(
|
||||||
slide, Inches(5.5), y,
|
slide, Inches(5.5), y,
|
||||||
Inches(1.5), Inches(0.45),
|
Inches(1.5), Inches(0.45),
|
||||||
@@ -1185,26 +1777,35 @@ class OCIDeckGenerator(OraclePresBase):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_spec(cls, spec: dict, template: Optional[str] = None) -> "OCIDeckGenerator":
|
def from_spec(cls, spec: dict, template: Optional[str] = None) -> "OCIDeckGenerator":
|
||||||
"""Build a complete deck from a YAML specification."""
|
"""Build a complete deck from a YAML specification."""
|
||||||
|
# The MCP server passes a flat payload shape (customer_name, services,
|
||||||
|
# cost_summary, ...) that doesn't match the proposal-spec structure this
|
||||||
|
# method was originally written against. Adapt it so the deck renders
|
||||||
|
# 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", {})
|
meta = spec.get("metadata", {})
|
||||||
gen = cls(
|
gen = cls(
|
||||||
customer=meta.get("customer", ""),
|
customer=pick(meta, "customer", "customer_name"),
|
||||||
project=meta.get("project", ""),
|
project=pick(meta, "project", "project_name"),
|
||||||
architect=meta.get("architect", ""),
|
architect=pick(meta, "architect", "prepared_by"),
|
||||||
firm=meta.get("firm", ""),
|
firm=meta.get("firm", ""),
|
||||||
template=template,
|
template=template,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Slide 1: Title
|
# Slide 1: Title
|
||||||
gen.add_title_slide(subtitle=meta.get("subtitle", ""))
|
gen.add_title_slide(subtitle=pick(meta, "subtitle", "title"))
|
||||||
|
|
||||||
# Slide 2: Summary
|
# Slide 2: Summary
|
||||||
if "summary" in spec:
|
if "summary" in spec:
|
||||||
s = spec["summary"]
|
s = spec["summary"]
|
||||||
gen.add_summary_slide(
|
gen.add_summary_slide(
|
||||||
why=s.get("why", ""),
|
why=pick(s, "why"),
|
||||||
current_state=s.get("current_state", []),
|
current_state=coerce_list(s.get("current_state")),
|
||||||
target_state=s.get("target_state", ""),
|
target_state=pick(s, "target_state"),
|
||||||
timeline=s.get("timeline", ""),
|
timeline=pick(s, "timeline"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Slide 3: Service Tiering (ECAL)
|
# Slide 3: Service Tiering (ECAL)
|
||||||
@@ -1218,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
|
||||||
@@ -1256,8 +1890,8 @@ class OCIDeckGenerator(OraclePresBase):
|
|||||||
if "cost" in spec:
|
if "cost" in spec:
|
||||||
c = spec["cost"]
|
c = spec["cost"]
|
||||||
gen.add_cost_slide(
|
gen.add_cost_slide(
|
||||||
line_items=c.get("line_items", []),
|
line_items=pick_list(c, "line_items"),
|
||||||
assumptions=c.get("assumptions", []),
|
assumptions=pick_list(c, "assumptions"),
|
||||||
show_byol=c.get("show_byol", True),
|
show_byol=c.get("show_byol", True),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1274,17 +1908,38 @@ class OCIDeckGenerator(OraclePresBase):
|
|||||||
if "migration" in spec:
|
if "migration" in spec:
|
||||||
m = spec["migration"]
|
m = spec["migration"]
|
||||||
gen.add_migration_slide(
|
gen.add_migration_slide(
|
||||||
phases=m.get("phases", []),
|
phases=pick_list(m, "phases"),
|
||||||
tools=m.get("tools", []),
|
tools=pick_list(m, "tools"),
|
||||||
downtime=m.get("downtime", ""),
|
downtime=pick(m, "downtime"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Slide 13: Operational RACI (ECAL)
|
# Slide 13: Operational RACI (ECAL)
|
||||||
if "operational_raci" in spec:
|
raci_payload = None
|
||||||
r = spec["operational_raci"]
|
for key in ("operational_raci", "raci", "operations_raci"):
|
||||||
|
if key in spec and spec[key] is not None:
|
||||||
|
raci_payload = spec[key]
|
||||||
|
break
|
||||||
|
if raci_payload is not None:
|
||||||
|
model = "co_managed"
|
||||||
|
raci_items = []
|
||||||
|
if isinstance(raci_payload, str):
|
||||||
|
model = raci_payload
|
||||||
|
elif isinstance(raci_payload, list):
|
||||||
|
raci_items = [r for r in raci_payload if isinstance(r, dict)]
|
||||||
|
elif isinstance(raci_payload, dict):
|
||||||
|
model = raci_payload.get("model") or raci_payload.get("engagement_model") or "co_managed"
|
||||||
|
items = (
|
||||||
|
raci_payload.get("raci_items")
|
||||||
|
or raci_payload.get("items")
|
||||||
|
or raci_payload.get("activities")
|
||||||
|
or []
|
||||||
|
)
|
||||||
|
raci_items = [r for r in items if isinstance(r, dict)]
|
||||||
|
if not raci_items:
|
||||||
|
raci_items = _default_operational_raci(model)
|
||||||
gen.add_operational_raci_slide(
|
gen.add_operational_raci_slide(
|
||||||
raci_items=r.get("raci_items", []),
|
raci_items=raci_items,
|
||||||
model=r.get("model", "co_managed"),
|
model=model,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Slide 14: Risks
|
# Slide 14: Risks
|
||||||
@@ -1295,24 +1950,18 @@ class OCIDeckGenerator(OraclePresBase):
|
|||||||
if "scorecard" in spec:
|
if "scorecard" in spec:
|
||||||
sc = spec["scorecard"]
|
sc = spec["scorecard"]
|
||||||
gen.add_scorecard_slide(
|
gen.add_scorecard_slide(
|
||||||
pillars=sc.get("pillars", []),
|
pillars=pick_list(sc, "pillars"),
|
||||||
recommendations=sc.get("recommendations", []),
|
recommendations=pick_list(sc, "recommendations"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Slide 16: Next Steps
|
# Slide 16: Next Steps
|
||||||
if "next_steps" in spec:
|
if "next_steps" in spec:
|
||||||
ns = spec["next_steps"]
|
ns = spec["next_steps"]
|
||||||
gen.add_next_steps_slide(
|
gen.add_next_steps_slide(
|
||||||
steps=ns.get("steps", []),
|
steps=pick_list(ns, "steps", "next_steps"),
|
||||||
contact_info=ns.get("contact_info", ""),
|
contact_info=pick(ns, "contact_info"),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Closing slides
|
|
||||||
gen.add_closing_slide(
|
|
||||||
name=meta.get("architect", ""),
|
|
||||||
title=meta.get("firm", ""),
|
|
||||||
)
|
|
||||||
|
|
||||||
return gen
|
return gen
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -78,15 +78,15 @@ STYLES = {
|
|||||||
"fontSize=11;"
|
"fontSize=11;"
|
||||||
),
|
),
|
||||||
"vcn": (
|
"vcn": (
|
||||||
"whiteSpace=wrap;html=1;strokeWidth=2;dashed=1;dashPattern=6 4;align=left;"
|
"whiteSpace=wrap;html=1;strokeWidth=2;dashed=1;dashPattern=4 2;align=left;"
|
||||||
"fontFamily=Oracle Sans;verticalAlign=top;fillColor=none;"
|
"fontFamily=Oracle Sans;verticalAlign=top;fillColor=none;"
|
||||||
"fontColor=#AE562C;strokeColor=#AE562C;perimeterSpacing=0;"
|
"fontColor=#aa643b;strokeColor=#aa643b;perimeterSpacing=0;"
|
||||||
"fontSize=12;fontStyle=1;spacingLeft=8;spacingTop=6;"
|
"fontSize=12;fontStyle=1;spacingLeft=8;spacingTop=6;"
|
||||||
),
|
),
|
||||||
"subnet": (
|
"subnet": (
|
||||||
"whiteSpace=wrap;html=1;strokeWidth=1;dashed=1;dashPattern=6 4;align=left;"
|
"whiteSpace=wrap;html=1;strokeWidth=2;dashed=1;dashPattern=4 2;align=left;"
|
||||||
"fontFamily=Oracle Sans;verticalAlign=top;fillColor=none;"
|
"fontFamily=Oracle Sans;verticalAlign=top;fillColor=none;"
|
||||||
"fontColor=#AE562C;strokeColor=#AE562C;fontSize=11;fontStyle=1;"
|
"fontColor=#aa643b;strokeColor=#aa643b;fontSize=11;fontStyle=1;"
|
||||||
"spacingLeft=8;spacingTop=4;"
|
"spacingLeft=8;spacingTop=4;"
|
||||||
),
|
),
|
||||||
"compartment": (
|
"compartment": (
|
||||||
@@ -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",
|
||||||
@@ -408,6 +410,220 @@ OBS_TYPE_MAP = {
|
|||||||
"auditing": "monitoring", # uses monitoring icon as closest match
|
"auditing": "monitoring", # uses monitoring icon as closest match
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def pick(mapping: dict, *keys, default=""):
|
||||||
|
"""Return the first non-empty value for any of the provided keys."""
|
||||||
|
if not isinstance(mapping, dict):
|
||||||
|
return default
|
||||||
|
for key in keys:
|
||||||
|
if key in mapping:
|
||||||
|
value = mapping.get(key)
|
||||||
|
if value is not None:
|
||||||
|
return value
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def pick_list(mapping: dict, *keys):
|
||||||
|
"""Return the first list-like field normalized to a list."""
|
||||||
|
if not isinstance(mapping, dict):
|
||||||
|
return []
|
||||||
|
for key in keys:
|
||||||
|
if key in mapping:
|
||||||
|
value = mapping.get(key)
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
return [value]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _slugify(value: str) -> str:
|
||||||
|
text = re.sub(r"[^a-z0-9]+", "-", str(value or "").lower()).strip("-")
|
||||||
|
return text or "item"
|
||||||
|
|
||||||
|
|
||||||
|
class _SpecIdGenerator:
|
||||||
|
"""Generate stable unique ids within one normalized spec."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.used = set()
|
||||||
|
self.counters = {}
|
||||||
|
|
||||||
|
def make(self, prefix: str, seed: str = "") -> str:
|
||||||
|
base = f"{prefix}-{_slugify(seed)}" if seed else prefix
|
||||||
|
candidate = base
|
||||||
|
idx = 2
|
||||||
|
while candidate in self.used:
|
||||||
|
candidate = f"{base}-{idx}"
|
||||||
|
idx += 1
|
||||||
|
self.used.add(candidate)
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_diagram_spec(spec: dict) -> dict:
|
||||||
|
"""Normalize aliases and synthesize ids before rendering."""
|
||||||
|
spec = spec or {}
|
||||||
|
id_gen = _SpecIdGenerator()
|
||||||
|
aliases = {}
|
||||||
|
|
||||||
|
def remember(node: dict, node_id: str):
|
||||||
|
for key in ("id", "name", "label", "title"):
|
||||||
|
value = node.get(key)
|
||||||
|
if value:
|
||||||
|
aliases[str(value)] = node_id
|
||||||
|
|
||||||
|
def resolve_ref(value):
|
||||||
|
if value in (None, ""):
|
||||||
|
return None
|
||||||
|
return aliases.get(str(value), str(value))
|
||||||
|
|
||||||
|
def normalize_service(node: dict, prefix: str, default_type: str = "compute") -> dict:
|
||||||
|
raw = dict(node or {})
|
||||||
|
label = pick(raw, "label", "name", "title")
|
||||||
|
node_id = pick(raw, "id", "name") or id_gen.make(prefix, label)
|
||||||
|
normalized = dict(raw)
|
||||||
|
normalized["id"] = str(node_id)
|
||||||
|
normalized["label"] = label or str(node_id)
|
||||||
|
normalized["type"] = pick(raw, "type", "service_type", default=default_type)
|
||||||
|
remember(raw, normalized["id"])
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
normalized = {
|
||||||
|
"title": spec.get("title", ""),
|
||||||
|
"external": [],
|
||||||
|
"clouds": [],
|
||||||
|
"tenancy": {},
|
||||||
|
"connections": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for ext in pick_list(spec, "external"):
|
||||||
|
normalized["external"].append(normalize_service(ext, "external"))
|
||||||
|
|
||||||
|
for cloud in pick_list(spec, "clouds"):
|
||||||
|
raw_cloud = dict(cloud or {})
|
||||||
|
label = pick(raw_cloud, "label", "name", "title")
|
||||||
|
cloud_id = pick(raw_cloud, "id", "name") or id_gen.make("cloud", label)
|
||||||
|
normalized_cloud = dict(raw_cloud)
|
||||||
|
normalized_cloud["id"] = str(cloud_id)
|
||||||
|
normalized_cloud["label"] = label or str(cloud_id)
|
||||||
|
normalized_cloud["services"] = [
|
||||||
|
normalize_service(svc, "cloud-svc", default_type="compute")
|
||||||
|
for svc in pick_list(raw_cloud, "services")
|
||||||
|
]
|
||||||
|
remember(raw_cloud, normalized_cloud["id"])
|
||||||
|
normalized["clouds"].append(normalized_cloud)
|
||||||
|
|
||||||
|
tenancy_spec = dict(spec.get("tenancy", {}) or {})
|
||||||
|
normalized_tenancy = dict(tenancy_spec)
|
||||||
|
normalized_tenancy["label"] = pick(tenancy_spec, "label", "name", "title", default="Oracle Cloud Infrastructure")
|
||||||
|
normalized_tenancy["regions"] = []
|
||||||
|
|
||||||
|
for region in pick_list(tenancy_spec, "regions"):
|
||||||
|
raw_region = dict(region or {})
|
||||||
|
region_label = pick(raw_region, "label", "name", "title")
|
||||||
|
region_id = pick(raw_region, "id", "name") or id_gen.make("region", region_label)
|
||||||
|
normalized_region = dict(raw_region)
|
||||||
|
normalized_region["id"] = str(region_id)
|
||||||
|
normalized_region["label"] = region_label or str(region_id)
|
||||||
|
normalized_region["availability_domains"] = []
|
||||||
|
normalized_region["vcns"] = []
|
||||||
|
normalized_region["compartments"] = []
|
||||||
|
normalized_region["observability"] = pick_list(raw_region, "observability")
|
||||||
|
remember(raw_region, normalized_region["id"])
|
||||||
|
|
||||||
|
for ad in pick_list(raw_region, "availability_domains"):
|
||||||
|
raw_ad = dict(ad or {})
|
||||||
|
ad_label = pick(raw_ad, "label", "name", "title")
|
||||||
|
ad_id = pick(raw_ad, "id", "name") or id_gen.make("ad", ad_label)
|
||||||
|
normalized_ad = dict(raw_ad)
|
||||||
|
normalized_ad["id"] = str(ad_id)
|
||||||
|
normalized_ad["label"] = ad_label or str(ad_id)
|
||||||
|
remember(raw_ad, normalized_ad["id"])
|
||||||
|
normalized_region["availability_domains"].append(normalized_ad)
|
||||||
|
|
||||||
|
def normalize_vcn(raw_vcn: dict) -> dict:
|
||||||
|
vcn_label = pick(raw_vcn, "label", "name", "title")
|
||||||
|
vcn_id = pick(raw_vcn, "id", "name") or id_gen.make("vcn", vcn_label)
|
||||||
|
normalized_vcn = dict(raw_vcn)
|
||||||
|
normalized_vcn["id"] = str(vcn_id)
|
||||||
|
normalized_vcn["label"] = vcn_label or str(vcn_id)
|
||||||
|
parent_ref = pick(raw_vcn, "parent", default="")
|
||||||
|
if parent_ref:
|
||||||
|
normalized_vcn["parent"] = resolve_ref(parent_ref) or str(parent_ref)
|
||||||
|
normalized_vcn["gateways"] = [
|
||||||
|
normalize_service(gw, "gw", default_type="drg" if pick(gw, "type", "service_type") in ("drg", "dynamic_routing_gateway") else "compute")
|
||||||
|
for gw in pick_list(raw_vcn, "gateways")
|
||||||
|
]
|
||||||
|
normalized_vcn["subnets"] = []
|
||||||
|
remember(raw_vcn, normalized_vcn["id"])
|
||||||
|
|
||||||
|
for subnet in pick_list(raw_vcn, "subnets"):
|
||||||
|
raw_subnet = dict(subnet or {})
|
||||||
|
subnet_label = pick(raw_subnet, "label", "name", "title")
|
||||||
|
subnet_id = pick(raw_subnet, "id", "name") or id_gen.make("subnet", subnet_label)
|
||||||
|
normalized_subnet = dict(raw_subnet)
|
||||||
|
normalized_subnet["id"] = str(subnet_id)
|
||||||
|
normalized_subnet["label"] = subnet_label or str(subnet_id)
|
||||||
|
normalized_subnet["services"] = [
|
||||||
|
normalize_service(svc, "svc", default_type="compute")
|
||||||
|
for svc in pick_list(raw_subnet, "services")
|
||||||
|
]
|
||||||
|
remember(raw_subnet, normalized_subnet["id"])
|
||||||
|
normalized_vcn["subnets"].append(normalized_subnet)
|
||||||
|
return normalized_vcn
|
||||||
|
|
||||||
|
for vcn in pick_list(raw_region, "vcns"):
|
||||||
|
normalized_region["vcns"].append(normalize_vcn(dict(vcn or {})))
|
||||||
|
|
||||||
|
for comp in pick_list(raw_region, "compartments"):
|
||||||
|
raw_comp = dict(comp or {})
|
||||||
|
comp_label = pick(raw_comp, "label", "name", "title")
|
||||||
|
comp_id = pick(raw_comp, "id", "name") or id_gen.make("compartment", comp_label)
|
||||||
|
normalized_comp = dict(raw_comp)
|
||||||
|
normalized_comp["id"] = str(comp_id)
|
||||||
|
normalized_comp["label"] = comp_label or str(comp_id)
|
||||||
|
normalized_comp["vcns"] = [
|
||||||
|
normalize_vcn(dict(vcn or {}))
|
||||||
|
for vcn in pick_list(raw_comp, "vcns")
|
||||||
|
]
|
||||||
|
remember(raw_comp, normalized_comp["id"])
|
||||||
|
normalized_region["compartments"].append(normalized_comp)
|
||||||
|
|
||||||
|
normalized_tenancy["regions"].append(normalized_region)
|
||||||
|
|
||||||
|
normalized["tenancy"] = normalized_tenancy
|
||||||
|
|
||||||
|
onprem_spec = spec.get("onprem")
|
||||||
|
if not onprem_spec:
|
||||||
|
onprem_spec = spec.get("on_prem")
|
||||||
|
if onprem_spec:
|
||||||
|
raw_onprem = dict(onprem_spec or {})
|
||||||
|
onprem_label = pick(raw_onprem, "label", "name", "title", default="On-Premises Data Center")
|
||||||
|
normalized_onprem = dict(raw_onprem)
|
||||||
|
normalized_onprem["id"] = pick(raw_onprem, "id", "name", default="onprem") or "onprem"
|
||||||
|
normalized_onprem["label"] = onprem_label
|
||||||
|
normalized_onprem["services"] = [
|
||||||
|
normalize_service(svc, "onprem-svc", default_type="compute")
|
||||||
|
for svc in pick_list(raw_onprem, "services")
|
||||||
|
]
|
||||||
|
remember(raw_onprem, normalized_onprem["id"])
|
||||||
|
normalized["onprem"] = normalized_onprem
|
||||||
|
|
||||||
|
for conn in pick_list(spec, "connections"):
|
||||||
|
raw_conn = dict(conn or {})
|
||||||
|
source = resolve_ref(pick(raw_conn, "from", "source", default=None))
|
||||||
|
target = resolve_ref(pick(raw_conn, "to", "target", default=None))
|
||||||
|
if not source or not target:
|
||||||
|
continue
|
||||||
|
normalized_conn = dict(raw_conn)
|
||||||
|
normalized_conn["from"] = source
|
||||||
|
normalized_conn["to"] = target
|
||||||
|
normalized_conn["type"] = pick(raw_conn, "type", "service_type", default="standard")
|
||||||
|
normalized["connections"].append(normalized_conn)
|
||||||
|
|
||||||
|
return normalized
|
||||||
|
|
||||||
# Unicode circled numbers for flow badges ① ② ③ ... ⑳
|
# Unicode circled numbers for flow badges ① ② ③ ... ⑳
|
||||||
_CIRCLED_NUMS = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳"
|
_CIRCLED_NUMS = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳"
|
||||||
|
|
||||||
@@ -473,6 +689,52 @@ class OCIDiagramGenerator:
|
|||||||
.replace('"', """)
|
.replace('"', """)
|
||||||
.replace("\n", "
"))
|
.replace("\n", "
"))
|
||||||
|
|
||||||
|
# Layout constants — calibrated from 37 Oracle Architecture Center .drawio files
|
||||||
|
# (12,617 cells parsed: 234x 63x63 service icons, 245x 42x42 gateway icons)
|
||||||
|
VCN_TOP_PAD = 50 # Oracle ref: VCN top to first subnet
|
||||||
|
SUBNET_GAP = 28 # Oracle ref: subnet-to-subnet gap
|
||||||
|
SVC_SPACING = 28 # Oracle ref: service-to-service horizontal gap
|
||||||
|
GW_DEFAULT_H = 110 # Gateway block: ~70px icon + 2px gap + 36px label
|
||||||
|
GW_SPACING = 20 # Gateway-to-gateway vertical gap
|
||||||
|
MIN_ICON_W = 42 # Oracle ref: gateway icons = 42x42
|
||||||
|
MIN_ICON_H = 42 # Oracle ref: gateway icons = 42x42
|
||||||
|
SVC_ICON_TARGET = 63 # Oracle ref: service icons = 63x63
|
||||||
|
GW_ICON_TARGET = 42 # Oracle ref: gateway icons = 42x42
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _calc_service_block_h(cls, label: str, service_type: str) -> int:
|
||||||
|
"""Calculate the total height a service block needs (icon + label).
|
||||||
|
|
||||||
|
Used by both auto-sizing (pre-calculation) and rendering to ensure
|
||||||
|
containers are always big enough for their content.
|
||||||
|
"""
|
||||||
|
n_lines = label.count('\n') + 1
|
||||||
|
label_h = max(n_lines * 16 + 4, 20)
|
||||||
|
|
||||||
|
# Estimate icon height using Oracle ref targets (63px height, 45px min width)
|
||||||
|
icon_entry = cls.ICON_CACHE.get(service_type) if cls.ICON_CACHE else None
|
||||||
|
if icon_entry:
|
||||||
|
iw, ih = icon_entry["w"], icon_entry["h"]
|
||||||
|
scale = min(63 / ih, 1.0)
|
||||||
|
if iw * scale < 45:
|
||||||
|
scale = 45 / iw
|
||||||
|
icon_h = int(ih * scale)
|
||||||
|
else:
|
||||||
|
icon_h = 63
|
||||||
|
|
||||||
|
return icon_h + 2 + label_h # icon + gap + label
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _calc_subnet_h(cls, subnet_spec: dict) -> int:
|
||||||
|
"""Calculate the auto-height for a subnet from its services."""
|
||||||
|
svcs = subnet_spec.get("services", [])
|
||||||
|
max_svc_h = 80
|
||||||
|
for svc in svcs:
|
||||||
|
label = svc.get("label", "").replace("\\n", "\n")
|
||||||
|
block_h = cls._calc_service_block_h(label, svc.get("type", "compute"))
|
||||||
|
max_svc_h = max(max_svc_h, block_h)
|
||||||
|
return 30 + max_svc_h + 15 # top pad + content + bottom pad
|
||||||
|
|
||||||
def _get_svc_style(self, service_type: str, font_size: Optional[int] = None) -> str:
|
def _get_svc_style(self, service_type: str, font_size: Optional[int] = None) -> str:
|
||||||
"""Get the style string for a service type."""
|
"""Get the style string for a service type."""
|
||||||
category = SVC_CATEGORY.get(service_type, "svc_infra")
|
category = SVC_CATEGORY.get(service_type, "svc_infra")
|
||||||
@@ -744,27 +1006,61 @@ class OCIDiagramGenerator:
|
|||||||
icon_cells = icon_entry["cells"]
|
icon_cells = icon_entry["cells"]
|
||||||
|
|
||||||
# Label height: proportional to actual line count (no over-allocation).
|
# Label height: proportional to actual line count (no over-allocation).
|
||||||
# ~15px per line at 11pt Oracle Sans, minimum 20px for single-line labels.
|
# ~16px per line at 12pt Oracle Sans, minimum 20px for single-line labels.
|
||||||
n_lines = label.count('\n') + 1
|
n_lines = label.count('\n') + 1
|
||||||
label_h = max(n_lines * 16 + 4, 20)
|
label_h = max(n_lines * 16 + 4, 20)
|
||||||
|
|
||||||
# Icon area = full spec height minus label height
|
# ── Icon sizing: Oracle ref arch standard ──
|
||||||
icon_area_h = max(h - label_h, 20)
|
# Oracle reference diagrams use ~55-65px icons that are visually
|
||||||
|
# prominent. We enforce minimum rendered width of 50px so that
|
||||||
|
# tall/narrow icons (ExaCS, Compute, DBCS, NAT GW — 21 of 61 icons
|
||||||
|
# have aspect ratio > 1.3) don't render as tiny slivers.
|
||||||
|
MIN_ICON_W = 50 # minimum rendered icon WIDTH (px)
|
||||||
|
MIN_ICON_H = 60 # minimum rendered icon HEIGHT (px)
|
||||||
|
|
||||||
# Scale icon to fit the icon area (never upscale beyond original size)
|
# For tall/narrow icons (ratio > 1.3), we need more vertical space
|
||||||
scale = min(w / icon_w, icon_area_h / icon_h, 1.0)
|
# so the width doesn't shrink below MIN_ICON_W when scaling to fit.
|
||||||
|
aspect_ratio = icon_h / icon_w if icon_w > 0 else 1
|
||||||
|
if aspect_ratio > 1.3:
|
||||||
|
# Need enough height so that width = icon_w * scale >= MIN_ICON_W
|
||||||
|
# scale = MIN_ICON_W / icon_w, needed_h = icon_h * scale
|
||||||
|
needed_h = int(icon_h * (MIN_ICON_W / icon_w))
|
||||||
|
icon_area_h = max(h - label_h, MIN_ICON_H, needed_h)
|
||||||
|
else:
|
||||||
|
icon_area_h = max(h - label_h, MIN_ICON_H)
|
||||||
|
|
||||||
|
# Scale icon to Oracle ref standard (calibrated from 37 Oracle .drawio files):
|
||||||
|
# Oracle uses 63x63 square icon boxes. Our OCI Library stencils are
|
||||||
|
# rectangular (tall), so we target 63px height and enforce minimum
|
||||||
|
# 45px width for legibility of detailed multi-cell stencils.
|
||||||
|
ICON_TARGET_H = 63 # Oracle ref: 63px icon box height
|
||||||
|
ICON_MIN_W = 45 # Minimum width for legible stencils
|
||||||
|
|
||||||
|
# Scale to fit target height
|
||||||
|
scale = min(ICON_TARGET_H / icon_h, 1.0)
|
||||||
scaled_w = icon_w * scale
|
scaled_w = icon_w * scale
|
||||||
scaled_h = icon_h * scale
|
scaled_h = icon_h * scale
|
||||||
|
|
||||||
# Icon group height = scaled icon height only.
|
# If too narrow, scale up to meet minimum width
|
||||||
# Connections terminate here — at the visible icon boundary.
|
if scaled_w < ICON_MIN_W:
|
||||||
icon_group_h = max(int(scaled_h), 20)
|
scale = ICON_MIN_W / icon_w
|
||||||
|
scaled_w = icon_w * scale
|
||||||
|
scaled_h = icon_h * scale
|
||||||
|
|
||||||
|
# Icon group dimensions = scaled icon size (not spec width).
|
||||||
|
# This ensures edges connect TO the visible icon, not to an
|
||||||
|
# invisible wide group. The label (sibling) uses spec width.
|
||||||
|
icon_group_w = max(int(scaled_w) + 4, MIN_ICON_W) # small padding
|
||||||
|
icon_group_h = max(int(scaled_h), MIN_ICON_H)
|
||||||
|
|
||||||
|
# Center the icon group horizontally within the spec width
|
||||||
|
icon_group_x = x + (w - icon_group_w) // 2
|
||||||
|
|
||||||
group_style = (
|
group_style = (
|
||||||
"group;fillColor=none;strokeColor=none;pointerEvents=1;"
|
"group;fillColor=none;strokeColor=none;pointerEvents=1;"
|
||||||
"connectable=1;"
|
"connectable=1;"
|
||||||
)
|
)
|
||||||
self._create_object(cell_id, "", group_style, parent, x, y, w, icon_group_h)
|
self._create_object(cell_id, "", group_style, parent, icon_group_x, y, icon_group_w, icon_group_h)
|
||||||
|
|
||||||
# Build ID mapping: original cell id -> unique cell id
|
# Build ID mapping: original cell id -> unique cell id
|
||||||
original_ids = set()
|
original_ids = set()
|
||||||
@@ -777,8 +1073,8 @@ class OCIDiagramGenerator:
|
|||||||
key=lambda x_: int(x_) if x_.isdigit() else 0):
|
key=lambda x_: int(x_) if x_.isdigit() else 0):
|
||||||
id_map[orig_id] = f"{cell_id}_i{orig_id}"
|
id_map[orig_id] = f"{cell_id}_i{orig_id}"
|
||||||
|
|
||||||
# Center the icon horizontally within the group
|
# Center the icon horizontally within the (now tight) group
|
||||||
icon_offset_x = (w - scaled_w) / 2
|
icon_offset_x = (icon_group_w - scaled_w) / 2
|
||||||
|
|
||||||
# Use a placeholder for the group's drawpyo ID — replaced in to_xml()
|
# Use a placeholder for the group's drawpyo ID — replaced in to_xml()
|
||||||
group_placeholder = f"__DRAWPYO_{cell_id}__"
|
group_placeholder = f"__DRAWPYO_{cell_id}__"
|
||||||
@@ -830,8 +1126,8 @@ class OCIDiagramGenerator:
|
|||||||
|
|
||||||
# Label is a SIBLING of the icon group (child of `parent`, not of `cell_id`).
|
# Label is a SIBLING of the icon group (child of `parent`, not of `cell_id`).
|
||||||
# This keeps connection geometry clean: arrows connect to the icon group only.
|
# This keeps connection geometry clean: arrows connect to the icon group only.
|
||||||
# Oracle ref arch style: 11pt, charcoal (#312D2A), centered below icon.
|
# Oracle ref arch style: 12pt, charcoal (#312D2A), centered below icon.
|
||||||
fs = font_size or 11
|
fs = font_size or 12
|
||||||
label_style = (
|
label_style = (
|
||||||
f"text;html=1;whiteSpace=wrap;overflow=visible;align=center;verticalAlign=top;"
|
f"text;html=1;whiteSpace=wrap;overflow=visible;align=center;verticalAlign=top;"
|
||||||
f"fontFamily=Oracle Sans;fontSize={fs};fontColor=#312D2A;"
|
f"fontFamily=Oracle Sans;fontSize={fs};fontColor=#312D2A;"
|
||||||
@@ -909,6 +1205,7 @@ class OCIDiagramGenerator:
|
|||||||
# - NO elbow=vertical (it conflicts with orthogonal + port constraints)
|
# - NO elbow=vertical (it conflicts with orthogonal + port constraints)
|
||||||
extra_style = (
|
extra_style = (
|
||||||
"edgeStyle=orthogonalEdgeStyle;orthogonalLoop=1;"
|
"edgeStyle=orthogonalEdgeStyle;orthogonalLoop=1;"
|
||||||
|
"jettySize=auto;"
|
||||||
"jumpStyle=arc;jumpSize=8;"
|
"jumpStyle=arc;jumpSize=8;"
|
||||||
"endFill=0;startFill=0;"
|
"endFill=0;startFill=0;"
|
||||||
"labelBackgroundColor=#FFFFFF;labelBorderColor=none;"
|
"labelBackgroundColor=#FFFFFF;labelBorderColor=none;"
|
||||||
@@ -1121,6 +1418,60 @@ class OCIDiagramGenerator:
|
|||||||
if pattern in xml:
|
if pattern in xml:
|
||||||
xml = xml.replace(pattern, f'id="{edge_id}" style="{extra_style}')
|
xml = xml.replace(pattern, f'id="{edge_id}" style="{extra_style}')
|
||||||
|
|
||||||
|
# ── Edge label offset injection ──
|
||||||
|
# Push labels away from the edge midpoint to avoid overlapping with
|
||||||
|
# source/target icons. Uses mxGeometry relative positioning:
|
||||||
|
# x: position along edge (-1=source, 0=center, 1=target)
|
||||||
|
# <mxPoint as="offset">: absolute pixel offset from that position
|
||||||
|
# Horizontal edges: push label 15px above the line (offset y=-15)
|
||||||
|
# Vertical edges: push label 15px to the right (offset x=15)
|
||||||
|
for edge_id, (src_id, tgt_id) in self._edge_objects.items():
|
||||||
|
src_pos = self._abs_positions.get(src_id)
|
||||||
|
tgt_pos = self._abs_positions.get(tgt_id)
|
||||||
|
if not src_pos or not tgt_pos:
|
||||||
|
continue
|
||||||
|
src_obj = self._objects.get(src_id)
|
||||||
|
tgt_obj = self._objects.get(tgt_id)
|
||||||
|
if not src_obj or not tgt_obj:
|
||||||
|
continue
|
||||||
|
sx = src_pos[0] + (getattr(src_obj, 'width', 100) or 100) / 2
|
||||||
|
sy = src_pos[1] + (getattr(src_obj, 'height', 60) or 60) / 2
|
||||||
|
tx = tgt_pos[0] + (getattr(tgt_obj, 'width', 100) or 100) / 2
|
||||||
|
ty = tgt_pos[1] + (getattr(tgt_obj, 'height', 60) or 60) / 2
|
||||||
|
dx = tx - sx
|
||||||
|
dy = ty - sy
|
||||||
|
dist = max((dx**2 + dy**2) ** 0.5, 1)
|
||||||
|
# Larger offset for short connections to avoid icon overlap
|
||||||
|
base_offset = 20 if dist < 300 else 15
|
||||||
|
is_horiz = abs(dx) > abs(dy) * 1.5
|
||||||
|
if is_horiz:
|
||||||
|
offset_x, offset_y = 0, -base_offset
|
||||||
|
else:
|
||||||
|
offset_x, offset_y = base_offset, 0
|
||||||
|
# Find the edge's mxGeometry and inject label offset.
|
||||||
|
# drawpyo emits: <mxGeometry relative="1" as="geometry" />
|
||||||
|
# We replace with expanded form containing offset point.
|
||||||
|
new_geo = (
|
||||||
|
f'<mxGeometry x="-0.2" y="0" relative="1" as="geometry">'
|
||||||
|
f'<mxPoint x="{offset_x}" y="{offset_y}" as="offset" />'
|
||||||
|
f'</mxGeometry>'
|
||||||
|
)
|
||||||
|
# Match both self-closing variants that drawpyo may produce
|
||||||
|
for old_geo in [
|
||||||
|
'<mxGeometry relative="1" as="geometry" />',
|
||||||
|
'<mxGeometry relative="1" as="geometry"/>',
|
||||||
|
]:
|
||||||
|
marker = f'id="{edge_id}"'
|
||||||
|
if marker in xml and old_geo in xml:
|
||||||
|
# Only replace the mxGeometry that belongs to THIS edge
|
||||||
|
# Find the edge cell, then replace its geometry
|
||||||
|
idx = xml.find(marker)
|
||||||
|
if idx >= 0:
|
||||||
|
geo_idx = xml.find(old_geo, idx)
|
||||||
|
if geo_idx >= 0 and geo_idx - idx < 2000: # sanity: within same cell
|
||||||
|
xml = xml[:geo_idx] + new_geo + xml[geo_idx + len(old_geo):]
|
||||||
|
break
|
||||||
|
|
||||||
# Inject raw icon stencil cells before </root>
|
# Inject raw icon stencil cells before </root>
|
||||||
if self._raw_cells:
|
if self._raw_cells:
|
||||||
inject = "\n".join(f" {c}" for c in self._raw_cells)
|
inject = "\n".join(f" {c}" for c in self._raw_cells)
|
||||||
@@ -1132,6 +1483,17 @@ class OCIDiagramGenerator:
|
|||||||
if placeholder in xml:
|
if placeholder in xml:
|
||||||
xml = xml.replace(placeholder, str(obj.id))
|
xml = xml.replace(placeholder, str(obj.id))
|
||||||
|
|
||||||
|
# Inject container=1;collapsible=0 into container styles via XML.
|
||||||
|
# drawpyo doesn't support 'container' as a settable property, so we
|
||||||
|
# append it to the style string in the final XML.
|
||||||
|
for container_marker in ["arcSize=1;", "arcSize=5;", "arcSize=3;",
|
||||||
|
"perimeterSpacing=0;", "spacingTop=4;"]:
|
||||||
|
# Only add to styles that already contain these OCI container markers
|
||||||
|
# and don't already have container=1
|
||||||
|
old = f'{container_marker}"'
|
||||||
|
new = f'{container_marker}container=1;collapsible=0;"'
|
||||||
|
xml = xml.replace(old, new)
|
||||||
|
|
||||||
# Sanitize: no pure white in OCI palette
|
# Sanitize: no pure white in OCI palette
|
||||||
xml = xml.replace('fill="#ffffff"', 'fill="#FCFBFA"')
|
xml = xml.replace('fill="#ffffff"', 'fill="#FCFBFA"')
|
||||||
xml = xml.replace('fill="#FFFFFF"', 'fill="#FCFBFA"')
|
xml = xml.replace('fill="#FFFFFF"', 'fill="#FCFBFA"')
|
||||||
@@ -1168,6 +1530,7 @@ class OCIDiagramGenerator:
|
|||||||
Plus optional on-premises, external actors, and connections.
|
Plus optional on-premises, external actors, and connections.
|
||||||
See examples/diagram-spec.yaml for the full format.
|
See examples/diagram-spec.yaml for the full format.
|
||||||
"""
|
"""
|
||||||
|
spec = _normalize_diagram_spec(spec)
|
||||||
gen = cls()
|
gen = cls()
|
||||||
|
|
||||||
# External actors (users, internet, third-party)
|
# External actors (users, internet, third-party)
|
||||||
@@ -1219,35 +1582,150 @@ class OCIDiagramGenerator:
|
|||||||
else:
|
else:
|
||||||
gen.add_service(svc_id, label, svc.get("type", "compute"),
|
gen.add_service(svc_id, label, svc.get("type", "compute"),
|
||||||
cloud_id, cx, cy, svc_w, svc_h)
|
cloud_id, cx, cy, svc_w, svc_h)
|
||||||
cy += svc_h + 20
|
actual_h = gen._calc_service_block_h(label, svc.get("type", "compute"))
|
||||||
|
cy += max(svc_h, actual_h) + 20
|
||||||
|
|
||||||
# Tenancy
|
# Tenancy — auto-size from regions if w/h not specified in spec
|
||||||
tenancy_spec = spec.get("tenancy", {})
|
tenancy_spec = spec.get("tenancy", {})
|
||||||
tenancy_x = tenancy_spec.get("x", 30)
|
tenancy_x = tenancy_spec.get("x", 30)
|
||||||
tenancy_y = tenancy_spec.get("y", 80)
|
tenancy_y = tenancy_spec.get("y", 80)
|
||||||
tenancy_w = tenancy_spec.get("w", 1850)
|
|
||||||
tenancy_h = tenancy_spec.get("h", 720)
|
# Pre-calculate tenancy size from region content if not explicit
|
||||||
|
# Always pre-calculate auto-size even if spec has dimensions (use max)
|
||||||
|
if True:
|
||||||
|
regions = tenancy_spec.get("regions", [])
|
||||||
|
max_region_right = 0
|
||||||
|
max_region_bottom = 0
|
||||||
|
region_x_cursor = 15
|
||||||
|
for r in regions:
|
||||||
|
# Estimate region dimensions (will be refined when regions are created)
|
||||||
|
r_vcns = r.get("vcns", [])
|
||||||
|
r_has_drg = any(
|
||||||
|
gw.get("type") in ("drg", "dynamic_routing_gateway")
|
||||||
|
for v in r_vcns for gw in v.get("gateways", [])
|
||||||
|
)
|
||||||
|
drg_w = 120 if r_has_drg else 0
|
||||||
|
# Subnet height calc (mirrors actual rendering)
|
||||||
|
total_sub_h = 0
|
||||||
|
max_sub_w = 300
|
||||||
|
for v in r_vcns:
|
||||||
|
for s in v.get("subnets", []):
|
||||||
|
auto_sh = cls._calc_subnet_h(s)
|
||||||
|
total_sub_h += max(s.get("h", auto_sh), auto_sh) + cls.SUBNET_GAP
|
||||||
|
svcs = s.get("services", [])
|
||||||
|
row_w = sum(sv.get("w", 150) for sv in svcs) + cls.SVC_SPACING * max(0, len(svcs) - 1) + 30
|
||||||
|
max_sub_w = max(max_sub_w, row_w)
|
||||||
|
vcn_gws = [gw for gw in v.get("gateways", [])
|
||||||
|
if gw.get("type") not in ("drg", "dynamic_routing_gateway")]
|
||||||
|
if vcn_gws:
|
||||||
|
max_sub_w += max(gw.get("w", 110) for gw in vcn_gws) + 20
|
||||||
|
|
||||||
|
est_rw = max_sub_w + drg_w + 80
|
||||||
|
est_rh = total_sub_h + 150 # 45 top + VCN_TOP_PAD(50) + bottom padding + margins
|
||||||
|
|
||||||
|
rx_val = r.get("x", region_x_cursor)
|
||||||
|
ry_val = r.get("y", 40)
|
||||||
|
max_region_right = max(max_region_right, rx_val + est_rw)
|
||||||
|
max_region_bottom = max(max_region_bottom, ry_val + est_rh)
|
||||||
|
region_x_cursor = max_region_right + 20
|
||||||
|
|
||||||
|
auto_tw = max(max_region_right + 30, 600)
|
||||||
|
auto_th = max(max_region_bottom + 30, 400)
|
||||||
|
tenancy_w = max(tenancy_spec.get("w", auto_tw), auto_tw)
|
||||||
|
tenancy_h = max(tenancy_spec.get("h", auto_th), auto_th)
|
||||||
|
|
||||||
gen.add_tenancy(
|
gen.add_tenancy(
|
||||||
"tenancy",
|
"tenancy",
|
||||||
tenancy_spec.get("label", "Oracle Cloud Infrastructure"),
|
tenancy_spec.get("label", "Oracle Cloud Infrastructure"),
|
||||||
x=tenancy_x, y=tenancy_y, w=tenancy_w, h=tenancy_h,
|
x=tenancy_x, y=tenancy_y, w=tenancy_w, h=tenancy_h,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Regions
|
# Regions — auto-size containers from content when spec doesn't override
|
||||||
|
region_x_cursor = 15 # tracks right edge for auto-positioning next region
|
||||||
|
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", 15 if is_primary else 1195)
|
rx = region.get("x", region_x_cursor)
|
||||||
# ry default 40: ~18px below tenancy label (12pt bold) + comfortable padding
|
|
||||||
ry = region.get("y", 40)
|
ry = region.get("y", 40)
|
||||||
rw = region.get("w", 1140 if is_primary else 640)
|
|
||||||
rh = region.get("h", 670 if is_primary else 480)
|
# ── Pre-calculate content size for auto-sizing ──
|
||||||
|
# Walk all VCNs → subnets → services to compute minimum dimensions
|
||||||
|
region_vcns = region.get("vcns", [])
|
||||||
|
auto_rw, auto_rh = 640 if not is_primary else 1140, 480 if not is_primary else 670
|
||||||
|
|
||||||
|
if region_vcns:
|
||||||
|
# Calculate total height needed from subnets + gateways
|
||||||
|
for vcn in region_vcns:
|
||||||
|
gateways = vcn.get("gateways", [])
|
||||||
|
# Separate DRG from VCN-internal gateways (IGW, NAT, SGW)
|
||||||
|
drg_gateways = [gw for gw in gateways if gw.get("type") in ("drg", "dynamic_routing_gateway")]
|
||||||
|
vcn_gateways = [gw for gw in gateways if gw.get("type") not in ("drg", "dynamic_routing_gateway")]
|
||||||
|
|
||||||
|
gw_lane_w = 0
|
||||||
|
if vcn_gateways:
|
||||||
|
gw_lane_w = max(gw.get("w", 110) for gw in vcn_gateways) + 20
|
||||||
|
|
||||||
|
subnets = vcn.get("subnets", [])
|
||||||
|
|
||||||
|
# Calculate actual subnet heights (same logic as rendering)
|
||||||
|
total_subnet_h = 0
|
||||||
|
max_svc_row_w = 0
|
||||||
|
for subnet in subnets:
|
||||||
|
auto_sh = cls._calc_subnet_h(subnet)
|
||||||
|
sh = max(subnet.get("h", auto_sh), auto_sh)
|
||||||
|
total_subnet_h += sh + cls.SUBNET_GAP
|
||||||
|
|
||||||
|
svcs = subnet.get("services", [])
|
||||||
|
row_w = sum(svc.get("w", 150) for svc in svcs) + cls.SVC_SPACING * max(0, len(svcs) - 1) + 30
|
||||||
|
max_svc_row_w = max(max_svc_row_w, row_w)
|
||||||
|
|
||||||
|
# VCN = gw_lane + content + margins
|
||||||
|
content_w = gw_lane_w + max(max_svc_row_w, 300) + 40
|
||||||
|
content_h = cls.VCN_TOP_PAD + total_subnet_h + 20
|
||||||
|
|
||||||
|
# Also account for gateway column height
|
||||||
|
if vcn_gateways:
|
||||||
|
total_gw_h = sum(gw.get("h", cls.GW_DEFAULT_H) for gw in vcn_gateways) + cls.GW_SPACING * max(0, len(vcn_gateways) - 1) + 64
|
||||||
|
content_h = max(content_h, total_gw_h)
|
||||||
|
|
||||||
|
# Store calculated VCN dimensions for use below
|
||||||
|
vcn["_auto_w"] = content_w
|
||||||
|
vcn["_auto_h"] = content_h
|
||||||
|
vcn["_drg_gateways"] = drg_gateways
|
||||||
|
vcn["_vcn_gateways"] = vcn_gateways
|
||||||
|
|
||||||
|
# Region size = VCN size + margins + DRG lane
|
||||||
|
total_vcn_w = max(v.get("_auto_w", 600) for v in region_vcns)
|
||||||
|
total_vcn_h = max(v.get("_auto_h", 400) for v in region_vcns)
|
||||||
|
# DRG lane on left edge of region (outside VCN)
|
||||||
|
has_drg = any(v.get("_drg_gateways") for v in region_vcns)
|
||||||
|
drg_lane_w = 120 if has_drg else 0
|
||||||
|
auto_rw = total_vcn_w + drg_lane_w + 50 # margins
|
||||||
|
auto_rh = total_vcn_h + 70 # 45 top (label) + 25 bottom
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
gen.add_region(
|
gen.add_region(
|
||||||
region["id"],
|
region["id"],
|
||||||
region["label"],
|
region["label"],
|
||||||
"tenancy", rx, ry, rw, rh,
|
"tenancy", rx, ry, rw, rh,
|
||||||
)
|
)
|
||||||
|
# Advance cursor for next region positioning
|
||||||
|
region_x_cursor = rx + rw + 20
|
||||||
|
|
||||||
# Availability Domains (optional)
|
# Availability Domains (optional)
|
||||||
for ad in region.get("availability_domains", []):
|
for ad in region.get("availability_domains", []):
|
||||||
@@ -1257,60 +1735,95 @@ class OCIDiagramGenerator:
|
|||||||
w=ad.get("w", rw - 30), h=ad.get("h", rh - 70),
|
w=ad.get("w", rw - 30), h=ad.get("h", rh - 70),
|
||||||
)
|
)
|
||||||
|
|
||||||
# VCNs
|
# ── DRG placement: OUTSIDE VCN, INSIDE region ──
|
||||||
for vcn in region.get("vcns", []):
|
# Oracle ref arch pattern: DRG sits in the region between external
|
||||||
|
# actors and VCNs, not inside any VCN.
|
||||||
|
has_drg = any(v.get("_drg_gateways") for v in region_vcns)
|
||||||
|
drg_lane_w = 120 if has_drg else 0
|
||||||
|
for vcn in region_vcns:
|
||||||
|
for drg in vcn.get("_drg_gateways", []):
|
||||||
|
drg_id = drg.get("id", gen._next_id())
|
||||||
|
drg_w = drg.get("w", 90)
|
||||||
|
drg_h = drg.get("h", 60)
|
||||||
|
# Position: left edge of region, vertically centered
|
||||||
|
drg_x = 15
|
||||||
|
drg_y = max(45, (rh - drg_h) // 2)
|
||||||
|
label = drg["label"].replace("\\n", "\n")
|
||||||
|
gen.add_service(
|
||||||
|
drg_id, label, drg["type"],
|
||||||
|
region["id"], drg_x, drg_y, drg_w, drg_h,
|
||||||
|
)
|
||||||
|
drg_by_region.append((region["id"], drg_id))
|
||||||
|
|
||||||
|
# VCNs — offset right if DRG lane present
|
||||||
|
vcn_offset_x = drg_lane_w + 15
|
||||||
|
for vcn in region_vcns:
|
||||||
vcn_parent = vcn.get("parent", region["id"])
|
vcn_parent = vcn.get("parent", region["id"])
|
||||||
vcn_w = vcn.get("w", rw - 30)
|
auto_w = vcn.get("_auto_w", rw - vcn_offset_x - 15)
|
||||||
vcn_h = vcn.get("h", rh - 70)
|
auto_h = vcn.get("_auto_h", rh - 70)
|
||||||
|
# Always use at least auto-calculated size even if spec is smaller
|
||||||
|
vcn_w = max(vcn.get("w", auto_w), auto_w)
|
||||||
|
vcn_h = max(vcn.get("h", auto_h), auto_h)
|
||||||
gen.add_vcn(
|
gen.add_vcn(
|
||||||
vcn["id"],
|
vcn["id"],
|
||||||
f"VCN {vcn['label']}",
|
f"VCN {vcn['label']}",
|
||||||
vcn_parent,
|
vcn_parent,
|
||||||
# y default 45: leaves proper gap below the region label (12pt ≈22px + padding)
|
x=vcn.get("x", vcn_offset_x), y=vcn.get("y", 45),
|
||||||
x=vcn.get("x", 15), y=vcn.get("y", 45),
|
|
||||||
w=vcn_w, h=vcn_h,
|
w=vcn_w, h=vcn_h,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── GATEWAY-AWARE AUTO-LAYOUT ──
|
# ── VCN-internal gateways (IGW, NAT, SGW — NOT DRG) ──
|
||||||
# Step 1: Place gateways on the LEFT EDGE of the VCN.
|
vcn_gateways = vcn.get("_vcn_gateways", [])
|
||||||
# Step 2: Offset subnets RIGHT to avoid overlap with gateways.
|
gw_lane_w = 0
|
||||||
# Step 3: Stack subnets vertically (top-to-bottom).
|
|
||||||
# This matches Oracle ref arch style: gateways on VCN boundary,
|
|
||||||
# subnets fill the remaining width.
|
|
||||||
|
|
||||||
gateways = vcn.get("gateways", [])
|
if vcn_gateways:
|
||||||
gw_lane_w = 0 # width reserved for gateway column
|
GW_DEFAULT_W = 110
|
||||||
|
GW_DEFAULT_H = gen.GW_DEFAULT_H
|
||||||
if gateways:
|
GW_SPACING = gen.GW_SPACING
|
||||||
# Calculate gateway lane width (max gateway width + padding)
|
gw_lane_w = max(gw.get("w", GW_DEFAULT_W) for gw in vcn_gateways) + 20
|
||||||
gw_lane_w = max(gw.get("w", 110) for gw in gateways) + 20
|
total_gw_h = sum(gw.get("h", GW_DEFAULT_H) for gw in vcn_gateways) + GW_SPACING * max(0, len(vcn_gateways) - 1)
|
||||||
|
|
||||||
# Place gateways vertically centered in VCN
|
|
||||||
total_gw_h = sum(gw.get("h", 70) for gw in gateways) + 8 * max(0, len(gateways) - 1)
|
|
||||||
gw_start_y = max(32, (vcn_h - total_gw_h) // 2)
|
gw_start_y = max(32, (vcn_h - total_gw_h) // 2)
|
||||||
gy = gw_start_y
|
gy = gw_start_y
|
||||||
for gw in gateways:
|
for gw in vcn_gateways:
|
||||||
gw_id = gw.get("id", gen._next_id())
|
gw_id = gw.get("id", gen._next_id())
|
||||||
gw_w = gw.get("w", 110)
|
gw_w = gw.get("w", GW_DEFAULT_W)
|
||||||
gw_h = gw.get("h", 70)
|
|
||||||
gw_x = gw.get("x", 10)
|
gw_x = gw.get("x", 10)
|
||||||
gw_y = gw.get("y", gy)
|
gw_y = gw.get("y", gy)
|
||||||
label = gw["label"].replace("\\n", "\n")
|
label = gw["label"].replace("\\n", "\n")
|
||||||
|
# Calculate ACTUAL rendered block height for correct stacking
|
||||||
|
actual_block_h = gen._calc_service_block_h(label, gw["type"])
|
||||||
|
gw_h = max(gw.get("h", GW_DEFAULT_H), GW_DEFAULT_H, actual_block_h)
|
||||||
gen.add_service(
|
gen.add_service(
|
||||||
gw_id, label, gw["type"],
|
gw_id, label, gw["type"],
|
||||||
vcn["id"], gw_x, gw_y, gw_w, gw_h,
|
vcn["id"], gw_x, gw_y, gw_w, gw_h,
|
||||||
)
|
)
|
||||||
gy = gw_y + gw_h + 8
|
# Stack using actual rendered height, not spec height
|
||||||
|
gy = gw_y + actual_block_h + GW_SPACING
|
||||||
|
|
||||||
# Subnet horizontal offset: push right if gateways present
|
# Subnet offset — Oracle ref arch: 42px left indent, 50px top
|
||||||
subnet_x = 8 + gw_lane_w
|
VCN_LEFT_PAD = 42 if not vcn_gateways else 8
|
||||||
subnet_avail_w = vcn_w - subnet_x - 8 # remaining width for subnets
|
VCN_TOP_PAD = gen.VCN_TOP_PAD
|
||||||
|
SUBNET_GAP = gen.SUBNET_GAP
|
||||||
|
|
||||||
|
subnet_x = VCN_LEFT_PAD + gw_lane_w
|
||||||
|
subnet_avail_w = vcn_w - subnet_x - 16 # 16px right margin
|
||||||
|
|
||||||
# Stack subnets vertically (top-to-bottom)
|
# Stack subnets vertically (top-to-bottom)
|
||||||
subnet_y = 32
|
subnet_y = VCN_TOP_PAD
|
||||||
|
SVC_SPACING = gen.SVC_SPACING
|
||||||
for subnet in vcn.get("subnets", []):
|
for subnet in vcn.get("subnets", []):
|
||||||
sw = subnet.get("w", subnet_avail_w)
|
sw = subnet.get("w", subnet_avail_w)
|
||||||
sh = subnet.get("h", 120)
|
subnet_svcs = subnet.get("services", [])
|
||||||
|
|
||||||
|
# ── Auto-calculate subnet height from service content ──
|
||||||
|
# Each service = icon group (MIN 60px, grows for tall icons) + label + gap
|
||||||
|
# Icon group grows based on aspect ratio (see _add_service_with_icon)
|
||||||
|
# Use shared auto-height calculation
|
||||||
|
auto_sh = gen._calc_subnet_h(subnet)
|
||||||
|
sh = max(subnet.get("h", auto_sh), auto_sh)
|
||||||
|
# Calculate max service block height for layout
|
||||||
|
max_svc_h = auto_sh - 30 - 15 # reverse: total - top_pad - bottom_pad
|
||||||
|
|
||||||
gen.add_subnet(
|
gen.add_subnet(
|
||||||
subnet["id"], subnet["label"],
|
subnet["id"], subnet["label"],
|
||||||
vcn["id"], subnet_x, subnet_y, sw, sh,
|
vcn["id"], subnet_x, subnet_y, sw, sh,
|
||||||
@@ -1318,22 +1831,41 @@ class OCIDiagramGenerator:
|
|||||||
|
|
||||||
# Auto-layout services HORIZONTALLY within subnet, centered.
|
# Auto-layout services HORIZONTALLY within subnet, centered.
|
||||||
svc_top = 30
|
svc_top = 30
|
||||||
subnet_svcs = subnet.get("services", [])
|
|
||||||
svc_widths = [svc.get("w", min(sw - 30, 150)) for svc in subnet_svcs]
|
svc_widths = [svc.get("w", min(sw - 30, 150)) for svc in subnet_svcs]
|
||||||
total_svc_w = sum(svc_widths) + 16 * max(0, len(svc_widths) - 1)
|
total_svc_w = sum(svc_widths) + SVC_SPACING * max(0, len(svc_widths) - 1)
|
||||||
svc_x = max(15, (sw - total_svc_w) // 2)
|
svc_x = max(15, (sw - total_svc_w) // 2)
|
||||||
for svc, svc_w in zip(subnet_svcs, svc_widths):
|
for svc, svc_w in zip(subnet_svcs, svc_widths):
|
||||||
svc_id = svc.get("id", gen._next_id())
|
svc_id = svc.get("id", gen._next_id())
|
||||||
svc_h = svc.get("h", 80)
|
# Use auto-calculated height (ensures icons fit at MIN_ICON_H)
|
||||||
|
svc_h = max(svc.get("h", 80), max_svc_h)
|
||||||
label = svc["label"].replace("\\n", "\n")
|
label = svc["label"].replace("\\n", "\n")
|
||||||
gen.add_service(
|
gen.add_service(
|
||||||
svc_id, label, svc["type"],
|
svc_id, label, svc["type"],
|
||||||
subnet["id"], svc_x, svc_top, svc_w, svc_h,
|
subnet["id"], svc_x, svc_top, svc_w, svc_h,
|
||||||
font_size=svc.get("fontSize"),
|
font_size=svc.get("fontSize"),
|
||||||
)
|
)
|
||||||
svc_x += svc_w + 16
|
svc_x += svc_w + SVC_SPACING
|
||||||
|
|
||||||
subnet_y += sh + 14
|
subnet_y += sh + SUBNET_GAP
|
||||||
|
|
||||||
|
# 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", []):
|
||||||
@@ -1381,22 +1913,23 @@ class OCIDiagramGenerator:
|
|||||||
font_size=svc.get("fontSize"),
|
font_size=svc.get("fontSize"),
|
||||||
)
|
)
|
||||||
svc_x += svc_w + 16
|
svc_x += svc_w + 16
|
||||||
subnet_y += sh + 14
|
subnet_y += sh + 28 # Oracle ref: subnet gap
|
||||||
# Gateways on left side of compartment VCN, stacked vertically
|
# Gateways on left side of compartment VCN, stacked vertically
|
||||||
gx = 10
|
gx = 10
|
||||||
gy = 40
|
gy = 40
|
||||||
for gw in vcn.get("gateways", []):
|
for gw in vcn.get("gateways", []):
|
||||||
gw_id = gw.get("id", gen._next_id())
|
gw_id = gw.get("id", gen._next_id())
|
||||||
gw_w = gw.get("w", 95)
|
gw_w = gw.get("w", 95)
|
||||||
gw_h = gw.get("h", 60)
|
|
||||||
gw_x = gw.get("x", gx)
|
gw_x = gw.get("x", gx)
|
||||||
gw_y = gw.get("y", gy)
|
gw_y = gw.get("y", gy)
|
||||||
label = gw["label"].replace("\\n", "\n")
|
label = gw["label"].replace("\\n", "\n")
|
||||||
|
actual_h = gen._calc_service_block_h(label, gw["type"])
|
||||||
|
gw_h = max(gw.get("h", actual_h), actual_h)
|
||||||
gen.add_service(
|
gen.add_service(
|
||||||
gw_id, label, gw["type"],
|
gw_id, label, gw["type"],
|
||||||
vcn["id"], gw_x, gw_y, gw_w, gw_h,
|
vcn["id"], gw_x, gw_y, gw_w, gw_h,
|
||||||
)
|
)
|
||||||
gy = gw_y + gw_h + 8
|
gy = gw_y + actual_h + cls.GW_SPACING
|
||||||
|
|
||||||
# Observability row at bottom of region — uses icons (only if explicitly requested)
|
# Observability row at bottom of region — uses icons (only if explicitly requested)
|
||||||
if region.get("observability"):
|
if region.get("observability"):
|
||||||
@@ -1441,8 +1974,58 @@ class OCIDiagramGenerator:
|
|||||||
)
|
)
|
||||||
opx += svc_w + 12
|
opx += svc_w + 12
|
||||||
|
|
||||||
# Connections
|
# Cross-region RPC — when 2+ regions own a DRG and the spec didn't
|
||||||
for conn in spec.get("connections", []):
|
# 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
|
||||||
|
seen_pairs = {} # (from, to) -> index in deduped list
|
||||||
|
deduped = []
|
||||||
|
for conn in connections:
|
||||||
|
pair = (conn["from"], conn["to"])
|
||||||
|
if pair in seen_pairs:
|
||||||
|
# Merge: combine labels
|
||||||
|
existing = deduped[seen_pairs[pair]]
|
||||||
|
old_label = existing.get("label", "")
|
||||||
|
new_label = conn.get("label", "")
|
||||||
|
if old_label and new_label and old_label != new_label:
|
||||||
|
existing["label"] = f"{old_label}\n+ {new_label}"
|
||||||
|
elif new_label and not old_label:
|
||||||
|
existing["label"] = new_label
|
||||||
|
# Upgrade: if either is "Dual", keep it in label
|
||||||
|
else:
|
||||||
|
seen_pairs[pair] = len(deduped)
|
||||||
|
deduped.append(dict(conn))
|
||||||
|
|
||||||
|
for conn in deduped:
|
||||||
conn_id = conn.get("id", gen._next_id())
|
conn_id = conn.get("id", gen._next_id())
|
||||||
gen.add_connection(
|
gen.add_connection(
|
||||||
conn_id, conn.get("label"), conn["type"],
|
conn_id, conn.get("label"), conn["type"],
|
||||||
|
|||||||
@@ -273,14 +273,72 @@ def validate_catalog():
|
|||||||
return errors == 0
|
return errors == 0
|
||||||
|
|
||||||
|
|
||||||
|
def check_links():
|
||||||
|
"""Check all catalog URLs for broken links (HTTP HEAD requests)."""
|
||||||
|
data = load_catalog()
|
||||||
|
entries = data.get("entries", [])
|
||||||
|
broken = []
|
||||||
|
redirected = []
|
||||||
|
ok_count = 0
|
||||||
|
|
||||||
|
print(f"Checking {len(entries)} URLs...")
|
||||||
|
for i, entry in enumerate(entries):
|
||||||
|
url = entry.get("url", "")
|
||||||
|
title = entry.get("title", "UNKNOWN")
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
resp = requests.head(url, timeout=10, allow_redirects=True)
|
||||||
|
if resp.status_code == 404:
|
||||||
|
broken.append((title, url))
|
||||||
|
print(f" ❌ 404: {title}")
|
||||||
|
elif resp.status_code in (301, 302) or resp.url != url:
|
||||||
|
redirected.append((title, url, resp.url))
|
||||||
|
print(f" ↪️ Redirect: {title} → {resp.url}")
|
||||||
|
ok_count += 1
|
||||||
|
elif resp.status_code < 400:
|
||||||
|
ok_count += 1
|
||||||
|
else:
|
||||||
|
broken.append((title, url))
|
||||||
|
print(f" ⚠️ {resp.status_code}: {title}")
|
||||||
|
except requests.RequestException as e:
|
||||||
|
broken.append((title, url))
|
||||||
|
print(f" ⚠️ Error: {title} — {e}")
|
||||||
|
# Rate limit: 200ms between requests
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"Results: {ok_count} OK, {len(broken)} broken, {len(redirected)} redirected")
|
||||||
|
|
||||||
|
if broken:
|
||||||
|
print(f"\n❌ Broken links ({len(broken)}):")
|
||||||
|
for title, url in broken:
|
||||||
|
print(f" - {title}")
|
||||||
|
print(f" {url}")
|
||||||
|
|
||||||
|
if redirected:
|
||||||
|
print(f"\n↪️ Redirected ({len(redirected)}):")
|
||||||
|
for title, old, new in redirected:
|
||||||
|
print(f" - {title}")
|
||||||
|
print(f" OLD: {old}")
|
||||||
|
print(f" NEW: {new}")
|
||||||
|
|
||||||
|
return len(broken) == 0
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Refresh Architecture Center catalog")
|
parser = argparse.ArgumentParser(description="Refresh Architecture Center catalog")
|
||||||
parser.add_argument("--url", help="Fetch a single URL and print YAML entry")
|
parser.add_argument("--url", help="Fetch a single URL and print YAML entry")
|
||||||
parser.add_argument("--whats-new", action="store_true", help="Crawl What's New pages for new entries")
|
parser.add_argument("--whats-new", action="store_true", help="Crawl What's New pages for new entries")
|
||||||
parser.add_argument("--validate", action="store_true", help="Validate existing catalog")
|
parser.add_argument("--validate", action="store_true", help="Validate existing catalog")
|
||||||
|
parser.add_argument("--check-links", action="store_true", help="Check all URLs for 404s and redirects")
|
||||||
parser.add_argument("--output", default=CATALOG_PATH, help="Output file path")
|
parser.add_argument("--output", default=CATALOG_PATH, help="Output file path")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.check_links:
|
||||||
|
ok = check_links()
|
||||||
|
sys.exit(0 if ok else 1)
|
||||||
|
|
||||||
if args.validate:
|
if args.validate:
|
||||||
ok = validate_catalog()
|
ok = validate_catalog()
|
||||||
sys.exit(0 if ok else 1)
|
sys.exit(0 if ok else 1)
|
||||||
|
|||||||
@@ -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,104 @@ 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)
|
||||||
|
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 +676,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 +697,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