Created
January 26, 2026 21:46
-
-
Save rexwhitten/634ca7b64e5ab3ab86f92495b67618fa to your computer and use it in GitHub Desktop.
Wf
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>M&A Cloud Posture Assessment - Onboarding</title> | |
| <style> | |
| * { | |
| margin: 0; | |
| padding: 0; | |
| box-sizing: border-box; | |
| } | |
| body { | |
| font-family: | |
| -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, | |
| Cantarell, sans-serif; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| min-height: 100vh; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| padding: 20px; | |
| } | |
| .container { | |
| background: white; | |
| border-radius: 16px; | |
| box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); | |
| max-width: 1100px; | |
| width: 100%; | |
| overflow: hidden; | |
| min-height: 500px; | |
| display: flex; | |
| flex-direction: column; | |
| transition: max-width 0.3s; | |
| } | |
| .header { | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| padding: 30px; | |
| text-align: center; | |
| position: relative; | |
| } | |
| .header h1 { | |
| font-size: 28px; | |
| margin-bottom: 10px; | |
| } | |
| .header p { | |
| opacity: 0.9; | |
| font-size: 14px; | |
| } | |
| .view-container { | |
| padding: 30px; | |
| flex: 1; | |
| display: none; | |
| } | |
| .view-container.active { | |
| display: block; | |
| animation: fadeIn 0.4s; | |
| } | |
| @keyframes fadeIn { | |
| from { | |
| opacity: 0; | |
| transform: translateY(10px); | |
| } | |
| to { | |
| opacity: 1; | |
| transform: translateY(0); | |
| } | |
| } | |
| /* Form Styles */ | |
| .form-group { | |
| margin-bottom: 20px; | |
| } | |
| label { | |
| display: block; | |
| margin-bottom: 8px; | |
| color: #2d3748; | |
| font-weight: 500; | |
| font-size: 14px; | |
| } | |
| input[type="text"], | |
| input[type="email"], | |
| select { | |
| width: 100%; | |
| padding: 12px 15px; | |
| border: 2px solid #e2e8f0; | |
| border-radius: 8px; | |
| font-size: 14px; | |
| transition: all 0.3s; | |
| } | |
| input:focus, | |
| select:focus { | |
| outline: none; | |
| border-color: #667eea; | |
| box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); | |
| } | |
| /* Grid Layouts */ | |
| .form-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); | |
| gap: 20px; | |
| } | |
| .full-width { | |
| grid-column: 1 / -1; | |
| } | |
| @media (max-width: 600px) { | |
| .form-grid { | |
| grid-template-columns: 1fr; | |
| } | |
| .view-container { | |
| padding: 20px; | |
| } | |
| .header { | |
| padding: 24px; | |
| } | |
| } | |
| /* Buttons */ | |
| button { | |
| padding: 12px 30px; | |
| border: none; | |
| border-radius: 8px; | |
| font-size: 14px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.3s; | |
| } | |
| .btn-primary { | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| } | |
| .btn-primary:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4); | |
| } | |
| .btn-secondary { | |
| background: #e2e8f0; | |
| color: #4a5568; | |
| } | |
| .btn-secondary:hover { | |
| background: #cbd5e0; | |
| } | |
| .btn-block { | |
| width: 100%; | |
| } | |
| .btn-sm { | |
| padding: 8px 12px; | |
| font-size: 12px; | |
| } | |
| .error { | |
| color: #e53e3e; | |
| font-size: 12px; | |
| margin-top: 5px; | |
| display: none; | |
| } | |
| /* Spinner */ | |
| .spinner { | |
| border: 4px solid rgba(0, 0, 0, 0.1); | |
| width: 36px; | |
| height: 36px; | |
| border-radius: 50%; | |
| border-left-color: #667eea; | |
| animation: spin 1s linear infinite; | |
| margin: 50px auto; | |
| } | |
| @keyframes spin { | |
| 0% { | |
| transform: rotate(0deg); | |
| } | |
| 100% { | |
| transform: rotate(360deg); | |
| } | |
| } | |
| /* Org List Grid */ | |
| .org-list { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); | |
| gap: 20px; | |
| margin-top: 20px; | |
| } | |
| .org-item { | |
| padding: 20px; | |
| border: 1px solid #e2e8f0; | |
| border-radius: 12px; | |
| background: #fff; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: space-between; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| height: 100%; | |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.02); | |
| } | |
| .org-item:hover { | |
| border-color: #667eea; | |
| background-color: #f8fafc; | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); | |
| } | |
| .org-name { | |
| font-weight: bold; | |
| color: #2d3748; | |
| font-size: 16px; | |
| margin-bottom: 5px; | |
| } | |
| .org-industry { | |
| font-size: 13px; | |
| color: #718096; | |
| margin-bottom: 15px; | |
| background: #edf2f7; | |
| display: inline-block; | |
| padding: 2px 8px; | |
| border-radius: 12px; | |
| } | |
| .org-meta { | |
| font-size: 12px; | |
| color: #a0aec0; | |
| border-top: 1px solid #edf2f7; | |
| padding-top: 10px; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .org-id-badge { | |
| font-family: monospace; | |
| font-size: 10px; | |
| background: #eee; | |
| padding: 2px 5px; | |
| border-radius: 3px; | |
| color: #555; | |
| } | |
| /* Floating Back Button in Header */ | |
| .back-btn { | |
| position: absolute; | |
| left: 20px; | |
| top: 20px; | |
| background: rgba(255, 255, 255, 0.2); | |
| color: white; | |
| border: none; | |
| padding: 5px 15px; | |
| border-radius: 20px; | |
| cursor: pointer; | |
| font-size: 12px; | |
| display: none; | |
| } | |
| .back-btn:hover { | |
| background: rgba(255, 255, 255, 0.3); | |
| } | |
| /* Account Item List */ | |
| .item-list { | |
| margin-top: 20px; | |
| border: 1px solid #e2e8f0; | |
| border-radius: 8px; | |
| overflow: hidden; | |
| background: #fff; | |
| } | |
| .item-row { | |
| padding: 15px 20px; | |
| border-bottom: 1px solid #e2e8f0; | |
| display: grid; | |
| grid-template-columns: 60px 1fr minmax(180px, 220px); | |
| align-items: start; | |
| gap: 15px; | |
| } | |
| @media (max-width: 700px) { | |
| .item-row { | |
| grid-template-columns: 1fr; | |
| align-items: start; | |
| } | |
| .account-actions { | |
| flex-direction: row; | |
| flex-wrap: wrap; | |
| } | |
| .account-actions button { | |
| flex: 1 1 auto; | |
| } | |
| } | |
| .item-row:last-child { | |
| border-bottom: none; | |
| } | |
| .provider-badge { | |
| font-weight: bold; | |
| text-align: center; | |
| padding: 4px; | |
| border-radius: 4px; | |
| font-size: 12px; | |
| color: white; | |
| } | |
| .badge-AWS { | |
| background-color: #ff9900; | |
| } | |
| .badge-Azure { | |
| background-color: #0078d4; | |
| } | |
| .badge-GCP { | |
| background-color: #4285f4; | |
| } | |
| .account-actions { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| justify-content: center; | |
| } | |
| .workflow-status { | |
| margin-top: 8px; | |
| font-size: 12px; | |
| color: #4a5568; | |
| } | |
| .workflow-pill { | |
| display: inline-flex; | |
| align-items: center; | |
| font-size: 11px; | |
| font-weight: 600; | |
| padding: 2px 8px; | |
| border-radius: 999px; | |
| margin-right: 8px; | |
| } | |
| .workflow-pill.idle { | |
| background: #edf2f7; | |
| color: #4a5568; | |
| } | |
| .workflow-pill.running { | |
| background: #ebf8ff; | |
| color: #2b6cb0; | |
| } | |
| .workflow-pill.success { | |
| background: #c6f6d5; | |
| color: #22543d; | |
| } | |
| .workflow-pill.error { | |
| background: #fed7d7; | |
| color: #9b2c2c; | |
| } | |
| .workflow-results { | |
| margin-top: 6px; | |
| padding-left: 18px; | |
| color: #2d3748; | |
| } | |
| .workflow-results li { | |
| margin-bottom: 4px; | |
| word-break: break-all; | |
| font-size: 12px; | |
| } | |
| /* Add Account Grid */ | |
| .add-account-box { | |
| background: #f8fafc; | |
| border: 1px solid #e2e8f0; | |
| border-radius: 12px; | |
| padding: 20px; | |
| margin-bottom: 25px; | |
| } | |
| .account-form-grid { | |
| display: grid; | |
| grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); | |
| gap: 15px; | |
| align-items: end; | |
| } | |
| .account-form-grid button { | |
| justify-self: start; | |
| } | |
| @media (max-width: 768px) { | |
| .account-form-grid { | |
| grid-template-columns: 1fr; | |
| } | |
| .account-form-grid button { | |
| width: 100%; | |
| } | |
| } | |
| .landing-header, | |
| .accounts-header { | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| gap: 16px; | |
| flex-wrap: wrap; | |
| } | |
| .landing-header button { | |
| white-space: nowrap; | |
| } | |
| .accounts-header > div { | |
| min-width: 220px; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <div class="header"> | |
| <button id="backBtn" class="back-btn" onclick="showLanding()"> | |
| ← Back | |
| </button> | |
| <h1>M&A Cloud Posture</h1> | |
| <p id="headerSubtitle"> | |
| Setup your organization for cloud security assessment | |
| </p> | |
| </div> | |
| <!-- VIEW: Landing Page --> | |
| <div id="view-landing" class="view-container"> | |
| <div id="landingSpinner" class="spinner"></div> | |
| <div id="landingContent" style="display: none"> | |
| <div class="landing-header" style="margin-bottom: 20px"> | |
| <div> | |
| <h2 style="font-size: 20px; color: #2d3748">Organizations</h2> | |
| <p style="font-size: 13px; color: #718096; margin-top: 4px"> | |
| Select an organization to manage | |
| </p> | |
| </div> | |
| <button class="btn-primary" onclick="showNewOrgForm()"> | |
| + New Org | |
| </button> | |
| </div> | |
| <div id="orgList" class="org-list"> | |
| <!-- Orgs will be populated here --> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- VIEW: New Organization Form --> | |
| <div id="view-new-org" class="view-container"> | |
| <div id="onboardingForm"> | |
| <div style="text-align: center; margin-bottom: 30px"> | |
| <h2 style="font-size: 24px; color: #2d3748">New Organization</h2> | |
| <p style="color: #718096"> | |
| Enter the details of the company being acquired | |
| </p> | |
| </div> | |
| <div class="form-grid"> | |
| <div class="form-group full-width"> | |
| <label for="companyName">Company Name *</label> | |
| <input | |
| type="text" | |
| id="companyName" | |
| required | |
| placeholder="Acme Corp" | |
| /> | |
| <div class="error" id="companyNameError"> | |
| Company name is required | |
| </div> | |
| </div> | |
| <div class="form-group full-width"> | |
| <label for="maName">M&A Target Name (Acquisition ID) *</label> | |
| <input | |
| type="text" | |
| id="maName" | |
| required | |
| placeholder="e.g. Acme, ProjectFalcon" | |
| /> | |
| <div class="error" id="maNameError"> | |
| M&A Target Name is required (used for unique identifier) | |
| </div> | |
| </div> | |
| <div class="form-group full-width"> | |
| <label for="industry">Industry</label> | |
| <input | |
| type="text" | |
| id="industry" | |
| placeholder="e.g., Technology, Healthcare, Finance" | |
| /> | |
| </div> | |
| <div class="form-group"> | |
| <label for="cisoName">CISO Name *</label> | |
| <input | |
| type="text" | |
| id="cisoName" | |
| required | |
| placeholder="John Doe" | |
| /> | |
| <div class="error" id="cisoNameError">CISO name is required</div> | |
| </div> | |
| <div class="form-group"> | |
| <label for="cisoEmail">CISO Email *</label> | |
| <input | |
| type="email" | |
| id="cisoEmail" | |
| required | |
| placeholder="[email protected]" | |
| /> | |
| <div class="error" id="cisoEmailError"> | |
| Valid CISO email is required | |
| </div> | |
| </div> | |
| <div class="form-group"> | |
| <label for="remediationName">Remediation Contact Name</label> | |
| <input | |
| type="text" | |
| id="remediationName" | |
| placeholder="Jane Smith" | |
| /> | |
| </div> | |
| <div class="form-group"> | |
| <label for="remediationEmail">Remediation Contact Email</label> | |
| <input | |
| type="email" | |
| id="remediationEmail" | |
| placeholder="[email protected]" | |
| /> | |
| </div> | |
| <div class="form-group full-width"> | |
| <label | |
| style=" | |
| display: flex; | |
| align-items: center; | |
| cursor: pointer; | |
| background: #f7fafc; | |
| padding: 15px; | |
| border-radius: 8px; | |
| " | |
| > | |
| <input | |
| type="checkbox" | |
| id="scoringEnabled" | |
| checked | |
| style="width: auto; margin-right: 15px" | |
| /> | |
| <div> | |
| <span style="display: block; font-weight: 600" | |
| >Enable Automated Scoring</span | |
| > | |
| <span | |
| style="font-size: 12px; color: #718096; font-weight: normal" | |
| >Automatically run posture assessments on connected | |
| accounts.</span | |
| > | |
| </div> | |
| </label> | |
| </div> | |
| </div> | |
| <div | |
| style="margin-top: 30px; display: flex; justify-content: flex-end" | |
| > | |
| <button | |
| class="btn-secondary" | |
| style="margin-right: 15px" | |
| onclick="showLanding()" | |
| > | |
| Cancel | |
| </button> | |
| <button | |
| class="btn-primary" | |
| style="min-width: 150px" | |
| onclick="submitOrgForm()" | |
| > | |
| Create Organization | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- VIEW: Success / Next Step --> | |
| <div | |
| id="view-success" | |
| class="view-container" | |
| style="text-align: center; padding-top: 50px" | |
| > | |
| <div | |
| style=" | |
| width: 80px; | |
| height: 80px; | |
| background: #28a745; | |
| border-radius: 50%; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| margin: 0 auto 20px; | |
| color: white; | |
| font-size: 40px; | |
| box-shadow: 0 10px 20px rgba(40, 167, 69, 0.3); | |
| " | |
| > | |
| ✓ | |
| </div> | |
| <h2 style="font-size: 28px; color: #2d3748; margin-bottom: 15px"> | |
| Organization Created! | |
| </h2> | |
| <p style="color: #718096; margin-bottom: 30px"> | |
| Organization <strong id="successOrgName"></strong> has been | |
| successfully onboarded. | |
| </p> | |
| <button class="btn-primary" id="btnGoToAccounts"> | |
| Connect Cloud Accounts → | |
| </button> | |
| </div> | |
| <!-- VIEW: Cloud Accounts --> | |
| <div id="view-accounts" class="view-container"> | |
| <div class="accounts-header" style="margin-bottom: 25px"> | |
| <div> | |
| <h2 style="font-size: 24px; color: #2d3748">Cloud Accounts</h2> | |
| <p style="color: #718096; font-size: 14px"> | |
| Manage connected environments | |
| </p> | |
| </div> | |
| <div | |
| style=" | |
| text-align: right; | |
| background: #f0fff4; | |
| border: 1px solid #c6f6d5; | |
| padding: 8px 15px; | |
| border-radius: 8px; | |
| " | |
| > | |
| <strong style="color: #276749" id="currentOrgName" | |
| >Loading...</strong | |
| > | |
| <div style="font-size: 11px; color: #718096"> | |
| ID: <span id="currentOrgId"></span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="add-account-box"> | |
| <h3 style="font-size: 16px; margin-bottom: 15px; color: #4a5568"> | |
| Add New Account | |
| </h3> | |
| <div class="account-form-grid"> | |
| <div class="form-group" style="margin-bottom: 0"> | |
| <label for="accProvider">Provider</label> | |
| <select id="accProvider"> | |
| <option value="AWS">AWS</option> | |
| <option value="Azure">Azure</option> | |
| <option value="GCP">GCP</option> | |
| </select> | |
| </div> | |
| <div class="form-group" style="margin-bottom: 0"> | |
| <label for="accId">Account / Sub ID</label> | |
| <input type="text" id="accId" placeholder="123456789012" /> | |
| </div> | |
| <div class="form-group" style="margin-bottom: 0"> | |
| <label for="accName">Account Name</label> | |
| <input type="text" id="accName" placeholder="Production Env" /> | |
| </div> | |
| <button class="btn-primary" onclick="addAccount()">+ Add</button> | |
| </div> | |
| <div id="accError" class="error" style="margin-top: 10px"></div> | |
| </div> | |
| <h3 | |
| style=" | |
| font-size: 16px; | |
| margin-top: 30px; | |
| margin-bottom: 15px; | |
| color: #4a5568; | |
| " | |
| > | |
| Linked Accounts | |
| </h3> | |
| <div id="accountsList" class="item-list"> | |
| <!-- Accounts loaded here --> | |
| </div> | |
| </div> | |
| </div> | |
| <script src="config.js"></script> | |
| <script> | |
| const API_ENDPOINT = window.config?.onboardingUrl || "/onboarding"; | |
| const PAAS_API_ENDPOINT = window.config?.paasUrl || "/inventory_crud"; | |
| const WORKFLOW_API_ENDPOINT = ( | |
| window.config?.workflowUrl || "/api/workflow" | |
| ).replace(/\/$/, ""); | |
| const WORKFLOW_POLL_INTERVAL = 1000; | |
| // State | |
| let allOrgs = []; | |
| let currentOrg = null; | |
| let accountsCache = []; | |
| const workflowRuns = {}; | |
| // Initialization | |
| document.addEventListener("DOMContentLoaded", () => { | |
| showLanding(); | |
| }); | |
| // Navigation | |
| function showView(viewId) { | |
| document | |
| .querySelectorAll(".view-container") | |
| .forEach((el) => el.classList.remove("active")); | |
| document.getElementById(viewId).classList.add("active"); | |
| const backBtn = document.getElementById("backBtn"); | |
| if (viewId === "view-landing") { | |
| backBtn.style.display = "none"; | |
| } else { | |
| backBtn.style.display = "block"; | |
| } | |
| } | |
| function showLanding() { | |
| showView("view-landing"); | |
| loadOrganizations(); | |
| } | |
| function showNewOrgForm() { | |
| // Reset form | |
| document.querySelectorAll("input").forEach((i) => (i.value = "")); | |
| document.getElementById("scoringEnabled").checked = true; | |
| document | |
| .querySelectorAll(".error") | |
| .forEach((e) => (e.style.display = "none")); | |
| showView("view-new-org"); | |
| } | |
| function showSuccess(org) { | |
| document.getElementById("successOrgName").textContent = | |
| org.organization_name || "Unknown"; | |
| document.getElementById("btnGoToAccounts").onclick = () => | |
| showAccounts(org); | |
| showView("view-success"); | |
| } | |
| function showAccounts(org) { | |
| currentOrg = org; | |
| document.getElementById("currentOrgName").textContent = | |
| org.organization_name || "Unknown"; | |
| document.getElementById("currentOrgId").textContent = org.ma_name; | |
| showView("view-accounts"); | |
| loadAccounts(); | |
| // Pre-cleanup error | |
| document.getElementById("accError").style.display = "none"; | |
| } | |
| function sanitizeId(value) { | |
| return (value || "").replace(/[^a-zA-Z0-9_-]/g, "_"); | |
| } | |
| function getWorkflowKey(maName, accountId) { | |
| return `${maName || "unknown"}::${accountId || "unknown"}`; | |
| } | |
| function getWorkflowStatusElementId(maName, accountId) { | |
| return `workflow-status-${sanitizeId(maName)}-${sanitizeId(accountId)}`; | |
| } | |
| function renderWorkflowStatus(maName, accountId) { | |
| const key = getWorkflowKey(maName, accountId); | |
| const state = workflowRuns[key]; | |
| if (!state) { | |
| return '<span class="workflow-pill idle">Idle</span>Ready to run workflow.'; | |
| } | |
| if (state.status === "RUNNING") { | |
| return '<span class="workflow-pill running">Running…</span>Gathering posture results.'; | |
| } | |
| if (state.status === "COMPLETED") { | |
| const results = state.results || []; | |
| const listItems = results | |
| .map((keyName) => `<li>${keyName}</li>`) | |
| .join(""); | |
| const listMarkup = results.length | |
| ? `<ul class="workflow-results">${listItems}</ul>` | |
| : ""; | |
| return ` | |
| <div> | |
| <span class="workflow-pill success">Completed</span> | |
| Results available below. | |
| </div> | |
| ${listMarkup} | |
| `; | |
| } | |
| if (state.status === "ERROR") { | |
| return `<span class="workflow-pill error">Error</span>${state.message || "Workflow failed"}`; | |
| } | |
| return '<span class="workflow-pill idle">Idle</span>Ready to run workflow.'; | |
| } | |
| function updateWorkflowStatusUI(maName, accountId) { | |
| const el = document.getElementById( | |
| getWorkflowStatusElementId(maName, accountId), | |
| ); | |
| if (el) { | |
| el.innerHTML = renderWorkflowStatus(maName, accountId); | |
| } | |
| } | |
| function getAccountDetails(maName, accountId) { | |
| return accountsCache.find( | |
| (acc) => acc.ma_name === maName && acc.account_id === accountId, | |
| ); | |
| } | |
| async function startWorkflow(maName, accountId, triggerBtn) { | |
| if (!currentOrg) return; | |
| const account = getAccountDetails(maName, accountId); | |
| if (!account) { | |
| alert("Unable to locate account details"); | |
| return; | |
| } | |
| const btn = triggerBtn; | |
| const originalLabel = btn?.textContent; | |
| if (btn) { | |
| btn.disabled = true; | |
| btn.textContent = "Starting..."; | |
| } | |
| const payload = { | |
| org: { | |
| id: currentOrg.ma_name, | |
| name: currentOrg.organization_name, | |
| }, | |
| account: { | |
| id: account.account_id, | |
| provider: account.provider, | |
| alias: account.account_alias, | |
| }, | |
| }; | |
| try { | |
| const res = await fetch(WORKFLOW_API_ENDPOINT, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(payload), | |
| }); | |
| const data = await res.json(); | |
| if (!res.ok) { | |
| throw new Error(data.error || "Workflow failed to start"); | |
| } | |
| const key = getWorkflowKey(maName, accountId); | |
| workflowRuns[key] = { | |
| status: data.status || "RUNNING", | |
| runId: data.runId, | |
| results: [], | |
| }; | |
| updateWorkflowStatusUI(maName, accountId); | |
| beginWorkflowPolling(maName, accountId, data.runId); | |
| } catch (err) { | |
| const key = getWorkflowKey(maName, accountId); | |
| workflowRuns[key] = { | |
| status: "ERROR", | |
| message: err.message, | |
| }; | |
| updateWorkflowStatusUI(maName, accountId); | |
| } finally { | |
| if (btn) { | |
| btn.disabled = false; | |
| btn.textContent = originalLabel; | |
| } | |
| } | |
| } | |
| function beginWorkflowPolling(maName, accountId, runId) { | |
| const key = getWorkflowKey(maName, accountId); | |
| stopWorkflowPolling(maName, accountId); | |
| let isPolling = false; | |
| const poll = async () => { | |
| if (isPolling) return; | |
| isPolling = true; | |
| try { | |
| const res = await fetch( | |
| `${WORKFLOW_API_ENDPOINT}/${encodeURIComponent(runId)}`, | |
| ); | |
| const data = await res.json(); | |
| if (!res.ok) { | |
| throw new Error(data.error || "Polling failed"); | |
| } | |
| workflowRuns[key] = { | |
| ...(workflowRuns[key] || {}), | |
| status: data.status, | |
| runId: data.runId, | |
| results: data.results || [], | |
| }; | |
| updateWorkflowStatusUI(maName, accountId); | |
| if (data.status === "COMPLETED") { | |
| stopWorkflowPolling(maName, accountId); | |
| } | |
| } catch (err) { | |
| workflowRuns[key] = { | |
| status: "ERROR", | |
| message: err.message, | |
| }; | |
| updateWorkflowStatusUI(maName, accountId); | |
| stopWorkflowPolling(maName, accountId); | |
| } | |
| isPolling = false; | |
| }; | |
| poll(); | |
| const intervalId = setInterval(poll, WORKFLOW_POLL_INTERVAL); | |
| workflowRuns[key] = { | |
| ...(workflowRuns[key] || {}), | |
| poller: intervalId, | |
| }; | |
| } | |
| function stopWorkflowPolling(maName, accountId) { | |
| const key = getWorkflowKey(maName, accountId); | |
| const poller = workflowRuns[key]?.poller; | |
| if (poller) { | |
| clearInterval(poller); | |
| delete workflowRuns[key].poller; | |
| } | |
| } | |
| // Logic - Landing | |
| async function loadOrganizations() { | |
| const spinner = document.getElementById("landingSpinner"); | |
| const content = document.getElementById("landingContent"); | |
| const list = document.getElementById("orgList"); | |
| spinner.style.display = "block"; | |
| content.style.display = "none"; | |
| list.innerHTML = ""; | |
| try { | |
| const res = await fetch(API_ENDPOINT); | |
| const json = await res.json(); | |
| if (res.status === 200 && json.data) { | |
| allOrgs = json.data; | |
| if (allOrgs.length === 0) { | |
| list.innerHTML = | |
| '<div style="grid-column: 1/-1; text-align: center; color: #718096; padding: 40px; background: #f8fafc; border-radius: 8px;">No organizations found. Click "+ New Org" to start.</div>'; | |
| } else { | |
| allOrgs.sort( | |
| (a, b) => | |
| new Date(b.created_at || 0) - new Date(a.created_at || 0), | |
| ); // Sort newest first | |
| allOrgs.forEach((org) => { | |
| const item = document.createElement("div"); | |
| item.className = "org-item"; | |
| item.onclick = () => showAccounts(org); // Going to detail/accounts view | |
| const name = org.organization_name || "Unnamed Organization"; | |
| const ma = org.ma_name || "Unnamed Target"; | |
| const industry = org.company_info?.industry || "Unspecified"; | |
| const created = org.created_at | |
| ? new Date(org.created_at).toLocaleDateString() | |
| : "Unknown"; | |
| const id = org.pk ? org.pk.replace("ORG#", "") : ""; | |
| item.innerHTML = ` | |
| <div> | |
| <div class="org-name">${name}</div> | |
| <div style="font-size: 13px; color: #667eea; font-weight: 600;">${ma}</div> | |
| <span class="org-industry">${industry}</span> | |
| </div> | |
| <div class="org-meta"> | |
| <span class="org-id-badge">${id}</span> | |
| <span>${created}</span> | |
| </div> | |
| `; | |
| list.appendChild(item); | |
| }); | |
| } | |
| } else { | |
| list.innerHTML = | |
| '<div style="color: red; text-align: center;">Failed to load organizations.</div>'; | |
| } | |
| } catch (e) { | |
| console.error(e); | |
| list.innerHTML = | |
| '<div style="color: red; text-align: center;">Network error.</div>'; | |
| } finally { | |
| spinner.style.display = "none"; | |
| content.style.display = "block"; | |
| } | |
| } | |
| // Logic - New Org | |
| function validateField(fieldId) { | |
| const field = document.getElementById(fieldId); | |
| const error = document.getElementById(fieldId + "Error"); | |
| if (!field.value.trim()) { | |
| error.style.display = "block"; | |
| field.style.borderColor = "#e53e3e"; | |
| return false; | |
| } | |
| error.style.display = "none"; | |
| field.style.borderColor = "#e2e8f0"; | |
| return true; | |
| } | |
| function validateEmail(fieldId) { | |
| const field = document.getElementById(fieldId); | |
| const error = document.getElementById(fieldId + "Error"); | |
| const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; | |
| if (!field.value.trim() || !emailRegex.test(field.value)) { | |
| error.style.display = "block"; | |
| field.style.borderColor = "#e53e3e"; | |
| return false; | |
| } | |
| error.style.display = "none"; | |
| field.style.borderColor = "#e2e8f0"; | |
| return true; | |
| } | |
| async function submitOrgForm() { | |
| if ( | |
| !validateField("companyName") || | |
| !validateField("maName") || | |
| !validateField("cisoName") || | |
| !validateEmail("cisoEmail") | |
| ) | |
| return; | |
| const payload = { | |
| organization_name: document.getElementById("companyName").value, | |
| ma_name: document.getElementById("maName").value, | |
| industry: document.getElementById("industry").value || "", | |
| contact_name: document.getElementById("cisoName").value, | |
| contact_email: document.getElementById("cisoEmail").value, | |
| scoring_enabled: document.getElementById("scoringEnabled").checked, | |
| }; | |
| const rName = document.getElementById("remediationName").value; | |
| const rEmail = document.getElementById("remediationEmail").value; | |
| if (rName.trim() || rEmail.trim()) { | |
| payload.remediation_contact = { name: rName, email: rEmail }; | |
| } | |
| const btn = event.target; | |
| const originalText = btn.textContent; | |
| btn.disabled = true; | |
| btn.textContent = "Creating..."; | |
| try { | |
| const res = await fetch(API_ENDPOINT, { | |
| method: "POST", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify(payload), | |
| }); | |
| const result = await res.json(); | |
| if ((res.status === 200 || res.status === 201) && result.data) { | |
| // Success | |
| showSuccess(result.data); // data contains the item including PK | |
| } else { | |
| alert("Error: " + (result.error || "Unknown error")); | |
| } | |
| } catch (e) { | |
| console.error(e); | |
| alert("Network error occurred."); | |
| } finally { | |
| btn.disabled = false; | |
| btn.textContent = originalText; | |
| } | |
| } | |
| // Logic - Accounts | |
| async function loadAccounts() { | |
| if (!currentOrg) return; | |
| const maName = currentOrg.ma_name; | |
| const list = document.getElementById("accountsList"); | |
| list.innerHTML = | |
| '<div style="padding:15px; text-align:center;">Loading...</div>'; | |
| accountsCache = []; | |
| try { | |
| const res = await fetch( | |
| `${PAAS_API_ENDPOINT}?ma_name=${encodeURIComponent(maName)}`, | |
| ); | |
| const json = await res.json(); | |
| const items = json.data; | |
| accountsCache = Array.isArray(items) ? items : []; | |
| list.innerHTML = ""; | |
| if (res.status === 200 && Array.isArray(items)) { | |
| if (items.length === 0) { | |
| list.innerHTML = | |
| '<div style="padding:15px; text-align:center; color:#718096;">No accounts connected.</div>'; | |
| } else { | |
| items.forEach((item) => { | |
| const div = document.createElement("div"); | |
| div.className = "item-row"; | |
| const statusId = getWorkflowStatusElementId( | |
| item.ma_name, | |
| item.account_id, | |
| ); | |
| div.innerHTML = ` | |
| <div class="provider-badge badge-${item.provider}">${item.provider}</div> | |
| <div> | |
| <strong style="color: #2d3748;">${item.account_id}</strong> | |
| <div style="font-size:12px; color:#718096;">${item.account_alias || "No alias"}</div> | |
| <div class="workflow-status" id="${statusId}">${renderWorkflowStatus(item.ma_name, item.account_id)}</div> | |
| </div> | |
| <div class="account-actions"> | |
| <button class="btn-sm btn-primary" onclick="startWorkflow('${item.ma_name}', '${item.account_id}', this)">Run Workflow</button> | |
| <button class="btn-sm btn-secondary" style="border: 1px solid #e2e8f0; color: #e53e3e;" onclick="deleteAccount('${item.ma_name}', '${item.account_id}')">Remove</button> | |
| </div> | |
| `; | |
| list.appendChild(div); | |
| }); | |
| } | |
| } else { | |
| list.innerHTML = | |
| '<div style="color:red; text-align:center; padding:10px;">Failed to load accounts.</div>'; | |
| } | |
| } catch (e) { | |
| console.error(e); | |
| list.innerHTML = | |
| '<div style="color:red; text-align:center; padding:10px;">Error loading accounts.</div>'; | |
| } | |
| } | |
| async function addAccount() { | |
| if (!currentOrg) return; | |
| const maName = currentOrg.ma_name; | |
| const provider = document.getElementById("accProvider").value; | |
| const accId = document.getElementById("accId").value.trim(); | |
| const accAlias = document.getElementById("accName").value.trim(); | |
| const errorDiv = document.getElementById("accError"); | |
| if (!accId) { | |
| errorDiv.style.display = "block"; | |
| errorDiv.textContent = "Account ID is required."; | |
| return; | |
| } | |
| errorDiv.style.display = "none"; | |
| const payload = { | |
| ma_name: maName, | |
| provider: provider, | |
| account_id: accId, | |
| account_alias: accAlias, | |
| }; | |
| const btn = event.target; | |
| btn.disabled = true; | |
| try { | |
| const res = await fetch(PAAS_API_ENDPOINT, { | |
| method: "POST", | |
| body: JSON.stringify(payload), | |
| }); | |
| if (res.status === 201 || res.status === 200) { | |
| document.getElementById("accId").value = ""; | |
| document.getElementById("accName").value = ""; | |
| loadAccounts(); | |
| } else { | |
| const data = await res.json(); | |
| errorDiv.style.display = "block"; | |
| errorDiv.textContent = data.error || "Failed to add account"; | |
| } | |
| } catch (e) { | |
| console.error(e); | |
| errorDiv.style.display = "block"; | |
| errorDiv.textContent = "Error adding account"; | |
| } finally { | |
| btn.disabled = false; | |
| } | |
| } | |
| async function deleteAccount(maName, accountId) { | |
| if (!confirm("Remove this account?")) return; | |
| const workflowKeyValue = getWorkflowKey(maName, accountId); | |
| try { | |
| // Using Path parameters or Query? | |
| // Backend supports DELETE via path variables or ma_name/account_id query/body? | |
| // Let's use path: /inventory/{ma}/{acc} | |
| // Or query if strictly following crud.py structure: expects ma_name and account_id from query/body | |
| // Frontend logic previously used query `?id=...`. | |
| // Updated backend `delete_item` checks ma_name/account_id. | |
| // Let's use REST path if supported, or params. | |
| // Backend inventory_crud logic splits path. | |
| const res = await fetch( | |
| `${PAAS_API_ENDPOINT}/${encodeURIComponent(maName)}/${encodeURIComponent(accountId)}`, | |
| { | |
| method: "DELETE", | |
| }, | |
| ); | |
| if (res.status === 200) { | |
| stopWorkflowPolling(maName, accountId); | |
| delete workflowRuns[workflowKeyValue]; | |
| loadAccounts(); | |
| } else { | |
| alert("Failed to delete"); | |
| } | |
| } catch (e) { | |
| console.error(e); | |
| alert("Error deleting"); | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import pytest | |
| from unittest.mock import MagicMock, patch | |
| import json | |
| import os | |
| import sys | |
| # Helper to ensure we start with a fresh module for top-level code execution | |
| def clean_imports(): | |
| if "lambdas.workflow" in sys.modules: | |
| del sys.modules["lambdas.workflow"] | |
| class TestStartWorkflow: | |
| """Tests for start_workflow function""" | |
| @patch.dict( | |
| os.environ, | |
| { | |
| "WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow", | |
| "CMAPP_SCORING_TABLE": "test-scoring", | |
| }, | |
| ) | |
| @patch("boto3.client") | |
| @patch("boto3.resource") | |
| def test_start_workflow_success(self, mock_boto_resource, mock_boto_client): | |
| """Test successful workflow start""" | |
| clean_imports() | |
| # Mock DynamoDB | |
| mock_dynamodb = MagicMock() | |
| mock_table = MagicMock() | |
| mock_boto_resource.return_value = mock_dynamodb | |
| mock_dynamodb.Table.return_value = mock_table | |
| # Mock Step Functions | |
| mock_stepfunctions = MagicMock() | |
| mock_boto_client.return_value = mock_stepfunctions | |
| mock_stepfunctions.start_execution.return_value = { | |
| "executionArn": "arn:aws:states:us-east-1:123456789012:execution:MyWorkflow:workflow-abc-123", | |
| "startDate": "2024-01-01T00:00:00Z", | |
| } | |
| from lambdas.workflow import start_workflow | |
| event = { | |
| "body": json.dumps( | |
| { | |
| "org": {"id": "test-org", "name": "Test Organization"}, | |
| "account": { | |
| "id": "123456789012", | |
| "provider": "AWS", | |
| "alias": "test-account", | |
| }, | |
| } | |
| ) | |
| } | |
| response = start_workflow(event) | |
| assert response["statusCode"] == 200 | |
| body = json.loads(response["body"]) | |
| assert body["status"] == "RUNNING" | |
| assert "runId" in body | |
| assert body["runId"].startswith("workflow-") | |
| mock_stepfunctions.start_execution.assert_called_once() | |
| @patch.dict( | |
| os.environ, | |
| { | |
| "CMAPP_SCORING_TABLE": "test-scoring", | |
| }, | |
| ) | |
| @patch("boto3.resource") | |
| def test_start_workflow_missing_arn(self, mock_boto_resource): | |
| """Test workflow start with missing state machine ARN""" | |
| clean_imports() | |
| mock_dynamodb = MagicMock() | |
| mock_table = MagicMock() | |
| mock_boto_resource.return_value = mock_dynamodb | |
| mock_dynamodb.Table.return_value = mock_table | |
| from lambdas.workflow import start_workflow | |
| event = { | |
| "body": json.dumps( | |
| { | |
| "org": {"id": "test-org"}, | |
| "account": {"id": "123456789012"}, | |
| } | |
| ) | |
| } | |
| response = start_workflow(event) | |
| assert response["statusCode"] == 500 | |
| body = json.loads(response["body"]) | |
| assert "State machine ARN not configured" in body["details"] | |
| @patch.dict( | |
| os.environ, | |
| { | |
| "WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow", | |
| "CMAPP_SCORING_TABLE": "test-scoring", | |
| }, | |
| ) | |
| @patch("boto3.resource") | |
| def test_start_workflow_invalid_json(self, mock_boto_resource): | |
| """Test workflow start with invalid JSON body""" | |
| clean_imports() | |
| mock_dynamodb = MagicMock() | |
| mock_table = MagicMock() | |
| mock_boto_resource.return_value = mock_dynamodb | |
| mock_dynamodb.Table.return_value = mock_table | |
| from lambdas.workflow import start_workflow | |
| event = {"body": "invalid json"} | |
| response = start_workflow(event) | |
| assert response["statusCode"] == 400 | |
| body = json.loads(response["body"]) | |
| assert "Invalid JSON payload" in body["error"] | |
| @patch.dict( | |
| os.environ, | |
| { | |
| "WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow", | |
| "CMAPP_SCORING_TABLE": "test-scoring", | |
| }, | |
| ) | |
| @patch("boto3.resource") | |
| def test_start_workflow_missing_org_id(self, mock_boto_resource): | |
| """Test workflow start with missing org.id""" | |
| clean_imports() | |
| mock_dynamodb = MagicMock() | |
| mock_table = MagicMock() | |
| mock_boto_resource.return_value = mock_dynamodb | |
| mock_dynamodb.Table.return_value = mock_table | |
| from lambdas.workflow import start_workflow | |
| event = { | |
| "body": json.dumps( | |
| { | |
| "org": {}, | |
| "account": {"id": "123456789012"}, | |
| } | |
| ) | |
| } | |
| response = start_workflow(event) | |
| assert response["statusCode"] == 400 | |
| body = json.loads(response["body"]) | |
| assert "org.id and account.id are required" in body["error"] | |
| @patch.dict( | |
| os.environ, | |
| { | |
| "WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow", | |
| "CMAPP_SCORING_TABLE": "test-scoring", | |
| }, | |
| ) | |
| @patch("boto3.resource") | |
| def test_start_workflow_missing_account_id(self, mock_boto_resource): | |
| """Test workflow start with missing account.id""" | |
| clean_imports() | |
| mock_dynamodb = MagicMock() | |
| mock_table = MagicMock() | |
| mock_boto_resource.return_value = mock_dynamodb | |
| mock_dynamodb.Table.return_value = mock_table | |
| from lambdas.workflow import start_workflow | |
| event = { | |
| "body": json.dumps( | |
| { | |
| "org": {"id": "test-org"}, | |
| "account": {}, | |
| } | |
| ) | |
| } | |
| response = start_workflow(event) | |
| assert response["statusCode"] == 400 | |
| body = json.loads(response["body"]) | |
| assert "org.id and account.id are required" in body["error"] | |
| @patch.dict( | |
| os.environ, | |
| { | |
| "WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow", | |
| "CMAPP_SCORING_TABLE": "test-scoring", | |
| }, | |
| ) | |
| @patch("boto3.resource") | |
| def test_start_workflow_with_fallback_fields(self, mock_boto_resource): | |
| """Test workflow start using fallback field names (ma_name, account_id)""" | |
| clean_imports() | |
| mock_dynamodb = MagicMock() | |
| mock_table = MagicMock() | |
| mock_boto_resource.return_value = mock_dynamodb | |
| mock_dynamodb.Table.return_value = mock_table | |
| with patch("boto3.client") as mock_boto_client: | |
| mock_stepfunctions = MagicMock() | |
| mock_boto_client.return_value = mock_stepfunctions | |
| mock_stepfunctions.start_execution.return_value = { | |
| "executionArn": "arn:aws:states:us-east-1:123456789012:execution:MyWorkflow:workflow-abc-123", | |
| } | |
| from lambdas.workflow import start_workflow | |
| # Use ma_name and account_id instead of id | |
| event = { | |
| "body": json.dumps( | |
| { | |
| "org": {"ma_name": "test-org-alt"}, | |
| "account": {"account_id": "123456789012"}, | |
| } | |
| ) | |
| } | |
| response = start_workflow(event) | |
| assert response["statusCode"] == 200 | |
| body = json.loads(response["body"]) | |
| assert body["status"] == "RUNNING" | |
| class TestCheckRunStatus: | |
| """Tests for check_run_status function""" | |
| @patch.dict( | |
| os.environ, | |
| { | |
| "WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow", | |
| "CMAPP_SCORING_TABLE": "test-scoring", | |
| "WORKFLOW_RESULT_PREFIX": "scoring", | |
| }, | |
| ) | |
| @patch("boto3.client") | |
| @patch("boto3.resource") | |
| def test_check_run_status_running(self, mock_boto_resource, mock_boto_client): | |
| """Test checking status of running execution""" | |
| clean_imports() | |
| mock_dynamodb = MagicMock() | |
| mock_table = MagicMock() | |
| mock_boto_resource.return_value = mock_dynamodb | |
| mock_dynamodb.Table.return_value = mock_table | |
| mock_stepfunctions = MagicMock() | |
| mock_boto_client.return_value = mock_stepfunctions | |
| mock_stepfunctions.describe_execution.return_value = { | |
| "status": "RUNNING", | |
| "executionArn": "arn:aws:states:us-east-1:123456789012:execution:MyWorkflow:workflow-abc-123", | |
| } | |
| from lambdas.workflow import check_run_status | |
| response = check_run_status("workflow-abc-123") | |
| assert response["statusCode"] == 200 | |
| body = json.loads(response["body"]) | |
| assert body["status"] == "RUNNING" | |
| assert body["runId"] == "workflow-abc-123" | |
| assert "results" not in body or not body.get("results") | |
| @patch.dict( | |
| os.environ, | |
| { | |
| "WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow", | |
| "CMAPP_SCORING_TABLE": "test-scoring", | |
| "WORKFLOW_RESULT_PREFIX": "scoring", | |
| }, | |
| ) | |
| @patch("boto3.client") | |
| @patch("boto3.resource") | |
| def test_check_run_status_succeeded(self, mock_boto_resource, mock_boto_client): | |
| """Test checking status of succeeded execution""" | |
| clean_imports() | |
| mock_dynamodb = MagicMock() | |
| mock_table = MagicMock() | |
| mock_boto_resource.return_value = mock_dynamodb | |
| mock_dynamodb.Table.return_value = mock_table | |
| mock_table.query.return_value = {"Items": []} | |
| mock_stepfunctions = MagicMock() | |
| mock_boto_client.return_value = mock_stepfunctions | |
| mock_stepfunctions.describe_execution.return_value = { | |
| "status": "SUCCEEDED", | |
| "executionArn": "arn:aws:states:us-east-1:123456789012:execution:MyWorkflow:workflow-abc-123", | |
| "output": json.dumps( | |
| { | |
| "org": {"id": "test-org"}, | |
| "account": {"id": "123456789012"}, | |
| } | |
| ), | |
| } | |
| from lambdas.workflow import check_run_status | |
| response = check_run_status("workflow-abc-123") | |
| assert response["statusCode"] == 200 | |
| body = json.loads(response["body"]) | |
| assert body["status"] == "SUCCEEDED" | |
| assert "results" in body | |
| assert isinstance(body["results"], list) | |
| mock_table.query.assert_called_once() | |
| @patch.dict( | |
| os.environ, | |
| { | |
| "WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow", | |
| "CMAPP_SCORING_TABLE": "test-scoring", | |
| "WORKFLOW_RESULT_PREFIX": "scoring", | |
| }, | |
| ) | |
| @patch("boto3.client") | |
| @patch("boto3.resource") | |
| def test_check_run_status_succeeded_with_dynamodb_results( | |
| self, mock_boto_resource, mock_boto_client | |
| ): | |
| """Test checking status of succeeded execution with DynamoDB results""" | |
| clean_imports() | |
| mock_dynamodb = MagicMock() | |
| mock_table = MagicMock() | |
| mock_boto_resource.return_value = mock_dynamodb | |
| mock_dynamodb.Table.return_value = mock_table | |
| mock_table.query.return_value = { | |
| "Items": [ | |
| { | |
| "pk": "ORG#test-org", | |
| "sk": "RUN#workflow-abc-123", | |
| "s3_keys": [ | |
| "s3://bucket/scoring/test-org/123456789012/workflow-abc-123/summary.json", | |
| "s3://bucket/scoring/test-org/123456789012/workflow-abc-123/evidence.csv", | |
| ], | |
| } | |
| ] | |
| } | |
| mock_stepfunctions = MagicMock() | |
| mock_boto_client.return_value = mock_stepfunctions | |
| mock_stepfunctions.describe_execution.return_value = { | |
| "status": "SUCCEEDED", | |
| "executionArn": "arn:aws:states:us-east-1:123456789012:execution:MyWorkflow:workflow-abc-123", | |
| "output": json.dumps( | |
| { | |
| "org": {"id": "test-org"}, | |
| "account": {"id": "123456789012"}, | |
| } | |
| ), | |
| } | |
| from lambdas.workflow import check_run_status | |
| response = check_run_status("workflow-abc-123") | |
| assert response["statusCode"] == 200 | |
| body = json.loads(response["body"]) | |
| assert body["status"] == "SUCCEEDED" | |
| assert len(body["results"]) == 2 | |
| @patch.dict( | |
| os.environ, | |
| { | |
| "WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow", | |
| "CMAPP_SCORING_TABLE": "test-scoring", | |
| "WORKFLOW_RESULT_PREFIX": "scoring", | |
| }, | |
| ) | |
| @patch("boto3.client") | |
| @patch("boto3.resource") | |
| def test_check_run_status_failed(self, mock_boto_resource, mock_boto_client): | |
| """Test checking status of failed execution""" | |
| clean_imports() | |
| mock_dynamodb = MagicMock() | |
| mock_table = MagicMock() | |
| mock_boto_resource.return_value = mock_dynamodb | |
| mock_dynamodb.Table.return_value = mock_table | |
| mock_stepfunctions = MagicMock() | |
| mock_boto_client.return_value = mock_stepfunctions | |
| mock_stepfunctions.describe_execution.return_value = { | |
| "status": "FAILED", | |
| "cause": "Lambda function failed with error: Invalid input", | |
| "executionArn": "arn:aws:states:us-east-1:123456789012:execution:MyWorkflow:workflow-abc-123", | |
| } | |
| from lambdas.workflow import check_run_status | |
| response = check_run_status("workflow-abc-123") | |
| assert response["statusCode"] == 200 | |
| body = json.loads(response["body"]) | |
| assert body["status"] == "FAILED" | |
| assert "error" in body | |
| @patch.dict( | |
| os.environ, | |
| { | |
| "WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow", | |
| "CMAPP_SCORING_TABLE": "test-scoring", | |
| "WORKFLOW_RESULT_PREFIX": "scoring", | |
| }, | |
| ) | |
| @patch("boto3.client") | |
| @patch("boto3.resource") | |
| def test_check_run_status_aborted(self, mock_boto_resource, mock_boto_client): | |
| """Test checking status of aborted execution""" | |
| clean_imports() | |
| mock_dynamodb = MagicMock() | |
| mock_table = MagicMock() | |
| mock_boto_resource.return_value = mock_dynamodb | |
| mock_dynamodb.Table.return_value = mock_table | |
| mock_stepfunctions = MagicMock() | |
| mock_boto_client.return_value = mock_stepfunctions | |
| mock_stepfunctions.describe_execution.return_value = { | |
| "status": "ABORTED", | |
| "executionArn": "arn:aws:states:us-east-1:123456789012:execution:MyWorkflow:workflow-abc-123", | |
| } | |
| from lambdas.workflow import check_run_status | |
| response = check_run_status("workflow-abc-123") | |
| assert response["statusCode"] == 200 | |
| body = json.loads(response["body"]) | |
| assert body["status"] == "ABORTED" | |
| assert "error" in body | |
| @patch.dict( | |
| os.environ, | |
| { | |
| "WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow", | |
| "CMAPP_SCORING_TABLE": "test-scoring", | |
| "WORKFLOW_RESULT_PREFIX": "scoring", | |
| }, | |
| ) | |
| @patch("boto3.client") | |
| @patch("boto3.resource") | |
| def test_check_run_status_timed_out(self, mock_boto_resource, mock_boto_client): | |
| """Test checking status of timed out execution""" | |
| clean_imports() | |
| mock_dynamodb = MagicMock() | |
| mock_table = MagicMock() | |
| mock_boto_resource.return_value = mock_dynamodb | |
| mock_dynamodb.Table.return_value = mock_table | |
| mock_stepfunctions = MagicMock() | |
| mock_boto_client.return_value = mock_stepfunctions | |
| mock_stepfunctions.describe_execution.return_value = { | |
| "status": "TIMED_OUT", | |
| "executionArn": "arn:aws:states:us-east-1:123456789012:execution:MyWorkflow:workflow-abc-123", | |
| } | |
| from lambdas.workflow import check_run_status | |
| response = check_run_status("workflow-abc-123") | |
| assert response["statusCode"] == 200 | |
| body = json.loads(response["body"]) | |
| assert body["status"] == "TIMED_OUT" | |
| assert "error" in body | |
| @patch.dict( | |
| os.environ, | |
| { | |
| "WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow", | |
| "CMAPP_SCORING_TABLE": "test-scoring", | |
| }, | |
| ) | |
| @patch("boto3.client") | |
| @patch("boto3.resource") | |
| def test_check_run_status_not_found(self, mock_boto_resource, mock_boto_client): | |
| """Test checking status of non-existent execution""" | |
| clean_imports() | |
| mock_dynamodb = MagicMock() | |
| mock_table = MagicMock() | |
| mock_boto_resource.return_value = mock_dynamodb | |
| mock_dynamodb.Table.return_value = mock_table | |
| mock_stepfunctions = MagicMock() | |
| mock_boto_client.return_value = mock_stepfunctions | |
| # Simulate ExecutionDoesNotExist exception | |
| class ExecutionDoesNotExistException(Exception): | |
| pass | |
| mock_stepfunctions.exceptions.ExecutionDoesNotExist = ( | |
| ExecutionDoesNotExistException | |
| ) | |
| mock_stepfunctions.describe_execution.side_effect = ( | |
| ExecutionDoesNotExistException() | |
| ) | |
| from lambdas.workflow import check_run_status | |
| response = check_run_status("nonexistent-workflow-123") | |
| assert response["statusCode"] == 404 | |
| body = json.loads(response["body"]) | |
| assert "Run not found" in body["error"] | |
| @patch.dict( | |
| os.environ, | |
| { | |
| "CMAPP_SCORING_TABLE": "test-scoring", | |
| }, | |
| ) | |
| @patch("boto3.resource") | |
| def test_check_run_status_missing_arn(self, mock_boto_resource): | |
| """Test checking status with missing state machine ARN""" | |
| clean_imports() | |
| mock_dynamodb = MagicMock() | |
| mock_table = MagicMock() | |
| mock_boto_resource.return_value = mock_dynamodb | |
| mock_dynamodb.Table.return_value = mock_table | |
| from lambdas.workflow import check_run_status | |
| response = check_run_status("workflow-abc-123") | |
| assert response["statusCode"] == 500 | |
| body = json.loads(response["body"]) | |
| assert "State machine ARN not configured" in body["details"] | |
| class TestWorkflowLogic: | |
| """Tests for workflow_logic main handler""" | |
| @patch.dict( | |
| os.environ, | |
| { | |
| "WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow", | |
| "CMAPP_SCORING_TABLE": "test-scoring", | |
| }, | |
| ) | |
| @patch("boto3.client") | |
| @patch("boto3.resource") | |
| def test_workflow_logic_post_root(self, mock_boto_resource, mock_boto_client): | |
| """Test POST to / routes to start_workflow""" | |
| clean_imports() | |
| mock_dynamodb = MagicMock() | |
| mock_table = MagicMock() | |
| mock_boto_resource.return_value = mock_dynamodb | |
| mock_dynamodb.Table.return_value = mock_table | |
| mock_stepfunctions = MagicMock() | |
| mock_boto_client.return_value = mock_stepfunctions | |
| mock_stepfunctions.start_execution.return_value = { | |
| "executionArn": "arn:aws:states:us-east-1:123456789012:execution:MyWorkflow:workflow-abc-123", | |
| } | |
| from lambdas.workflow import workflow_logic | |
| event = { | |
| "httpMethod": "POST", | |
| "rawPath": "/api/workflow", | |
| "body": json.dumps( | |
| { | |
| "org": {"id": "test-org"}, | |
| "account": {"id": "123456789012"}, | |
| } | |
| ), | |
| } | |
| response = workflow_logic(event, {}) | |
| assert response["statusCode"] == 200 | |
| body = json.loads(response["body"]) | |
| assert body["status"] == "RUNNING" | |
| @patch.dict( | |
| os.environ, | |
| { | |
| "WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow", | |
| "CMAPP_SCORING_TABLE": "test-scoring", | |
| }, | |
| ) | |
| @patch("boto3.client") | |
| @patch("boto3.resource") | |
| def test_workflow_logic_get_runid(self, mock_boto_resource, mock_boto_client): | |
| """Test GET /{runid} routes to check_run_status""" | |
| clean_imports() | |
| mock_dynamodb = MagicMock() | |
| mock_table = MagicMock() | |
| mock_boto_resource.return_value = mock_dynamodb | |
| mock_dynamodb.Table.return_value = mock_table | |
| mock_stepfunctions = MagicMock() | |
| mock_boto_client.return_value = mock_stepfunctions | |
| mock_stepfunctions.describe_execution.return_value = { | |
| "status": "RUNNING", | |
| } | |
| from lambdas.workflow import workflow_logic | |
| event = { | |
| "httpMethod": "GET", | |
| "rawPath": "/api/workflow/workflow-abc-123", | |
| } | |
| response = workflow_logic(event, {}) | |
| assert response["statusCode"] == 200 | |
| body = json.loads(response["body"]) | |
| assert body["status"] == "RUNNING" | |
| @patch.dict( | |
| os.environ, | |
| { | |
| "WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow", | |
| "CMAPP_SCORING_TABLE": "test-scoring", | |
| }, | |
| ) | |
| @patch("boto3.resource") | |
| def test_workflow_logic_options(self, mock_boto_resource): | |
| """Test OPTIONS request returns 200""" | |
| clean_imports() | |
| mock_dynamodb = MagicMock() | |
| mock_table = MagicMock() | |
| mock_boto_resource.return_value = mock_dynamodb | |
| mock_dynamodb.Table.return_value = mock_table | |
| from lambdas.workflow import workflow_logic | |
| event = { | |
| "requestContext": {"http": {"method": "OPTIONS"}}, | |
| "rawPath": "/api/workflow", | |
| } | |
| response = workflow_logic(event, {}) | |
| assert response["statusCode"] == 200 | |
| @patch.dict( | |
| os.environ, | |
| { | |
| "WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow", | |
| "CMAPP_SCORING_TABLE": "test-scoring", | |
| }, | |
| ) | |
| @patch("boto3.resource") | |
| def test_workflow_logic_invalid_method(self, mock_boto_resource): | |
| """Test invalid route returns 404""" | |
| clean_imports() | |
| mock_dynamodb = MagicMock() | |
| mock_table = MagicMock() | |
| mock_boto_resource.return_value = mock_dynamodb | |
| mock_dynamodb.Table.return_value = mock_table | |
| from lambdas.workflow import workflow_logic | |
| event = { | |
| "httpMethod": "DELETE", | |
| "rawPath": "/api/workflow", | |
| } | |
| response = workflow_logic(event, {}) | |
| assert response["statusCode"] == 404 | |
| body = json.loads(response["body"]) | |
| assert "Not Found" in body["error"] | |
| @patch.dict( | |
| os.environ, | |
| { | |
| "WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow", | |
| "CMAPP_SCORING_TABLE": "test-scoring", | |
| "WORKFLOW_BASE_PATH": "/api/workflow", | |
| }, | |
| ) | |
| @patch("boto3.client") | |
| @patch("boto3.resource") | |
| def test_workflow_logic_with_query_string( | |
| self, mock_boto_resource, mock_boto_client | |
| ): | |
| """Test that query string is stripped from path""" | |
| clean_imports() | |
| mock_dynamodb = MagicMock() | |
| mock_table = MagicMock() | |
| mock_boto_resource.return_value = mock_dynamodb | |
| mock_dynamodb.Table.return_value = mock_table | |
| mock_stepfunctions = MagicMock() | |
| mock_boto_client.return_value = mock_stepfunctions | |
| mock_stepfunctions.describe_execution.return_value = { | |
| "status": "RUNNING", | |
| } | |
| from lambdas.workflow import workflow_logic | |
| event = { | |
| "httpMethod": "GET", | |
| "rawPath": "/api/workflow/workflow-abc-123?foo=bar", | |
| } | |
| response = workflow_logic(event, {}) | |
| assert response["statusCode"] == 200 | |
| class TestNormalizePath: | |
| """Tests for _normalize_path helper function""" | |
| @patch.dict( | |
| os.environ, | |
| { | |
| "WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow", | |
| "CMAPP_SCORING_TABLE": "test-scoring", | |
| "WORKFLOW_BASE_PATH": "/api/workflow", | |
| }, | |
| ) | |
| @patch("boto3.resource") | |
| def test_normalize_path_root(self, mock_boto_resource): | |
| """Test normalizing root path""" | |
| clean_imports() | |
| mock_dynamodb = MagicMock() | |
| mock_table = MagicMock() | |
| mock_boto_resource.return_value = mock_dynamodb | |
| mock_dynamodb.Table.return_value = mock_table | |
| from lambdas.workflow import _normalize_path | |
| assert _normalize_path("/api/workflow") == "/" | |
| @patch.dict( | |
| os.environ, | |
| { | |
| "WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow", | |
| "CMAPP_SCORING_TABLE": "test-scoring", | |
| "WORKFLOW_BASE_PATH": "/api/workflow", | |
| }, | |
| ) | |
| @patch("boto3.resource") | |
| def test_normalize_path_with_runid(self, mock_boto_resource): | |
| """Test normalizing path with run ID""" | |
| clean_imports() | |
| mock_dynamodb = MagicMock() | |
| mock_table = MagicMock() | |
| mock_boto_resource.return_value = mock_dynamodb | |
| mock_dynamodb.Table.return_value = mock_table | |
| from lambdas.workflow import _normalize_path | |
| assert _normalize_path("/api/workflow/workflow-abc-123") == "/workflow-abc-123" | |
| @patch.dict( | |
| os.environ, | |
| { | |
| "WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow", | |
| "CMAPP_SCORING_TABLE": "test-scoring", | |
| }, | |
| ) | |
| @patch("boto3.resource") | |
| def test_normalize_path_trailing_slash(self, mock_boto_resource): | |
| """Test that trailing slashes are removed""" | |
| clean_imports() | |
| mock_dynamodb = MagicMock() | |
| mock_table = MagicMock() | |
| mock_boto_resource.return_value = mock_dynamodb | |
| mock_dynamodb.Table.return_value = mock_table | |
| from lambdas.workflow import _normalize_path | |
| assert _normalize_path("/test/") == "/test" | |
| @patch.dict( | |
| os.environ, | |
| { | |
| "WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow", | |
| "CMAPP_SCORING_TABLE": "test-scoring", | |
| }, | |
| ) | |
| @patch("boto3.resource") | |
| def test_normalize_path_missing_leading_slash(self, mock_boto_resource): | |
| """Test that missing leading slash is added""" | |
| clean_imports() | |
| mock_dynamodb = MagicMock() | |
| mock_table = MagicMock() | |
| mock_boto_resource.return_value = mock_dynamodb | |
| mock_dynamodb.Table.return_value = mock_table | |
| from lambdas.workflow import _normalize_path | |
| assert _normalize_path("test").startswith("/") | |
| class TestResponseFormat: | |
| """Tests for response format and headers""" | |
| @patch.dict( | |
| os.environ, | |
| { | |
| "WORKFLOW_STATE_MACHINE_ARN": "arn:aws:states:us-east-1:123456789012:stateMachine:MyWorkflow", | |
| "CMAPP_SCORING_TABLE": "test-scoring", | |
| }, | |
| ) | |
| @patch("boto3.resource") | |
| def test_response_has_cors_headers(self, mock_boto_resource): | |
| """Test that response includes CORS headers""" | |
| clean_imports() | |
| mock_dynamodb = MagicMock() | |
| mock_table = MagicMock() | |
| mock_boto_resource.return_value = mock_dynamodb | |
| mock_dynamodb.Table.return_value = mock_table | |
| from lambdas.workflow import response | |
| resp = response(200, {"test": "data"}) | |
| assert "headers" in resp | |
| assert "Access-Control-Allow-Origin" in resp["headers"] | |
| assert resp["headers"]["Access-Control-Allow-Origin"] == "*" | |
| assert "Content-Type" in resp["headers"] | |
| assert resp["headers"]["Content-Type"] == "application/json" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import json | |
| import logging | |
| import os | |
| import time | |
| import uuid | |
| from datetime import datetime, timezone | |
| from decimal import Decimal | |
| from typing import Any, Dict | |
| import boto3 | |
| # Configure structured logging | |
| logger = logging.getLogger() | |
| logger.setLevel(logging.INFO) | |
| # Initialize AWS clients | |
| dynamodb = boto3.resource("dynamodb") | |
| stepfunctions = boto3.client("stepfunctions") | |
| SCORING_TABLE = os.environ.get("CMAPP_SCORING_TABLE", "cmapp_scoring") | |
| WORKFLOW_STATE_MACHINE_ARN = os.environ.get("WORKFLOW_STATE_MACHINE_ARN") | |
| RESULT_PREFIX = os.environ.get("WORKFLOW_RESULT_PREFIX", "scoring") | |
| BASE_PATH = os.environ.get("WORKFLOW_BASE_PATH", "/api/workflow") | |
| STATE_SK = "STATE" | |
| scoring_table = dynamodb.Table(SCORING_TABLE) | |
| class DecimalEncoder(json.JSONEncoder): | |
| """Helper class to convert DynamoDB Decimal types to JSON""" | |
| def default(self, obj: Any) -> Any: | |
| if isinstance(obj, Decimal): | |
| return float(obj) | |
| return super(DecimalEncoder, self).default(obj) | |
| def response(status_code: int, body: Dict[str, Any]) -> Dict[str, Any]: | |
| """Generate API Gateway response with consistent headers""" | |
| return { | |
| "statusCode": status_code, | |
| "headers": { | |
| "Content-Type": "application/json", | |
| "Access-Control-Allow-Origin": "*", | |
| "Access-Control-Allow-Headers": "Content-Type,X-Amz-Date,Authorization,X-Api-Key", | |
| "Access-Control-Allow-Methods": "GET,POST,PUT,DELETE,OPTIONS", | |
| }, | |
| "body": json.dumps(body, cls=DecimalEncoder), | |
| } | |
| def start_workflow(event: Dict[str, Any]) -> Dict[str, Any]: | |
| """Invoke Step Function state machine and return execution ID.""" | |
| try: | |
| if not WORKFLOW_STATE_MACHINE_ARN: | |
| logger.error("WORKFLOW_STATE_MACHINE_ARN environment variable not set") | |
| return response( | |
| 500, | |
| { | |
| "error": "Configuration error", | |
| "details": "State machine ARN not configured", | |
| }, | |
| ) | |
| try: | |
| body = json.loads(event.get("body") or "{}") | |
| except json.JSONDecodeError: | |
| return response(400, {"error": "Invalid JSON payload"}) | |
| org = body.get("org") or {} | |
| account = body.get("account") or {} | |
| org_id = org.get("id") or org.get("ma_name") or org.get("name") | |
| account_id = account.get("id") or account.get("account_id") | |
| if not org_id or not account_id: | |
| logger.warning( | |
| "Missing required workflow fields", | |
| extra={"org_id": org_id, "account_id": account_id}, | |
| ) | |
| return response(400, {"error": "org.id and account.id are required"}) | |
| # Build execution input | |
| execution_input = {"org": org, "account": account} | |
| # Start Step Function execution | |
| execution_name = f"workflow-{uuid.uuid4()}" | |
| execution_response = stepfunctions.start_execution( | |
| stateMachineArn=WORKFLOW_STATE_MACHINE_ARN, | |
| name=execution_name, | |
| input=json.dumps(execution_input), | |
| ) | |
| execution_arn = execution_response.get("executionArn") | |
| run_id = execution_name | |
| logger.info( | |
| "Step Function execution started", | |
| extra={ | |
| "run_id": run_id, | |
| "execution_arn": execution_arn, | |
| "org_id": org_id, | |
| "account_id": account_id, | |
| }, | |
| ) | |
| return response(200, {"runId": run_id, "status": "RUNNING"}) | |
| except stepfunctions.exceptions.StateMachineDoesNotExist as e: | |
| logger.error(f"State machine not found: {str(e)}", exc_info=True) | |
| return response( | |
| 500, | |
| {"error": "State machine not found", "details": str(e)}, | |
| ) | |
| except stepfunctions.exceptions.ExecutionLimitExceeded as e: | |
| logger.error(f"Execution limit exceeded: {str(e)}", exc_info=True) | |
| return response( | |
| 503, | |
| {"error": "Too many concurrent executions", "details": str(e)}, | |
| ) | |
| except Exception as e: | |
| logger.error(f"Error starting workflow: {str(e)}", exc_info=True) | |
| return response(500, {"error": "Failed to start workflow", "details": str(e)}) | |
| def check_run_status(run_id: str) -> Dict[str, Any]: | |
| """Check Step Function execution status and return results if complete.""" | |
| try: | |
| if not WORKFLOW_STATE_MACHINE_ARN: | |
| logger.error("WORKFLOW_STATE_MACHINE_ARN environment variable not set") | |
| return response( | |
| 500, | |
| { | |
| "error": "Configuration error", | |
| "details": "State machine ARN not configured", | |
| }, | |
| ) | |
| # Build execution ARN from run_id (execution name) | |
| state_machine_name = WORKFLOW_STATE_MACHINE_ARN.split(":")[-1] | |
| account_id = WORKFLOW_STATE_MACHINE_ARN.split(":")[4] | |
| region = WORKFLOW_STATE_MACHINE_ARN.split(":")[3] | |
| execution_arn = f"arn:aws:states:{region}:{account_id}:execution:{state_machine_name}:{run_id}" | |
| # Describe execution to get current status | |
| exec_response = stepfunctions.describe_execution(executionArn=execution_arn) | |
| status = exec_response.get( | |
| "status" | |
| ) # RUNNING, SUCCEEDED, FAILED, TIMED_OUT, ABORTED | |
| execution_output = exec_response.get("output") | |
| payload = {"runId": run_id, "status": status} | |
| # If execution is complete, fetch results from DynamoDB | |
| if status == "SUCCEEDED": | |
| try: | |
| # Parse execution output to get org and account info | |
| output_data = {} | |
| if execution_output: | |
| output_data = json.loads(execution_output) | |
| org = output_data.get("org", {}) | |
| account = output_data.get("account", {}) | |
| # Extract identifiers | |
| org_id = ( | |
| org.get("id") or org.get("ma_name") or org.get("name") or "unknown" | |
| ) | |
| account_id_val = ( | |
| account.get("id") or account.get("account_id") or "unknown" | |
| ) | |
| # Query DynamoDB for scoring results | |
| # Results are stored with PK = org_id, SK begins with run_id | |
| try: | |
| query_response = scoring_table.query( | |
| KeyConditionExpression="pk = :pk AND begins_with(sk, :sk)", | |
| ExpressionAttributeValues={ | |
| ":pk": f"ORG#{org_id}", | |
| ":sk": f"RUN#{run_id}", | |
| }, | |
| ) | |
| items = query_response.get("Items", []) | |
| if items: | |
| # Extract S3 keys from results | |
| results = [] | |
| for item in items: | |
| if "s3_keys" in item: | |
| s3_keys = item.get("s3_keys", []) | |
| if isinstance(s3_keys, list): | |
| results.extend(s3_keys) | |
| else: | |
| results.append(s3_keys) | |
| if results: | |
| payload["results"] = results | |
| else: | |
| # Fallback: construct expected S3 keys based on pattern | |
| prefix = ( | |
| f"{RESULT_PREFIX}/{org_id}/{account_id_val}/{run_id}" | |
| ) | |
| payload["results"] = [ | |
| f"{prefix}/summary.json", | |
| f"{prefix}/evidence.csv", | |
| f"{prefix}/report.pdf", | |
| ] | |
| else: | |
| # No DynamoDB results yet, construct fallback keys | |
| prefix = f"{RESULT_PREFIX}/{org_id}/{account_id_val}/{run_id}" | |
| payload["results"] = [ | |
| f"{prefix}/summary.json", | |
| f"{prefix}/evidence.csv", | |
| f"{prefix}/report.pdf", | |
| ] | |
| except Exception as db_err: | |
| logger.warning( | |
| "Could not fetch results from DynamoDB", | |
| extra={"run_id": run_id, "error": str(db_err)}, | |
| ) | |
| # Fallback: construct expected S3 keys | |
| prefix = f"{RESULT_PREFIX}/{org_id}/{account_id_val}/{run_id}" | |
| payload["results"] = [ | |
| f"{prefix}/summary.json", | |
| f"{prefix}/evidence.csv", | |
| f"{prefix}/report.pdf", | |
| ] | |
| except json.JSONDecodeError: | |
| logger.warning( | |
| "Could not parse execution output", | |
| extra={"run_id": run_id}, | |
| ) | |
| # Fallback: provide generic result paths | |
| payload["results"] = [ | |
| f"{RESULT_PREFIX}/unknown/unknown/{run_id}/summary.json", | |
| f"{RESULT_PREFIX}/unknown/unknown/{run_id}/evidence.csv", | |
| f"{RESULT_PREFIX}/unknown/unknown/{run_id}/report.pdf", | |
| ] | |
| elif status == "FAILED": | |
| payload["error"] = exec_response.get("cause", "Execution failed") | |
| elif status == "ABORTED": | |
| payload["error"] = "Execution was aborted" | |
| elif status == "TIMED_OUT": | |
| payload["error"] = "Execution timed out" | |
| logger.info( | |
| "Execution status retrieved", | |
| extra={"run_id": run_id, "status": status}, | |
| ) | |
| return response(200, payload) | |
| except stepfunctions.exceptions.ExecutionDoesNotExist: | |
| logger.warning("Execution not found", extra={"run_id": run_id}) | |
| return response(404, {"error": "Run not found", "runId": run_id}) | |
| except Exception as e: | |
| logger.error(f"Error checking run status: {str(e)}", exc_info=True) | |
| return response(500, {"error": "Failed to check status", "details": str(e)}) | |
| def _normalize_path(path: str) -> str: | |
| """Strip optional base path and ensure a single leading slash.""" | |
| clean_path = path or "/" | |
| if not clean_path.startswith("/"): | |
| clean_path = f"/{clean_path}" | |
| if BASE_PATH and clean_path.startswith(BASE_PATH): | |
| suffix = clean_path[len(BASE_PATH) :] | |
| clean_path = suffix or "/" | |
| if clean_path != "/" and clean_path.endswith("/"): | |
| clean_path = clean_path[:-1] | |
| return clean_path | |
| def workflow_logic(event: Dict[str, Any], context: Any) -> Dict[str, Any]: | |
| """Main handler for workflow API requests.""" | |
| try: | |
| method = ( | |
| event.get("requestContext", {}).get("http", {}).get("method") | |
| or event.get("httpMethod") | |
| or "GET" | |
| ) | |
| raw_path = (event.get("rawPath") or event.get("path") or "/").split("?")[0] | |
| normalized_path = _normalize_path(raw_path) | |
| logger.info( | |
| "Workflow request received", | |
| extra={ | |
| "method": method, | |
| "path": raw_path, | |
| "normalized_path": normalized_path, | |
| }, | |
| ) | |
| if method == "OPTIONS": | |
| return response(200, {"message": "ok"}) | |
| # POST /api/workflow - Start new workflow | |
| if normalized_path == "/" and method == "POST": | |
| return start_workflow(event) | |
| # GET /api/workflow/{runid} - Check workflow status | |
| if normalized_path != "/" and method == "GET": | |
| run_id = normalized_path.lstrip("/") | |
| return check_run_status(run_id) | |
| logger.warning("No matching route", extra={"path": raw_path, "method": method}) | |
| return response(404, {"error": "Not Found", "path": raw_path}) | |
| except Exception as e: | |
| logger.error(f"Unhandled exception in workflow_logic: {str(e)}", exc_info=True) | |
| return response(500, {"error": "Internal Server Error", "details": str(e)}) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment