diff --git a/docs/upgrades/api-v1-retirement.md b/docs/upgrades/api-v1-retirement.md new file mode 100644 index 00000000..ddcdcc4a --- /dev/null +++ b/docs/upgrades/api-v1-retirement.md @@ -0,0 +1,35 @@ +# Retire the v1 API + +This release removes every `/api/v1/**` handler. Old API calls return 404. The v2 API keeps its existing session and API-key policies. This change does not redeploy environment services, move Jobs or change Kubernetes permissions. + +Before the upgrade: + +- Inventory ingress and internal callers, including scheduled automation. Move v1 calls to supported v2 operations. A quiet ingress sample does not prove that internal callers are absent. +- Deploy the ticket-capable Lifecycle UI and publish a distinguishable ticket-capable CLI release. Validate live pod, build and deploy logs and archived logs against the new API image. +- Coordinate the API and WebSocket replica cutover. Mixed versions can interrupt logs. Drain every old replica and anonymous socket before declaring the endpoint closed. Users must reopen disconnected views or rerun CLI commands. +- Confirm job-only workers have no custom HTTP consumers beyond GET `/api/health` and GET `/api/jobs`. +- Update existing GitHub App settings before a future setup or installation flow: setup URL `/api/setup/installed`, redirect URL `/api/setup/callback`, and the matching OAuth callback entry. Keep the existing Keycloak/user authorization callback and `/api/webhooks/github` webhook URL. Existing configured Apps are managed through installation settings, not the fresh setup API. + +The environment banner now opens the authenticated environment page. It uses the existing `LFC_DASHBOARD_URL` metadata, so newly loaded banner scripts work in existing environments without a service redeploy. Already loaded or cached old scripts may still call the retired API until users refresh. The in-page anonymous log terminal is removed. + +The old `/schema/validate` page now explains CLI and authenticated v2 validation. Use `lfc schema validate lifecycle.yaml` for a local file or sign in and use `lfc schema validate --repo owner/repository --branch branch-name`. `/v1/docs` redirects to `/v2/docs`. + +## Fresh GitHub App setup + +Fresh setup uses `/setup` and `/api/setup/**`. It requires an operator token and reads the live bootstrap Secret before changing credentials. Any existing credentials lock new App creation. A valid, matching fresh setup flow can finish installation and configuration. There is no HTTP path to replace an existing configured App. + +Provide the same `LIFECYCLE_SETUP_TOKEN` to all web replicas, or use the token derived from their shared `ENCRYPTION_KEY`. The server does not log the token and does not generate per-process credentials. Operators with access to a web pod can retrieve the token without printing the encryption key: + +```sh +kubectl exec -n -- node -e 'const c=require("crypto"); const token=process.env.LIFECYCLE_SETUP_TOKEN?.trim(); if(token) process.stdout.write(token+"\n"); else { const key=process.env.ENCRYPTION_KEY?.trim(); if(!key || ["changeme","not_setup","replace_me","your_value_here"].includes(key.toLowerCase())) throw new Error("Configure LIFECYCLE_SETUP_TOKEN or ENCRYPTION_KEY"); process.stdout.write(c.createHmac("sha256",key).update("lifecycle-setup-token").digest("hex").slice(0,32)+"\n"); }' +``` + +Treat this output as a credential. Enter it in the setup form, keep it out of URLs and shared logs, and remove an explicit setup token after completion when it is no longer needed. Existing in-flight v1 setup links are rejected. Start a fresh setup flow using the new page. + +Setup callback state expires after one hour and is consumed when its phase advances. Mutations serialize on the existing `app_setup` database row. Kubernetes Secret writes use the observed resource version. Nonsecret flow annotations bind retry recovery to the exact App credentials written by that flow. This permits retry after a Secret write succeeds but the database commit fails. A conflicting operator credential update locks the flow instead of adopting the changed credentials. + +The installation callback verifies that the installation belongs to the App created by setup. Setup initializes only missing or known seed domain and registry defaults and preserves other configured values. It requests a rollout restart only after fresh installation is recorded. If a restart fails, retry the configure action or use the deployment tools. A restart accepted before a lost response may be repeated on retry. + +## Verification still required for an installation + +Run a fresh install rehearsal and verify callback state, Secret writes, configured-instance lockout, and queue bootstrap with the actual chart and authentication provider. Verify the banner in an existing environment with main-era injected metadata. Verify that all old v1 API paths return 404 on the built image, including each log alias and archived-log route. Unit tests do not establish private chart compatibility, actual client versions, production routing, or the absence of old listeners. diff --git a/docs/upgrades/main-security-release.md b/docs/upgrades/main-security-release.md new file mode 100644 index 00000000..aea7716c --- /dev/null +++ b/docs/upgrades/main-security-release.md @@ -0,0 +1,62 @@ +# Upgrade checklist for the main-based security release + +This candidate starts from API main `976e713af4dbd1b5cd1cacd06ccef91d36dc24f0`. It addresses application security without introducing a migration of existing environment namespaces, service accounts, RBAC, AWS identities, network policies, databases or queues relative to that revision. API dependencies and the lockfile are unchanged. Existing permissions are not revoked by the first new Job. + +This boundary applies to the candidate diff, not to the difference between production and main. Map the production image to source and compare its migration ledger before approving an upgrade. Existing broad workload permissions and datastore exposure remain separate security work. No production closure or guarantee of uninterrupted control-plane connections follows from this checklist. + +## Included behavior + +- Shell injection fixes use argument arrays or quote data in fixed scripts. Native and Codefresh build/deploy/destroy paths, ingress and ExternalSecret commands retain their resource targets. Generated scripts and executable versions still require rehearsal. +- Agent Kubernetes tools require an authoritative environment namespace. A missing namespace denies dependent tools instead of accepting a caller-supplied namespace. A failed Build or database lookup can fail the agent run. +- Command-based MCP servers execute and discover tools through the session workspace gateway. Discovery runs for each agent run, even when stored tool metadata exists. Workspace tool catalogs and errors stay within that run; they do not update shared or user connection discovery state. These tools are classified as MCP writes regardless of their annotations; admission and approval for Develop are described below. +- Develop uses its existing workspace-command and MCP-read permissions to admit command-based MCP tools. It gains no HTTP/SSE write permission, but command-based servers can themselves call remote services. Compared with main, Develop can use write-annotated command tools; read-annotated command tools that previously ran without approval now follow the stricter policy. +- This workspace admission path requires MCP-write availability for the agent owner. The default `admin_only` setting excludes user-owned custom agents; creator-reserved write also blocks them. Approval uses the strictest command, MCP-read and MCP-write rule unless an exact tool rule overrides it. A configured `approvalPolicy.defaultMode` supplies each unspecified rule. Disabled capabilities cannot be overridden. Agents that explicitly declare MCP-write permission retain their existing MCP-write approval path; a command deny does not block that path. +- Denied command-based connections are excluded before discovery starts a process; an applicable exact tool exception can permit discovery. A connection can appear available in the controls while policy excludes all its tools. Review installation-specific policies before rollout. +- Workspace command capture skips directory symlink aliases after the existing path checks. Such aliases previously caused an `EISDIR` error before an approved command could start. File aliases within the workspace remain supported. Existing workspace containers retain their running image until normal session replacement. +- Log streams require a ticket issued through authenticated `/api/v2/logs/stream-tickets`. The issuer checks environment access, API-key repository restrictions, and the pod/container. Tickets bind the target and expire after five minutes. They are replayable within that period; expiration does not terminate an established stream. +- Editor bootstrap exchanges the existing authenticated link for an opaque grant tied to the session and owner. Lifecycle credentials are removed from upstream requests; upstream cookies are suppressed on HTTP and WebSocket responses. Old raw-token cookies are rejected. Reopen the editor through Lifecycle to obtain a grant. Grants expire at the earlier of one hour or the source token's expiry. A 60-second allowance for a future issue time handles clock skew; it does not extend expiry or maximum lifetime. Existing sockets require a separate drain during replacement. +- All v1 API handlers are removed. The environment banner opens the authenticated environment page using existing `LFC_DASHBOARD_URL` metadata. Newly loaded scripts do not require an environment redeploy; cached pages need refresh. Fresh setup uses protected bootstrap state at `/api/setup/**`; configured installations remain managed through installation settings. See [V1 retirement and fresh setup](api-v1-retirement.md). + +## Finding accounting + +Rows refer to the original 22-critical-finding CSV. A source-complete candidate has the intended implementation; release verification and reviewer acceptance remain required. The log-ticket issue is a separate High finding and does not increase the critical count. + +| Classification | Rows | Remaining work | +| ----------------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Source-complete candidates: 5 | 2, 8, 15, 17, 21 | Qualify v1 removal/setup and external consumers; deploy/destroy command fidelity; session-local MCP discovery/write policy; namespace denial; editor credentials and connection behavior. | +| Partial fixes: 8 | 4, 9, 11, 12, 13, 16, 18, 19 | Direct shell execution is corrected. Report-specific input validation, generated pipeline review, exact-ref/fork behavior and credential scope still require evidence or an explicitly accepted alternative. | +| Deferred: 9 | 1, 3, 5, 6, 7, 10, 14, 20, 22 | Prewarm/setup execution isolation, workload authority, Aurora helpers, Redis/PostgreSQL controls, clone credentials, OpenSandbox key handling and workspace ownership. Assign owners and follow-up releases. | + +The candidate touches 13 critical rows. It does not close all 22 findings. Record each accepted criterion and unresolved condition in the release evidence; do not infer scanner closure from a passing build. + +## Before deployment + +- [ ] Pin API, UI and CLI source revisions, image/package digests and rollback artifacts. Give the ticket-capable CLI a distinguishable release version. Map the running production image to source and compare migrations with the candidate base; qualify any intervening main changes separately. +- [ ] Record the effective `workspaceBackend.provider` and whether it comes from stored configuration or an environment default. Local agent, editor and gateway replays used `lifecycle_kubernetes`; they do not qualify OpenSandbox. If production uses OpenSandbox, treat finding 20 as live. Withhold agent rollout approval until that exposure is fixed or the installation owner explicitly accepts it. If sessions are already enabled, record the current exposure and agree on containment separately; this checklist does not automatically disable existing sessions. +- [ ] Record the effective workspace and gateway image settings, including `agentSessionDefaults.workspaceImage` and `workspaceGatewayImage`. Publish the corrected gateway image and update the applicable setting to its immutable digest, with a recorded rollback value. Inspect prewarm pods and verify the image ID and gateway module hash inside a new session. An API image update alone does not replace workspace images; existing sessions retain their running image until normal replacement. +- [ ] Confirm the rendered deployment contains no unintended chart, identity, namespace, RBAC, network, database or queue changes. Check private Terraform/chart overrides as well as this repository. Do not run fresh setup or rotate existing credentials as an upgrade step. +- [ ] Inventory all v1 consumers: ingress, internal services, automation and scheduled tasks. Migrate them before removal. Update GitHub App callback/setup URLs before a future setup or installation flow, following the linked v1 checklist. Existing in-flight v1 setup links cannot resume. +- [ ] Verify every serving production web/gateway/all replica has `ENABLE_AUTH=true` and an appropriate `LIFECYCLE_MODE` (`web`, `gateway` or `all`). Protected API, MCP, preview and editor access fails closed without configured authentication. Log issuance/redemption also requires a recognized serving mode. An unset mode is not a log-serving mode. +- [ ] Verify worker replicas use `LIFECYCLE_MODE=job`. Their only HTTP routes are exact GET `/api/health` and GET `/api/jobs`; all upgrades are rejected. Inventory custom probes, metrics or HTTP consumers before rollout. Confirm `/api/jobs` starts queue consumers and readiness succeeds with the actual private chart. +- [ ] Check explicit local-development settings separately. Only `APP_ENV=dev` or `APP_ENV=development` with auth disabled enables local anonymous behavior. An unset value or staging value does not qualify. Never use this exception on production listeners. +- [ ] Inventory global and repository-specific MCP policies: `approvalPolicy.defaultMode`, `rules.shell_exec`, `rules.external_mcp_read`, `rules.external_mcp_write`, exact `mcp__*` tool rules, and `capabilityPolicy.availability` for MCP-write and workspace-command permissions. Identify command-based servers that call remote services. Record expected approvals and denied agents before rollout. +- [ ] Compare the selected grant secret across every issuer and redeemer without recording secret values. Selection order is `CHAT_PREVIEW_GRANT_SECRET`, `ENCRYPTION_KEY`, `NEXTAUTH_SECRET`, then `GITHUB_WEBHOOK_SECRET`. The first nonempty configured value is selected and must not be a placeholder; an unusable higher-priority value does not fall through. Preserve the existing selected key to retain valid preview grants. Fresh setup has its separate shared-token requirements in the linked guide. +- [ ] Confirm ingress, load balancers, proxies and application logs do not retain ticket/token query values. Check clock synchronization across replicas. Verify that issuer responses are not cached. +- [ ] Capture representative existing PR and static environment state, including `good-dev-0` where available: Service/Ingress identities, pod UIDs, PVC data, application traffic and current Jobs. Record pending queue work so normal deployment activity can be distinguished from an upgrade regression. +- [ ] Compare production's expiry sweep with the candidate base. Main contains a PostgreSQL parameter-inference failure that can stop expiry and stuck-teardown retries; it may be new to an older production installation. Inventory overdue environments and pending teardown work before a separate fix, because restoring the sweep can activate overdue deletions. + +## Rehearse and cut over + +- [ ] Run the named test-repository scenarios for native build/Helm and Codefresh build/deploy/destroy, ExternalSecrets, Jobs, CronJobs, webhooks and after-build actions. Exercise valid complex branch/argument values and denied hostile input. The API image installs Codefresh CLI `0.81.5`; generated pipelines use `codefresh/cli:0.87.4`. Verify both execution environments, cleanup and error behavior. Record the resolved image digests used in rehearsal for generated Job and pipeline helpers, including `alpine/git:latest`, `alpine/helm:3.7.2` and `codefresh/cli:0.87.4`; a tag alone does not identify the tested image. +- [ ] Rehearse fresh setup separately with actual PostgreSQL locking, the bootstrap Secret, GitHub callbacks and multiple web replicas. Test stale/replayed state, Secret-write/DB-failure recovery and configured-install lockout. Do not use production as the fresh-install fixture. +- [ ] Test pending and previously cached stdio connections across two sessions/runs. Confirm discovery uses each workspace, does not persist its catalog/errors globally, and cannot obtain read-only treatment through tool annotations. Verify absent namespace and failed build lookup deny Kubernetes tools before a request is made. +- [ ] Check Develop's default and saved MCP selections. Command-based tools must retain write classification, respect command/read/write approval rules and disabled capabilities, and require the existing workspace-command and MCP-read permissions. Confirm HTTP/SSE write tools remain unavailable in Develop, including under existing allow rules. Verify workspace commands on a new session using the released gateway image. Rehearse a configured default approval mode and repository override. Confirm a user-owned custom agent under `admin_only` has no command tools. For the workspace admission path, verify a command deny prevents discovery and an exact exception exposes only the permitted tool. +- [ ] Test editor reopen, old-cookie rejection, grant expiry, clock skew, wrong owner/session and upstream credential/cookie filtering for supported backends. Test valid existing preview grants with the unchanged selected key. +- [ ] Deploy the ticket-capable UI before the enforcing backend if using its missing-route fallback. Activate the ticket-capable CLI only after both issuer and enforcing WebSocket server are available; that CLI has no anonymous fallback. Test live pod/build/deploy logs and archived logs with the released artifacts. +- [ ] Coordinate API/WebSocket replacement so ticket issuance and redemption reach compatible replicas. Remove every old anonymous listener, including internal worker endpoints, and drain existing sockets. Mixed replicas can interrupt logs. Users may need to reopen terminals or rerun CLI commands; do not assume automatic reconnect. +- [ ] On the built image, verify anonymous issuance/stream denial, expired/tampered/wrong-target tickets, key scope/repository restrictions, worker HTTP/upgrade denial and v1 404s. Verify the banner in an existing environment with main-era metadata and a refreshed page. +- [ ] Monitor the captured PR/static environments through control-plane replacement without changing their application configuration or permissions. Compare traffic, resource identities and PVC data. Confirm readiness, queue progress, error rates and new deploy operations before broad rollout. + +## Release decision + +Keep automated results, image digests, operator evidence and reviewer verdicts together in the release record. Unit tests establish specific code contracts; they do not establish private infrastructure compatibility, live queue behavior or the absence of old listeners. Approve production rollout only after the installation-specific gates pass. If rollback is required, record that the old image reopens the corrected security paths; availability recovery is not security closure. diff --git a/public/utils/0-banner.js b/public/utils/0-banner.js index e0baa3b8..36695c4f 100644 --- a/public/utils/0-banner.js +++ b/public/utils/0-banner.js @@ -15,9 +15,6 @@ */ (function () { - const SERVICE_NAME = window.LFC_SERVICE_NAME || 'service name'; - const UUID = window.LFC_UUID || 'not found'; - const BADGE_STYLES = { position: 'fixed', bottom: '20px', @@ -35,28 +32,13 @@ pointerEvents: 'auto', }; - const TERMINAL_COLORS = { - bg: '#1E1E1E', - text: '#D4D4D4', - error: '#FF5252', - warning: '#FFA726', - info: '#4CAF50', - prompt: '#64B5F6', - }; - const LINK_STYLES = 'color: #4CAF50; text-decoration: none; border-bottom: 1px dotted #4CAF50;'; const BADGE_ID = 'versionBadge'; - const LOG_MODAL_ID = 'logsModal'; - const LOG_MODAL_CONTENT_ID = 'logsModalContent'; - const LOG_BUTTON_ID = 'showLogsButton'; - const SEARCH_INPUT_ID = 'logSearchInput'; + const LOGS_BUTTON_ID = 'openLogsButton'; let shadowRoot = null; - let logsModal = null; - let evtSource = null; const state = { isHidden: true, - terminalWasOpen: false, shouldShowBadge: false, isCollapsed: false, badgePosition: { @@ -134,7 +116,6 @@ state.isHidden = hiddenState === null ? true : hiddenState === 'true'; state.shouldShowBadge = badgeVisibleState === null ? false : badgeVisibleState === 'true'; - state.terminalWasOpen = localStorage.getItem('terminalWasOpen') === 'true'; state.isCollapsed = localStorage.getItem('bannerCollapsed') === 'true'; const savedPosition = localStorage.getItem('badgePosition'); @@ -155,11 +136,6 @@ state.shouldShowBadge = true; toggleBadge(); saveState(); - - if (state.terminalWasOpen) { - if (!logsModal) logsModal = createLogsModal(); - showLogsModal(); - } } function toggleBadge(forceHide = false) { @@ -179,14 +155,13 @@ } badge = createBadge(content); - addShowLogsButton(badge); + addLogsButton(badge); shadowRoot.appendChild(badge); saveState(); } function saveState() { localStorage.setItem('componentsHidden', state.isHidden); - localStorage.setItem('terminalWasOpen', state.terminalWasOpen); localStorage.setItem('badgeVisible', state.shouldShowBadge); localStorage.setItem('badgePosition', JSON.stringify(state.badgePosition)); localStorage.setItem('bannerCollapsed', state.isCollapsed); @@ -355,7 +330,7 @@ function updateBadgeCollapsedState(badge, contentWrapper) { const collapseBtn = badge.querySelector('#collapse-btn'); - const buttonContainer = shadowRoot.getElementById(`${LOG_BUTTON_ID}-container`); + const buttonContainer = shadowRoot.getElementById(`${LOGS_BUTTON_ID}-container`); const dragHandle = badge.querySelector('#drag-handle'); if (state.isCollapsed) { @@ -378,7 +353,7 @@ collapseBtn.style.borderRadius = '50%'; } else { contentWrapper.style.display = 'block'; - if (buttonContainer && !state.terminalWasOpen) buttonContainer.style.display = 'block'; + if (buttonContainer) buttonContainer.style.display = 'block'; if (dragHandle) dragHandle.style.display = 'block'; badge.style.width = ''; badge.style.height = ''; @@ -425,19 +400,27 @@ return badge; } - function addShowLogsButton(badge) { + function addLogsButton(badge) { + let environmentUrl; + try { + environmentUrl = new URL(window.LFC_ENVIRONMENT_URL || window.LFC_DASHBOARD_URL || ''); + if (!['https:', 'http:'].includes(environmentUrl.protocol)) return; + } catch { + return; + } + const buttonContainer = document.createElement('div'); - buttonContainer.id = `${LOG_BUTTON_ID}-container`; + buttonContainer.id = `${LOGS_BUTTON_ID}-container`; Object.assign(buttonContainer.style, { padding: '8px 15px 15px 15px', borderTop: '1px solid rgba(255, 255, 255, 0.1)', marginTop: '10px', - display: state.terminalWasOpen || state.isCollapsed ? 'none' : 'block', + display: state.isCollapsed ? 'none' : 'block', }); const btn = document.createElement('button'); - btn.id = LOG_BUTTON_ID; - btn.textContent = 'Show logs'; + btn.id = LOGS_BUTTON_ID; + btn.textContent = 'View logs'; Object.assign(btn.style, { padding: '8px 12px', fontSize: '14px', @@ -454,273 +437,16 @@ btn.addEventListener('mouseout', () => (btn.style.backgroundColor = 'rgba(255, 255, 255, 0.1)')); btn.addEventListener('click', (e) => { e.stopPropagation(); - showLogsModal(); - buttonContainer.style.display = 'none'; + window.open(environmentUrl.toString(), '_blank', 'noopener,noreferrer'); }); buttonContainer.appendChild(btn); badge.appendChild(buttonContainer); } - function createLogsModal() { - const modal = document.createElement('div'); - modal.id = LOG_MODAL_ID; - Object.assign(modal.style, { - position: 'fixed', - left: '0', - right: '0', - bottom: '0', - height: '400px', - backgroundColor: TERMINAL_COLORS.bg, - borderTop: `2px solid ${TERMINAL_COLORS.prompt}`, - zIndex: '2147483647', - overflow: 'hidden', - display: 'none', - fontFamily: '"Fira Code", "Source Code Pro", monospace', - pointerEvents: 'auto', - }); - - const header = document.createElement('div'); - Object.assign(header.style, { - height: '40px', - backgroundColor: '#2D2D2D', - borderBottom: '1px solid #3D3D3D', - display: 'flex', - alignItems: 'center', - padding: '0 10px', - cursor: 'ns-resize', - position: 'relative', - }); - - const controls = document.createElement('div'); - controls.innerHTML = ` - - - - `; - header.appendChild(controls); - - const searchContainer = document.createElement('div'); - Object.assign(searchContainer.style, { - position: 'absolute', - right: '40px', - top: '50%', - transform: 'translateY(-50%)', - display: 'flex', - alignItems: 'center', - zIndex: '1', - }); - - const searchInput = document.createElement('input'); - searchInput.id = SEARCH_INPUT_ID; - Object.assign(searchInput.style, { - backgroundColor: '#3D3D3D', - border: 'none', - color: TERMINAL_COLORS.text, - padding: '5px 10px', - borderRadius: '4px', - width: '200px', - fontSize: '14px', - height: '28px', - }); - searchInput.placeholder = 'Filter logs...'; - searchContainer.appendChild(searchInput); - - const copyBtn = document.createElement('button'); - copyBtn.textContent = 'Copy'; - Object.assign(copyBtn.style, { - backgroundColor: '#3D3D3D', - border: 'none', - color: TERMINAL_COLORS.text, - padding: '5px 10px', - borderRadius: '4px', - marginLeft: '8px', - cursor: 'pointer', - fontSize: '14px', - height: '28px', - }); - - copyBtn.addEventListener('click', async () => { - const contentDiv = shadowRoot.getElementById(LOG_MODAL_CONTENT_ID); - const logLines = Array.from(contentDiv.getElementsByClassName('log-line')) - .filter((line) => line.style.display !== 'none') - .map((line) => line.textContent) - .join('\n'); - - try { - await navigator.clipboard.writeText(logLines); - copyBtn.textContent = 'Copied!'; - setTimeout(() => { - copyBtn.textContent = 'Copy'; - }, 2000); - } catch (err) { - console.error('Failed to copy logs:', err); - copyBtn.textContent = 'Failed'; - setTimeout(() => { - copyBtn.textContent = 'Copy'; - }, 2000); - } - }); - - copyBtn.addEventListener('mouseover', () => (copyBtn.style.backgroundColor = '#4D4D4D')); - copyBtn.addEventListener('mouseout', () => (copyBtn.style.backgroundColor = '#3D3D3D')); - - searchContainer.appendChild(copyBtn); - - const closeBtn = document.createElement('button'); - closeBtn.textContent = '\u2715'; - Object.assign(closeBtn.style, { - position: 'absolute', - right: '10px', - top: '50%', - transform: 'translateY(-50%)', - backgroundColor: 'transparent', - border: 'none', - color: TERMINAL_COLORS.text, - fontSize: '20px', - cursor: 'pointer', - padding: '5px', - lineHeight: '1', - zIndex: '1', - }); - closeBtn.addEventListener('click', hideLogsModal); - - header.appendChild(searchContainer); - header.appendChild(closeBtn); - modal.appendChild(header); - - const contentDiv = document.createElement('div'); - contentDiv.id = LOG_MODAL_CONTENT_ID; - Object.assign(contentDiv.style, { - position: 'absolute', - top: '40px', - bottom: '0', - left: '0', - right: '0', - overflowY: 'auto', - padding: '10px', - backgroundColor: TERMINAL_COLORS.bg, - color: TERMINAL_COLORS.text, - fontSize: '14px', - lineHeight: '1.5', - userSelect: 'text', - }); - modal.appendChild(contentDiv); - - let isResizing = false; - let startY = 0; - let startHeight = 0; - - header.addEventListener('mousedown', (e) => { - if (e.target === searchInput || e.target.closest('button')) return; - isResizing = true; - startY = e.clientY; - startHeight = parseInt(window.getComputedStyle(modal).height, 10); - document.body.style.cursor = 'ns-resize'; - e.preventDefault(); - }); - - document.addEventListener('mousemove', (e) => { - if (!isResizing) return; - const dy = startY - e.clientY; - const newHeight = startHeight + dy; - if (newHeight > 200) modal.style.height = `${newHeight}px`; - }); - - document.addEventListener('mouseup', () => { - if (isResizing) { - isResizing = false; - document.body.style.cursor = ''; - } - }); - - searchInput.addEventListener('input', (e) => { - const searchTerm = e.target.value.toLowerCase(); - Array.from(contentDiv.getElementsByClassName('log-line')).forEach((line) => { - line.style.display = line.textContent.toLowerCase().includes(searchTerm) ? 'block' : 'none'; - }); - }); - - shadowRoot.appendChild(modal); - return modal; - } - - function colorizeLog(logText) { - if (logText.includes('Error:') || logText.includes('[ERROR]')) { - return `${logText}`; - } - if (logText.includes('Warning:') || logText.includes('[WARN]')) { - return `${logText}`; - } - if (logText.includes('Info:') || logText.includes('[INFO]')) { - return `${logText}`; - } - return logText; - } - - function getBaseUrl() { - return window.LFC_BASE_URL || 'PLEASE_UPDATE'; - } - - function showLogsModal() { - if (!logsModal) logsModal = createLogsModal(); - logsModal.style.display = 'block'; - state.terminalWasOpen = true; - saveState(); - - const contentDiv = shadowRoot.getElementById(LOG_MODAL_CONTENT_ID); - contentDiv.innerHTML = ''; - - const url = `${getBaseUrl()}/api/v1/builds/${encodeURIComponent(UUID)}/services/${encodeURIComponent( - SERVICE_NAME - )}/logs`; - evtSource = new EventSource(url); - - evtSource.onmessage = (event) => { - const logLine = document.createElement('div'); - logLine.className = 'log-line'; - logLine.innerHTML = colorizeLog(event.data); - contentDiv.appendChild(logLine); - - const searchTerm = shadowRoot.getElementById(SEARCH_INPUT_ID).value.toLowerCase(); - if (searchTerm) { - logLine.style.display = event.data.toLowerCase().includes(searchTerm) ? 'block' : 'none'; - } - contentDiv.scrollTop = contentDiv.scrollHeight; - }; - - evtSource.onerror = (error) => console.error('SSE error:', error); - } - - function hideLogsModal() { - if (logsModal) { - logsModal.style.display = 'none'; - state.terminalWasOpen = false; - saveState(); - - const buttonContainer = shadowRoot.getElementById(`${LOG_BUTTON_ID}-container`); - if (buttonContainer && !state.isCollapsed) buttonContainer.style.display = 'block'; - } - if (evtSource) { - evtSource.close(); - evtSource = null; - } - } - function hideAll() { - const wasTerminalOpen = logsModal?.style.display === 'block'; - - if (logsModal) { - logsModal.style.display = 'none'; - if (evtSource) { - evtSource.close(); - evtSource = null; - } - } - toggleBadge(true); state.isHidden = true; - state.terminalWasOpen = wasTerminalOpen; saveState(); } @@ -738,14 +464,8 @@ loadState(); initShortcut(); - if (!state.isHidden) { - if (state.shouldShowBadge) { - toggleBadge(); - } - if (state.terminalWasOpen) { - if (!logsModal) logsModal = createLogsModal(); - showLogsModal(); - } + if (!state.isHidden && state.shouldShowBadge) { + toggleBadge(); } setInterval(() => { diff --git a/src/__tests__/pages-api/buildRoutes.test.ts b/src/__tests__/pages-api/buildRoutes.test.ts deleted file mode 100644 index e4092b56..00000000 --- a/src/__tests__/pages-api/buildRoutes.test.ts +++ /dev/null @@ -1,597 +0,0 @@ -import { request, response } from 'src/test-utils/pagesApi'; - -type Query = Record & PromiseLike; - -function queryResult(value: T): Query { - const query = {} as Query; - for (const method of [ - 'select', - 'where', - 'whereNotIn', - 'whereIn', - 'findById', - 'findOne', - 'withGraphFetched', - 'offset', - 'limit', - 'orderBy', - 'patch', - ]) { - query[method] = jest.fn(() => query); - } - query.resultSize = jest.fn().mockResolvedValue(Array.isArray(value) ? value.length : 0); - query.first = jest.fn().mockResolvedValue(value); - query.then = jest.fn((resolve, reject) => Promise.resolve(value).then(resolve, reject)); - return query; -} - -const mockBuildQuery = jest.fn(); -const mockDeployQuery = jest.fn(); -const mockGithubBuildQuery = jest.fn(); -const mockWebhookBuildQuery = jest.fn(); -const mockInvocationQuery = jest.fn(); -const mockEnqueueBuild = jest.fn(); -const mockValidateUuid = jest.fn(); -const mockUpdateBuildUuid = jest.fn(); -const mockValidateUuidFormat = jest.fn(); -const mockGenerateGraph = jest.fn(); -const mockWebhookQueueAdd = jest.fn(); -const mockNanoid = jest.fn(() => 'fixed-id'); -const mockWithLogContext = jest.fn((_context: unknown, callback: () => unknown) => callback()); -const mockLogger = { - debug: jest.fn(), - info: jest.fn(), - error: jest.fn(), -}; - -const mockBuildService = { - db: { - models: { - Build: { query: (...args: unknown[]) => mockBuildQuery(...args) }, - Deploy: { query: (...args: unknown[]) => mockDeployQuery(...args) }, - }, - }, - enqueueResolveAndDeployBuild: (...args: unknown[]) => mockEnqueueBuild(...args), -}; - -const mockOverrideService = { - db: { models: { Build: { query: (...args: unknown[]) => mockBuildQuery(...args) } } }, - validateUuid: (...args: unknown[]) => mockValidateUuid(...args), - updateBuildUuid: (...args: unknown[]) => mockUpdateBuildUuid(...args), -}; - -const mockGithubService = { - db: { models: { Build: { query: (...args: unknown[]) => mockGithubBuildQuery(...args) } } }, -}; - -const mockWebhookService = { - db: { - models: { - Build: { query: (...args: unknown[]) => mockWebhookBuildQuery(...args) }, - WebhookInvocations: { query: (...args: unknown[]) => mockInvocationQuery(...args) }, - }, - }, - webhookQueue: { add: (...args: unknown[]) => mockWebhookQueueAdd(...args) }, -}; - -jest.mock('server/services/build', () => ({ - __esModule: true, - default: jest.fn(() => mockBuildService), -})); - -jest.mock('server/services/override', () => ({ - __esModule: true, - default: jest.fn(() => mockOverrideService), - BuildUuidValidationError: class BuildUuidValidationError extends Error {}, -})); - -jest.mock('server/services/github', () => ({ - __esModule: true, - default: jest.fn(() => mockGithubService), -})); - -jest.mock('server/services/webhook', () => ({ - __esModule: true, - default: jest.fn(() => mockWebhookService), -})); - -jest.mock('server/lib/validation/buildUuidValidator', () => ({ - validateBuildUuidFormat: (...args: unknown[]) => mockValidateUuidFormat(...args), -})); - -jest.mock('server/lib/dependencyGraph', () => ({ - generateGraph: (...args: unknown[]) => mockGenerateGraph(...args), -})); - -jest.mock('server/lib/logger', () => ({ - withLogContext: (...args: unknown[]) => mockWithLogContext(...(args as [unknown, () => unknown])), - getLogger: () => mockLogger, - LogStage: { - BUILD_QUEUED: 'build-queued', - BUILD_FAILED: 'build-failed', - WEBHOOK_PROCESSING: 'webhook-processing', - }, -})); - -jest.mock('nanoid', () => ({ nanoid: () => mockNanoid() })); - -import buildsHandler from 'src/pages/api/v1/builds'; -import deployHandler from 'src/pages/api/v1/builds/[uuid]/deploy'; -import graphHandler from 'src/pages/api/v1/builds/[uuid]/graph'; -import buildHandler from 'src/pages/api/v1/builds/[uuid]'; -import serviceBuildHandler from 'src/pages/api/v1/builds/[uuid]/services/[name]/build'; -import torndownHandler from 'src/pages/api/v1/builds/[uuid]/torndown'; -import webhooksHandler from 'src/pages/api/v1/builds/[uuid]/webhooks'; -import { BuildUuidValidationError } from 'server/services/override'; - -describe('legacy build action routes', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockWithLogContext.mockImplementation((_context: unknown, callback: () => unknown) => callback()); - mockValidateUuidFormat.mockReturnValue(undefined); - mockValidateUuid.mockResolvedValue({ valid: true }); - mockUpdateBuildUuid.mockResolvedValue({ build: { id: 1, uuid: 'new-uuid' } }); - mockEnqueueBuild.mockResolvedValue(undefined); - mockGenerateGraph.mockResolvedValue({ nodes: ['api'] }); - mockWebhookQueueAdd.mockResolvedValue({ id: 'job-1' }); - }); - - describe('GET /builds', () => { - it('rejects unsupported methods before querying', async () => { - const res = response(); - await buildsHandler(request({ method: 'POST' }), res); - expect(res.statusCode).toBe(405); - expect(mockBuildQuery).not.toHaveBeenCalled(); - }); - - it.each([ - [{}, ['torn_down', 'pending']], - [{ exclude: '["failed",7]' }, ['failed']], - [{ exclude: 'ready' }, ['ready']], - [{ exclude: '"ready"' }, ['torn_down', 'pending']], - ])('normalizes exclusions and returns unpaginated results: %j', async (params, excluded) => { - const builds = [{ uuid: 'build-1', status: 'ready' }]; - const query = queryResult(builds); - mockBuildQuery.mockReturnValue(query); - const res = response(); - await buildsHandler(request({ query: params }), res); - expect(query.whereNotIn).toHaveBeenCalledWith('status', excluded); - expect(res.body).toEqual({ - builds, - metadata: { currentPage: 1, totalPages: 1, total: 1, limit: 1 }, - }); - }); - - it.each([ - [{ page: '2', limit: '5' }, { currentPage: 2, totalPages: 3, total: 12, limit: 5 }, 5], - [{ page: 'bad', limit: '0' }, { currentPage: 1, totalPages: 1, total: 12, limit: 20 }, 0], - ])('paginates using current defaults and lower bounds: %j', async (params, metadata, offset) => { - const builds = [{ uuid: 'build-1', status: 'ready' }]; - const query = queryResult(builds); - query.resultSize.mockResolvedValue(12); - mockBuildQuery.mockReturnValue(query); - const res = response(); - await buildsHandler(request({ query: params }), res); - expect(query.offset).toHaveBeenCalledWith(offset); - expect(query.limit).toHaveBeenCalledWith(metadata.limit); - expect(res.body).toEqual({ builds, metadata }); - }); - - it('returns the stable 500 response on query failure', async () => { - mockBuildQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await buildsHandler(request(), res); - expect(res.statusCode).toBe(500); - }); - }); - - describe('/builds/:uuid', () => { - it.each([{}, { uuid: ['build-1'] }])('rejects an invalid path UUID: %j', async (query) => { - const res = response(); - await buildHandler(request({ query }), res); - expect(res.statusCode).toBe(400); - expect(mockBuildQuery).not.toHaveBeenCalled(); - }); - - it('rejects unsupported methods for a valid UUID', async () => { - const res = response(); - await buildHandler(request({ method: 'DELETE', query: { uuid: 'build-1' } }), res); - expect(res.statusCode).toBe(405); - }); - - it('returns a selected build or 404 when absent', async () => { - const build = { id: 1, uuid: 'build-1', status: 'ready' }; - const foundQuery = queryResult(build); - mockBuildQuery.mockReturnValueOnce(foundQuery).mockReturnValueOnce(queryResult(undefined)); - - const found = response(); - await buildHandler(request({ query: { uuid: 'build-1' } }), found); - expect(foundQuery.findOne).toHaveBeenCalledWith({ uuid: 'build-1' }); - expect(found.body).toEqual(build); - - const missing = response(); - await buildHandler(request({ query: { uuid: 'missing' } }), missing); - expect(missing.statusCode).toBe(404); - }); - - it('maps GET query failures to the stable 500 response', async () => { - mockBuildQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await buildHandler(request({ query: { uuid: 'build-1' } }), res); - expect(res.statusCode).toBe(500); - }); - - it.each([ - [{}, { error: 'uuid is required' }], - [{ uuid: null }, { error: 'uuid is required' }], - [{ uuid: 7 }, { error: 'uuid must be a string' }], - ])('validates PATCH body before creating OverrideService: %j', async (body, expectedBody) => { - const res = response(); - await buildHandler(request({ method: 'PATCH', query: { uuid: 'build-1' }, body }), res); - expect(res.statusCode).toBe(400); - expect(res.body).toEqual(expectedBody); - expect(mockBuildQuery).not.toHaveBeenCalled(); - }); - - it('returns the UUID format validator message', async () => { - mockValidateUuidFormat.mockReturnValueOnce('UUID format is invalid'); - const res = response(); - await buildHandler(request({ method: 'PATCH', query: { uuid: 'build-1' }, body: { uuid: 'INVALID' } }), res); - expect(res.statusCode).toBe(400); - expect(res.body).toEqual({ error: 'UUID format is invalid' }); - expect(mockBuildQuery).not.toHaveBeenCalled(); - }); - - it('returns 404 when the build to update is absent', async () => { - mockBuildQuery.mockReturnValue(queryResult(undefined)); - const res = response(); - await buildHandler(request({ method: 'PATCH', query: { uuid: 'build-1' }, body: { uuid: 'new-uuid' } }), res); - expect(res.statusCode).toBe(404); - expect(mockValidateUuid).not.toHaveBeenCalled(); - }); - - it('rejects the existing UUID and service-level validation failures', async () => { - mockBuildQuery - .mockReturnValueOnce(queryResult({ id: 1, uuid: 'same-uuid' })) - .mockReturnValueOnce(queryResult({ id: 1, uuid: 'old-uuid' })); - const same = response(); - await buildHandler(request({ method: 'PATCH', query: { uuid: 'same-uuid' }, body: { uuid: 'same-uuid' } }), same); - expect(same.body).toEqual({ error: 'UUID must be different' }); - - mockValidateUuid.mockResolvedValueOnce({ valid: false, error: 'UUID is unavailable' }); - const invalid = response(); - await buildHandler( - request({ method: 'PATCH', query: { uuid: 'old-uuid' }, body: { uuid: 'new-uuid' } }), - invalid - ); - expect(invalid.body).toEqual({ error: 'UUID is unavailable' }); - expect(mockUpdateBuildUuid).not.toHaveBeenCalled(); - }); - - it.each([ - [{ id: 1, uuid: 'old-uuid', pullRequest: undefined }, false], - [{ id: 1, uuid: 'old-uuid', pullRequest: { deployOnUpdate: false } }, false], - [{ id: 1, uuid: 'old-uuid', pullRequest: { deployOnUpdate: true } }, true], - ])('updates UUID and conditionally redeploys: %j', async (build, redeploys) => { - mockBuildQuery.mockReturnValue(queryResult(build)); - mockUpdateBuildUuid.mockResolvedValueOnce({ build: { id: 1, uuid: 'new-uuid', status: 'ready' } }); - const res = response(); - await buildHandler(request({ method: 'PATCH', query: { uuid: 'old-uuid' }, body: { uuid: 'new-uuid' } }), res); - expect(mockValidateUuid).toHaveBeenCalledWith('new-uuid', 1); - expect(mockUpdateBuildUuid).toHaveBeenCalledWith(build, 'new-uuid'); - if (redeploys) expect(mockEnqueueBuild).toHaveBeenCalledWith({ buildId: 1, runUUID: 'fixed-id' }); - else expect(mockEnqueueBuild).not.toHaveBeenCalled(); - expect(res.body).toEqual({ data: { id: 1, uuid: 'new-uuid', status: 'ready' } }); - }); - - it.each([ - [new BuildUuidValidationError('conflicting UUID'), 400, { error: 'conflicting UUID' }], - [new Error('db unavailable'), 500, { error: 'An unexpected error occurred' }], - ])('maps update errors by domain: %s', async (error, status, body) => { - mockBuildQuery.mockReturnValue(queryResult({ id: 1, uuid: 'old-uuid' })); - mockValidateUuid.mockRejectedValueOnce(error); - const res = response(); - await buildHandler(request({ method: 'PATCH', query: { uuid: 'old-uuid' }, body: { uuid: 'new-uuid' } }), res); - expect(res.statusCode).toBe(status); - expect(res.body).toEqual(body); - }); - }); - - describe('POST /builds/:uuid/deploy', () => { - it('rejects unsupported methods', async () => { - const res = response(); - await deployHandler(request({ query: { uuid: 'build-1' } }), res); - expect(res.statusCode).toBe(405); - }); - - it('returns 404 for a missing build without enqueuing', async () => { - mockBuildQuery.mockReturnValue(queryResult(undefined)); - const res = response(); - await deployHandler(request({ method: 'POST', query: { uuid: 'missing' } }), res); - expect(res.statusCode).toBe(404); - expect(mockEnqueueBuild).not.toHaveBeenCalled(); - }); - - it('queues a full build redeploy', async () => { - mockBuildQuery.mockReturnValue(queryResult({ id: 42, uuid: 'build-1' })); - const res = response(); - await deployHandler(request({ method: 'POST', query: { uuid: 'build-1' } }), res); - expect(mockEnqueueBuild).toHaveBeenCalledWith({ buildId: 42, runUUID: 'fixed-id' }); - expect(res.body).toEqual({ - status: 'success', - message: 'Redeploy for build build-1 has been queued', - }); - }); - - it('maps lookup/enqueue failures to the redeploy-specific 500 response', async () => { - mockBuildQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await deployHandler(request({ method: 'POST', query: { uuid: 'build-1' } }), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: 'Unable to proceed with redeploy for build build-1.' }); - }); - }); - - describe('GET /builds/:uuid/graph', () => { - it('rejects unsupported methods', async () => { - const res = response(); - await graphHandler(request({ method: 'POST', query: { uuid: 'build-1' } }), res); - expect(res.statusCode).toBe(405); - }); - - it.each([ - [{ nodes: ['existing'] }, false], - [{}, true], - ])('returns the stored graph and only generates an empty graph: %j', async (dependencyGraph, generates) => { - const patchAndFetch = jest.fn().mockImplementation(async (patch) => { - build.dependencyGraph = patch.dependencyGraph; - return build; - }); - const build = { - id: 1, - dependencyGraph, - $query: () => ({ patchAndFetch }), - }; - mockBuildQuery.mockReturnValue(queryResult(build)); - const res = response(); - await graphHandler(request({ query: { uuid: 'build-1' } }), res); - if (generates) { - expect(mockGenerateGraph).toHaveBeenCalledWith(build, 'TB'); - expect(patchAndFetch).toHaveBeenCalledWith({ dependencyGraph: { nodes: ['api'] } }); - expect(res.body).toEqual(expect.objectContaining({ dependencyGraph: { nodes: ['api'] } })); - } else { - expect(mockGenerateGraph).not.toHaveBeenCalled(); - expect(patchAndFetch).not.toHaveBeenCalled(); - expect(res.body).toEqual(expect.objectContaining({ dependencyGraph })); - } - }); - - it('maps lookup failures to the stable 500 response', async () => { - mockBuildQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await graphHandler(request({ query: { uuid: 'build-1' } }), res); - expect(res.statusCode).toBe(500); - }); - }); - - describe('POST /builds/:uuid/services/:name/build', () => { - it('rejects unsupported methods', async () => { - const res = response(); - await serviceBuildHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.statusCode).toBe(405); - }); - - it('returns 404 for a missing build or service and does not enqueue', async () => { - mockBuildQuery - .mockReturnValueOnce(queryResult(undefined)) - .mockReturnValueOnce(queryResult({ id: 1, deploys: undefined })) - .mockReturnValueOnce(queryResult({ id: 1, deploys: [{ deployable: undefined }] })); - for (const uuid of ['missing', 'no-deploys', 'no-deployable']) { - const res = response(); - await serviceBuildHandler(request({ method: 'POST', query: { uuid, name: 'api' } }), res); - expect(res.statusCode).toBe(404); - } - expect(mockEnqueueBuild).not.toHaveBeenCalled(); - }); - - it.each([ - [{ resolvedFromRepositoryId: 11, repositoryId: 12 }, 11], - [{ resolvedFromRepositoryId: null, repositoryId: 12 }, 12], - [{ resolvedFromRepositoryId: null, repositoryId: null }, 13], - ])('queues a service redeploy using repository precedence: %j', async (deployable, repositoryId) => { - const build = { - id: 42, - deploys: [{ deployable: { name: 'api', ...deployable }, githubRepositoryId: 13 }], - }; - mockBuildQuery.mockReturnValue(queryResult(build)); - const res = response(); - await serviceBuildHandler(request({ method: 'POST', query: { uuid: 'build-1', name: 'api' } }), res); - expect(mockEnqueueBuild).toHaveBeenCalledWith({ - buildId: 42, - githubRepositoryId: repositoryId, - runUUID: 'fixed-id', - }); - expect(res.statusCode).toBe(200); - }); - - it('returns 500 when repository identity is absent or enqueueing fails', async () => { - const deployable = { name: 'api', resolvedFromRepositoryId: null, repositoryId: null }; - mockBuildQuery - .mockReturnValueOnce(queryResult({ id: 1, deploys: [{ deployable, githubRepositoryId: null }] })) - .mockReturnValueOnce(queryResult({ id: 1, deploys: [{ deployable: { name: 'api', repositoryId: 1 } }] })); - const missingRepo = response(); - await serviceBuildHandler(request({ method: 'POST', query: { uuid: 'build-1', name: 'api' } }), missingRepo); - expect(missingRepo.statusCode).toBe(500); - - mockEnqueueBuild.mockRejectedValueOnce(new Error('queue unavailable')); - const queueFailure = response(); - await serviceBuildHandler(request({ method: 'POST', query: { uuid: 'build-1', name: 'api' } }), queueFailure); - expect(queueFailure.statusCode).toBe(500); - }); - }); - - describe('PATCH /builds/:uuid/torndown', () => { - it('validates method and UUID before entering log context', async () => { - const method = response(); - await torndownHandler(request({ query: { uuid: 'build-1' } }), method); - expect(method.statusCode).toBe(405); - - const missing = response(); - await torndownHandler(request({ method: 'PATCH', query: {} }), missing); - expect(missing.statusCode).toBe(500); - expect(mockWithLogContext).not.toHaveBeenCalled(); - }); - - it('rejects static builds without mutating deploy status', async () => { - mockBuildQuery.mockReturnValue(queryResult({ id: 1, isStatic: true, deploys: [] })); - const res = response(); - await torndownHandler(request({ method: 'PATCH', query: { uuid: 'static' } }), res); - expect(res.statusCode).toBe(404); - expect(mockDeployQuery).not.toHaveBeenCalled(); - }); - - it('locks the current missing-build behavior: dereference is caught as a 500', async () => { - mockBuildQuery.mockReturnValue(queryResult(undefined)); - const res = response(); - await torndownHandler(request({ method: 'PATCH', query: { uuid: 'missing' } }), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: 'An unexpected error occurred.' }); - }); - - it('marks the build and its deploys torn down, then returns updated deploys', async () => { - const build = { id: 1, isStatic: false, deploys: [{ id: 7 }, { id: 8 }] }; - const initialBuildQuery = queryResult(build); - const patchBuildQuery = queryResult(undefined); - const deployPatchQuery = queryResult(undefined); - const updatedDeploys = [{ id: 7, uuid: 'api-build-1', status: 'torn_down' }]; - const deploySelectQuery = queryResult(updatedDeploys); - mockBuildQuery.mockReturnValueOnce(initialBuildQuery).mockReturnValueOnce(patchBuildQuery); - mockDeployQuery.mockReturnValueOnce(deployPatchQuery).mockReturnValueOnce(deploySelectQuery); - const res = response(); - await torndownHandler(request({ method: 'PATCH', query: { uuid: 'build-1' } }), res); - expect(patchBuildQuery.findById).toHaveBeenCalledWith(1); - expect(patchBuildQuery.patch).toHaveBeenCalledWith({ - status: 'torn_down', - statusMessage: 'Namespace was deleted successfully', - }); - expect(deployPatchQuery.whereIn).toHaveBeenCalledWith('id', [7, 8]); - expect(res.body).toEqual({ - status: 'The namespace env-build-1 it was delete sucessfuly', - namespacesUpdated: updatedDeploys, - }); - }); - - it('maps database failures to a stable 500 response', async () => { - mockBuildQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await torndownHandler(request({ method: 'PATCH', query: { uuid: 'build-1' } }), res); - expect(res.statusCode).toBe(500); - }); - }); - - describe('/builds/:uuid/webhooks', () => { - it.each([{}, { uuid: ['build-1'] }])('rejects invalid UUIDs: %j', async (query) => { - const res = response(); - await webhooksHandler(request({ query }), res); - expect(res.statusCode).toBe(400); - }); - - it('advertises allowed methods', async () => { - const res = response(); - await webhooksHandler(request({ method: 'DELETE', query: { uuid: 'build-1' } }), res); - expect(res.setHeader).toHaveBeenCalledWith('Allow', ['GET', 'POST']); - expect(res.statusCode).toBe(405); - }); - - it('maps synchronous routing failures to the outer stable error', async () => { - const res = response(); - (res.setHeader as jest.Mock).mockImplementationOnce(() => { - throw new Error('response unavailable'); - }); - await webhooksHandler(request({ method: 'DELETE', query: { uuid: 'build-1' } }), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: 'An unexpected error occurred.' }); - }); - - it.each([ - [{}, { currentPage: 1, totalPages: 1, total: 12, limit: 100 }, 0], - [{ page: '2', limit: '5' }, { currentPage: 2, totalPages: 3, total: 12, limit: 5 }, 5], - [{ page: 'bad', limit: '0' }, { currentPage: 1, totalPages: 2, total: 12, limit: 10 }, 0], - ])('retrieves a page using the current query-builder build identifier: %j', async (params, metadata, offset) => { - const buildIdQuery = queryResult({ id: 42 }); - const countQuery = queryResult(undefined); - countQuery.resultSize.mockResolvedValue(12); - const webhooks = [{ id: 1, buildId: 42 }]; - const listQuery = queryResult(webhooks); - mockWebhookBuildQuery.mockReturnValue(buildIdQuery); - mockInvocationQuery.mockReturnValueOnce(countQuery).mockReturnValueOnce(listQuery); - const res = response(); - await webhooksHandler(request({ query: { uuid: 'build-1', ...params } }), res); - const buildIdPromise = buildIdQuery.first.mock.results[0].value; - expect(countQuery.where).toHaveBeenCalledWith('buildId', buildIdPromise); - expect(listQuery.offset).toHaveBeenCalledWith(offset); - expect(res.body).toEqual({ webhooks, metadata }); - }); - - it('maps webhook history query failures to the route-specific 500 response', async () => { - mockWebhookBuildQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await webhooksHandler(request({ query: { uuid: 'build-1' } }), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: 'Unable to retrieve webhooks for build build-1.' }); - }); - - it('locks the current missing-build POST behavior: id dereference produces a 500', async () => { - mockGithubBuildQuery.mockReturnValue(queryResult(undefined)); - const res = response(); - await webhooksHandler(request({ method: 'POST', query: { uuid: 'missing' } }), res); - expect(res.statusCode).toBe(500); - expect(mockWebhookQueueAdd).not.toHaveBeenCalled(); - }); - - it('returns 204 when the build has no webhook configuration', async () => { - mockGithubBuildQuery.mockReturnValue(queryResult({ id: 42, webhooksYaml: null })); - const res = response(); - await webhooksHandler(request({ method: 'POST', query: { uuid: 'build-1' } }), res); - expect(res.statusCode).toBe(204); - expect(mockWebhookQueueAdd).not.toHaveBeenCalled(); - }); - - it('queues configured webhooks with the generated correlation ID', async () => { - mockGithubBuildQuery.mockReturnValue(queryResult({ id: 42, webhooksYaml: { webhooks: [] } })); - const res = response(); - await webhooksHandler(request({ method: 'POST', query: { uuid: 'build-1' } }), res); - expect(mockWebhookQueueAdd).toHaveBeenCalledWith('webhook', { - buildId: 42, - correlationId: expect.stringMatching(/^api-webhook-invoke-\d+-fixed-id$/), - }); - expect(res.body).toEqual({ - status: 'success', - message: 'Webhook for build build-1 has been queued', - }); - }); - - it('maps queue failures to the invoke-specific 500 response', async () => { - mockGithubBuildQuery.mockReturnValue(queryResult({ id: 42, webhooksYaml: {} })); - mockWebhookQueueAdd.mockRejectedValueOnce(new Error('queue unavailable')); - const res = response(); - await webhooksHandler(request({ method: 'POST', query: { uuid: 'build-1' } }), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ - error: 'Unable to proceed with triggering webhook for build build-1.', - }); - }); - }); -}); diff --git a/src/__tests__/pages-api/kubernetesRoutes.test.ts b/src/__tests__/pages-api/kubernetesRoutes.test.ts deleted file mode 100644 index 0ad02531..00000000 --- a/src/__tests__/pages-api/kubernetesRoutes.test.ts +++ /dev/null @@ -1,443 +0,0 @@ -import { gzipSync } from 'zlib'; -import { request, response } from 'src/test-utils/pagesApi'; - -type Query = Record & PromiseLike; - -function queryResult(value: T): Query { - const query = {} as Query; - for (const method of ['where', 'withGraphFetched']) query[method] = jest.fn(() => query); - query.first = jest.fn().mockResolvedValue(value); - query.then = jest.fn((resolve, reject) => Promise.resolve(value).then(resolve, reject)); - return query; -} - -const mockLoadKubeConfig = jest.fn(); -const mockMakeApiClient = jest.fn(); -const mockListEvents = jest.fn(); -const mockReadSecret = jest.fn(); -const mockListConfigMaps = jest.fn(); -const mockGetNativeBuildJobs = jest.fn(); -const mockGetDeploymentJobs = jest.fn(); -const mockDeployQuery = jest.fn(); -const mockWithLogContext = jest.fn((_context: unknown, callback: () => unknown) => callback()); -const mockLogger = { - debug: jest.fn(), - warn: jest.fn(), - error: jest.fn(), -}; - -const mockCoreApi = { - listNamespacedEvent: (...args: unknown[]) => mockListEvents(...args), - readNamespacedSecret: (...args: unknown[]) => mockReadSecret(...args), - listNamespacedConfigMap: (...args: unknown[]) => mockListConfigMaps(...args), -}; - -jest.mock('@kubernetes/client-node', () => ({ - KubeConfig: jest.fn(() => ({ - loadFromDefault: (...args: unknown[]) => mockLoadKubeConfig(...args), - makeApiClient: (...args: unknown[]) => mockMakeApiClient(...args), - })), - CoreV1Api: class CoreV1Api {}, - HttpError: class HttpError extends Error { - response: { statusCode: number }; - - constructor(response: { statusCode: number }, _body: unknown, statusCode = response.statusCode) { - super(`Kubernetes ${statusCode}`); - this.response = { statusCode }; - } - }, -})); - -jest.mock('server/lib/kubernetes/getNativeBuildJobs', () => ({ - getNativeBuildJobs: (...args: unknown[]) => mockGetNativeBuildJobs(...args), -})); - -jest.mock('server/lib/kubernetes/getDeploymentJobs', () => ({ - getDeploymentJobs: (...args: unknown[]) => mockGetDeploymentJobs(...args), -})); - -jest.mock('server/models', () => ({ - Deploy: { query: (...args: unknown[]) => mockDeployQuery(...args) }, -})); - -jest.mock('server/lib/logger', () => ({ - withLogContext: (...args: unknown[]) => mockWithLogContext(...(args as [unknown, () => unknown])), - getLogger: () => mockLogger, -})); - -let eventsHandler: typeof import('src/pages/api/v1/builds/[uuid]/jobs/[jobName]/events').default; -let buildLogsHandler: typeof import('src/pages/api/v1/builds/[uuid]/services/[name]/buildLogs').default; -let deployLogsHandler: typeof import('src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs').default; -let deploymentHandler: typeof import('src/pages/api/v1/builds/[uuid]/services/[name]/deployment').default; -let HttpError: typeof import('@kubernetes/client-node').HttpError; - -const kubernetesError = (statusCode: number) => - new HttpError({ statusCode } as import('http').IncomingMessage, undefined, statusCode); - -beforeAll(() => { - mockMakeApiClient.mockReturnValue(mockCoreApi); - HttpError = require('@kubernetes/client-node').HttpError; - eventsHandler = require('src/pages/api/v1/builds/[uuid]/jobs/[jobName]/events').default; - buildLogsHandler = require('src/pages/api/v1/builds/[uuid]/services/[name]/buildLogs').default; - deployLogsHandler = require('src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs').default; - deploymentHandler = require('src/pages/api/v1/builds/[uuid]/services/[name]/deployment').default; -}); - -describe('legacy Kubernetes-backed build routes', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockMakeApiClient.mockReturnValue(mockCoreApi); - mockWithLogContext.mockImplementation((_context: unknown, callback: () => unknown) => callback()); - mockListEvents.mockResolvedValue({ body: { items: [] } }); - mockGetNativeBuildJobs.mockResolvedValue([]); - mockGetDeploymentJobs.mockResolvedValue([]); - mockReadSecret.mockResolvedValue({ body: { data: {} } }); - mockListConfigMaps.mockResolvedValue({ body: { items: [] } }); - mockDeployQuery.mockReturnValue(queryResult(undefined)); - }); - - describe('GET /builds/:uuid/jobs/:jobName/events', () => { - it('rejects unsupported methods and advertises GET', async () => { - const res = response(); - await eventsHandler(request({ method: 'POST', query: { uuid: 'build-1', jobName: 'job-1' } }), res); - expect(res.setHeader).toHaveBeenCalledWith('Allow', ['GET']); - expect(res.statusCode).toBe(405); - expect(mockListEvents).not.toHaveBeenCalled(); - }); - - it.each([ - [{ jobName: 'job-1' }], - [{ uuid: 'build-1' }], - [{ uuid: ['build-1'], jobName: 'job-1' }], - [{ uuid: 'build-1', jobName: ['job-1'] }], - ])('requires string UUID and job name: %j', async (query) => { - const res = response(); - await eventsHandler(request({ query }), res); - expect(res.statusCode).toBe(400); - expect(mockListEvents).not.toHaveBeenCalled(); - }); - - it('filters job/pod events, normalizes missing fields, and sorts newest first', async () => { - mockListEvents.mockResolvedValueOnce({ - body: { - items: [ - { involvedObject: undefined }, - { involvedObject: { kind: 'Service', name: 'job-1' } }, - { involvedObject: { kind: 'Job', name: 'other-job' } }, - { involvedObject: { kind: 'Pod', name: undefined } }, - { - involvedObject: { kind: 'Job', name: 'job-1' }, - metadata: { name: 'created', namespace: 'env-build-1' }, - reason: 'Created', - message: 'Job created', - type: 'Normal', - count: 2, - lastTimestamp: '2026-01-01T00:00:00.000Z', - source: { component: 'job-controller', host: 'node-1' }, - }, - { - involvedObject: { kind: 'Pod', name: 'job-1-abc' }, - metadata: {}, - eventTime: '2026-01-02T00:00:00.000Z', - }, - { - involvedObject: { kind: 'Pod', name: 'job-1-old' }, - }, - ], - }, - }); - const res = response(); - await eventsHandler(request({ query: { uuid: 'build-1', jobName: 'job-1' } }), res); - expect(mockListEvents).toHaveBeenCalledWith('env-build-1'); - expect(res.body).toEqual({ - events: [ - expect.objectContaining({ name: '', type: 'Normal', count: 1, eventTime: '2026-01-02T00:00:00.000Z' }), - expect.objectContaining({ name: 'created', reason: 'Created', count: 2 }), - expect.objectContaining({ name: '', namespace: '', reason: '', message: '', type: 'Normal', count: 1 }), - ], - }); - expect((res.body as { events: Array<{ source?: unknown }> }).events[0].source).toBeUndefined(); - expect((res.body as { events: Array<{ source?: unknown }> }).events[1].source).toEqual({ - component: 'job-controller', - host: 'node-1', - }); - }); - - it('treats an absent Kubernetes item list as empty', async () => { - mockListEvents.mockResolvedValueOnce({ body: { items: undefined } }); - const res = response(); - await eventsHandler(request({ query: { uuid: 'build-1', jobName: 'job-1' } }), res); - expect(res.body).toEqual({ events: [] }); - }); - - it.each([ - [ - [ - { involvedObject: { kind: 'Pod', name: 'job-1-event' }, eventTime: '2026-01-02T00:00:00.000Z' }, - { involvedObject: { kind: 'Pod', name: 'job-1-last' }, lastTimestamp: '2026-01-01T00:00:00.000Z' }, - ], - ], - [ - [ - { involvedObject: { kind: 'Pod', name: 'job-1-undated' } }, - { involvedObject: { kind: 'Pod', name: 'job-1-last' }, lastTimestamp: '2026-01-01T00:00:00.000Z' }, - ], - ], - ])('sorts correctly when the comparator right side uses fallback timestamps', async (items) => { - mockListEvents.mockResolvedValueOnce({ body: { items } }); - const res = response(); - await eventsHandler(request({ query: { uuid: 'build-1', jobName: 'job-1' } }), res); - expect((res.body as { events: unknown[] }).events).toHaveLength(2); - }); - - it.each([ - [() => kubernetesError(404), 404, { error: 'Environment or job not found.' }], - [() => kubernetesError(403), 502, { error: 'Failed to communicate with Kubernetes.' }], - [ - () => { - const error = kubernetesError(403); - delete (error as unknown as { response?: unknown }).response; - return error; - }, - 502, - { error: 'Failed to communicate with Kubernetes.' }, - ], - [() => new Error('unexpected'), 500, { error: 'Internal server error occurred.' }], - ])('maps Kubernetes and internal failures', async (makeError, status, body) => { - mockListEvents.mockRejectedValueOnce(makeError()); - const res = response(); - await eventsHandler(request({ query: { uuid: 'build-1', jobName: 'job-1' } }), res); - expect(res.statusCode).toBe(status); - expect(res.body).toEqual(body); - }); - }); - - describe.each([ - ['build logs', () => buildLogsHandler, mockGetNativeBuildJobs, 'builds'], - ['deploy logs', () => deployLogsHandler, mockGetDeploymentJobs, 'deployments'], - ])('GET service %s list', (_name, getHandler, fetchJobs, responseKey) => { - it('rejects unsupported methods and advertises GET', async () => { - const res = response(); - await getHandler()(request({ method: 'POST', query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.setHeader).toHaveBeenCalledWith('Allow', ['GET']); - expect(res.statusCode).toBe(405); - expect(fetchJobs).not.toHaveBeenCalled(); - }); - - it.each([ - [{ name: 'api' }], - [{ uuid: 'build-1' }], - [{ uuid: ['build-1'], name: 'api' }], - [{ uuid: 'build-1', name: ['api'] }], - ])('requires string UUID and service name: %j', async (query) => { - const res = response(); - await getHandler()(request({ query }), res); - expect(res.statusCode).toBe(400); - expect(fetchJobs).not.toHaveBeenCalled(); - }); - - it('returns jobs for the service namespace', async () => { - const jobs = [{ name: 'job-1', status: 'Complete' }]; - fetchJobs.mockResolvedValueOnce(jobs); - const res = response(); - await getHandler()(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(fetchJobs).toHaveBeenCalledWith('api', 'env-build-1'); - expect(res.body).toEqual({ [responseKey]: jobs }); - }); - - it.each([ - [() => kubernetesError(404), 404, { error: 'Environment or service not found.' }], - [() => kubernetesError(403), 502, { error: 'Failed to communicate with Kubernetes.' }], - [ - () => { - const error = kubernetesError(403); - delete (error as unknown as { response?: unknown }).response; - return error; - }, - 502, - { error: 'Failed to communicate with Kubernetes.' }, - ], - [() => new Error('unexpected'), 500, { error: 'Internal server error occurred.' }], - ])('maps Kubernetes and internal failures', async (makeError, status, body) => { - fetchJobs.mockRejectedValueOnce(makeError()); - const res = response(); - await getHandler()(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.statusCode).toBe(status); - expect(res.body).toEqual(body); - }); - }); - - describe('GET /builds/:uuid/services/:name/deployment', () => { - const jsonRelease = { - name: 'api-build-1', - chart: { metadata: { name: 'api-chart', version: '1.2.3' } }, - config: { replicas: 2 }, - manifest: 'kind: Deployment', - }; - - it('rejects methods and malformed params before querying Kubernetes', async () => { - const method = response(); - await deploymentHandler(request({ method: 'POST', query: { uuid: 'build-1', name: 'api' } }), method); - expect(method.statusCode).toBe(405); - - for (const query of [{ name: 'api' }, { uuid: 'build-1' }, { uuid: ['build-1'], name: 'api' }]) { - const res = response(); - await deploymentHandler(request({ query }), res); - expect(res.statusCode).toBe(400); - } - expect(mockReadSecret).not.toHaveBeenCalled(); - }); - - it('returns a double-base64 gzip Helm release', async () => { - const encoded = Buffer.from(gzipSync(Buffer.from(JSON.stringify(jsonRelease))).toString('base64')).toString( - 'base64' - ); - mockReadSecret.mockResolvedValueOnce({ body: { data: { release: encoded } } }); - const res = response(); - await deploymentHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(mockReadSecret).toHaveBeenCalledWith('sh.helm.release.v1.api-build-1.v1', 'env-build-1'); - expect(res.body).toEqual({ - type: 'helm', - releaseName: 'api-build-1', - chart: 'api-chart', - version: '1.2.3', - values: { replicas: 2 }, - manifest: 'kind: Deployment', - }); - expect(mockListConfigMaps).not.toHaveBeenCalled(); - }); - - it('falls back to plain JSON Helm data and applies output defaults', async () => { - const release = { name: 'api-build-1', chart: {}, config: null }; - mockReadSecret.mockResolvedValueOnce({ - body: { data: { release: Buffer.from(JSON.stringify(release)).toString('base64') } }, - }); - const res = response(); - await deploymentHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.body).toEqual({ - type: 'helm', - releaseName: 'api-build-1', - chart: 'unknown', - version: undefined, - values: {}, - manifest: undefined, - }); - }); - - it('returns a manifest ConfigMap when Helm data is absent', async () => { - mockListConfigMaps.mockResolvedValueOnce({ - body: { - items: [ - { metadata: undefined }, - { metadata: { name: 'other' }, data: { 'manifest.yaml': 'ignored' } }, - { metadata: { name: 'api-manifest' }, data: { 'manifest.yaml': 'kind: Service' } }, - ], - }, - }); - const res = response(); - await deploymentHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(mockListConfigMaps).toHaveBeenCalledWith( - 'env-build-1', - undefined, - undefined, - undefined, - undefined, - 'deploy_uuid=api-build-1,app=lifecycle-deploy' - ); - expect(res.body).toEqual({ - type: 'github', - manifestConfigMap: 'api-manifest', - manifest: 'kind: Service', - }); - expect(mockDeployQuery).not.toHaveBeenCalled(); - }); - - it('treats a Helm secret body without data as absent', async () => { - mockReadSecret.mockResolvedValueOnce({ body: {} }); - mockListConfigMaps.mockResolvedValueOnce({ - body: { items: [{ metadata: { name: 'api-manifest' }, data: { 'manifest.yaml': 'kind: Service' } }] }, - }); - const res = response(); - await deploymentHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.body).toEqual(expect.objectContaining({ type: 'github' })); - }); - - it.each([ - [[{ metadata: { name: 'api-manifest' }, data: undefined }], { manifest: 'kind: Deployment' }], - [[], { manifest: 'kind: Deployment' }], - ])('falls back to the database manifest when ConfigMap data is unavailable', async (items, deploy) => { - mockListConfigMaps.mockResolvedValueOnce({ body: { items } }); - const deployQuery = queryResult(deploy); - mockDeployQuery.mockReturnValueOnce(deployQuery); - const res = response(); - await deploymentHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(deployQuery.where).toHaveBeenCalledWith('uuid', 'api-build-1'); - expect(res.body).toEqual({ - type: 'github', - manifestConfigMap: 'stored-in-database', - manifest: 'kind: Deployment', - }); - }); - - it.each([undefined, {}, { manifest: '' }])('returns 404 when no manifest exists: %j', async (deploy) => { - mockDeployQuery.mockReturnValueOnce(queryResult(deploy)); - const res = response(); - await deploymentHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.statusCode).toBe(404); - expect(res.body).toEqual({ error: 'Deployment not found' }); - }); - - it('treats Helm and ConfigMap 404 responses as not found', async () => { - mockReadSecret.mockRejectedValueOnce(kubernetesError(404)); - mockListConfigMaps.mockRejectedValueOnce(kubernetesError(404)); - const res = response(); - await deploymentHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.statusCode).toBe(404); - }); - - it.each([ - [() => kubernetesError(403), 502, { error: 'Failed to communicate with Kubernetes' }], - [() => new Error('unexpected'), 500, { error: 'Internal server error' }], - ])('maps upstream and internal failures', async (makeError, status, body) => { - mockReadSecret.mockRejectedValueOnce(makeError()); - const res = response(); - await deploymentHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.statusCode).toBe(status); - expect(res.body).toEqual(body); - }); - - it('maps a non-404 ConfigMap failure after Helm lookup to 502', async () => { - mockListConfigMaps.mockRejectedValueOnce(kubernetesError(403)); - const res = response(); - await deploymentHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.statusCode).toBe(502); - expect(res.body).toEqual({ error: 'Failed to communicate with Kubernetes' }); - }); - - it.each(['secret', 'config-map'])( - 'maps a Kubernetes error without response metadata from %s lookup', - async (source) => { - const error = kubernetesError(403); - delete (error as unknown as { response?: unknown }).response; - if (source === 'secret') mockReadSecret.mockRejectedValueOnce(error); - else mockListConfigMaps.mockRejectedValueOnce(error); - const res = response(); - await deploymentHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.statusCode).toBe(502); - } - ); - - it('continues to GitHub lookup after malformed Helm release data', async () => { - mockReadSecret.mockResolvedValueOnce({ - body: { data: { release: Buffer.from('not-json').toString('base64') } }, - }); - mockListConfigMaps.mockResolvedValueOnce({ - body: { items: [{ metadata: { name: 'api-manifest' }, data: { 'manifest.yaml': 'kind: Pod' } }] }, - }); - const res = response(); - await deploymentHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.body).toEqual(expect.objectContaining({ type: 'github', manifest: 'kind: Pod' })); - expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to parse Helm release data')); - }); - }); -}); diff --git a/src/__tests__/pages-api/logRoutes.test.ts b/src/__tests__/pages-api/logRoutes.test.ts deleted file mode 100644 index ab053efa..00000000 --- a/src/__tests__/pages-api/logRoutes.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { EventEmitter } from 'events'; -import { request, response } from 'src/test-utils/pagesApi'; - -type Query = Record & PromiseLike; - -function queryResult(value: T): Query { - const query = {} as Query; - query.findOne = jest.fn(() => query); - query.then = jest.fn((resolve, reject) => Promise.resolve(value).then(resolve, reject)); - return query; -} - -function childProcess() { - const process = new EventEmitter() as EventEmitter & { - stdout: EventEmitter; - stderr: EventEmitter; - kill: jest.Mock; - }; - process.stdout = new EventEmitter(); - process.stderr = new EventEmitter(); - process.kill = jest.fn(); - return process; -} - -const mockGetLogStreamInfo = jest.fn(); -const mockGithubBuildQuery = jest.fn(); -const mockExec = jest.fn(); -const mockSpawn = jest.fn(); -const mockWithLogContext = jest.fn((_context: unknown, callback: () => unknown) => callback()); -const mockLogger = { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), -}; - -jest.mock('server/services/logStreaming', () => ({ - LogStreamingService: jest.fn(() => ({ - getLogStreamInfo: (...args: unknown[]) => mockGetLogStreamInfo(...args), - })), -})); - -jest.mock('server/services/github', () => ({ - __esModule: true, - default: jest.fn(() => ({ - db: { models: { Build: { query: (...args: unknown[]) => mockGithubBuildQuery(...args) } } }, - })), -})); - -jest.mock('child_process', () => ({ - exec: (...args: unknown[]) => mockExec(...args), - spawn: (...args: unknown[]) => mockSpawn(...args), -})); - -jest.mock('@kubernetes/client-node', () => ({ - HttpError: class HttpError extends Error { - response: { statusCode: number }; - - constructor(response: { statusCode: number }, _body: unknown, statusCode = response.statusCode) { - super(`Kubernetes ${statusCode}`); - this.response = { statusCode }; - } - }, -})); - -jest.mock('server/lib/logger', () => ({ - withLogContext: (...args: unknown[]) => mockWithLogContext(...(args as [unknown, () => unknown])), - getLogger: () => mockLogger, -})); - -import unifiedLogHandler from 'src/pages/api/v1/builds/[uuid]/services/[name]/logs/[jobName]'; -import buildLogProxy from 'src/pages/api/v1/builds/[uuid]/services/[name]/buildLogs/[jobName]'; -import deployLogProxy from 'src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs/[jobName]'; -import webhookLogProxy from 'src/pages/api/v1/builds/[uuid]/jobs/[jobName]/logs'; -import serviceLogsHandler, { config as serviceLogsConfig } from 'src/pages/api/v1/builds/[uuid]/services/[name]/logs'; -import { HttpError } from '@kubernetes/client-node'; - -const kubernetesError = (statusCode: number) => - new HttpError({ statusCode } as import('http').IncomingMessage, undefined, statusCode); - -describe('legacy build log routes', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockWithLogContext.mockImplementation((_context: unknown, callback: () => unknown) => callback()); - mockGetLogStreamInfo.mockResolvedValue({ source: 'kubernetes', streamUrl: '/logs' }); - mockGithubBuildQuery.mockReturnValue(queryResult({ namespace: 'env-custom' })); - mockExec.mockImplementation((_command: string, callback: (error: unknown, result?: unknown) => void) => - callback(null, { stdout: 'pod-a\n', stderr: '' }) - ); - mockSpawn.mockImplementation(() => childProcess()); - }); - - describe('unified job log metadata handler', () => { - it('rejects unsupported methods and advertises GET', async () => { - const res = response(); - await unifiedLogHandler( - request({ method: 'POST', query: { uuid: 'build-1', name: 'api', jobName: 'job-1' } }), - res - ); - expect(res.setHeader).toHaveBeenCalledWith('Allow', ['GET']); - expect(res.statusCode).toBe(405); - expect(mockGetLogStreamInfo).not.toHaveBeenCalled(); - }); - - it.each([ - [{ name: 'api', jobName: 'job-1' }], - [{ uuid: 'build-1', name: 'api' }], - [{ uuid: ['build-1'], name: 'api', jobName: 'job-1' }], - [{ uuid: 'build-1', name: ['api'], jobName: 'job-1' }], - [{ uuid: 'build-1', jobName: 'job-1' }], - ])('requires valid route params for non-webhook streams: %j', async (query) => { - const res = response(); - await unifiedLogHandler(request({ query }), res); - expect(res.statusCode).toBe(400); - expect(mockGetLogStreamInfo).not.toHaveBeenCalled(); - }); - - it.each([ - [{ uuid: 'build-1', name: 'api', jobName: 'job-1', type: ['build'] }], - [{ uuid: 'build-1', name: 'api', jobName: 'job-1', type: 'unknown' }], - ])('rejects invalid log types: %j', async (query) => { - const res = response(); - await unifiedLogHandler(request({ query }), res); - expect(res.statusCode).toBe(400); - expect(res.body).toEqual({ error: 'Invalid type parameter. Must be "build", "deploy", or "webhook"' }); - }); - - it.each([ - [{ uuid: 'build-1', name: 'api', jobName: 'job-1' }, ['build-1', 'job-1', 'api', undefined]], - [{ uuid: 'build-1', name: 'api', jobName: 'job-1', type: 'build' }, ['build-1', 'job-1', 'api', 'build']], - [{ uuid: 'build-1', name: 'api', jobName: 'job-1', type: 'deploy' }, ['build-1', 'job-1', 'api', 'deploy']], - [{ uuid: 'build-1', jobName: 'job-1', type: 'webhook' }, ['build-1', 'job-1', undefined, 'webhook']], - ])('returns stream metadata for normalized inputs: %j', async (query, expectedArgs) => { - const res = response(); - await unifiedLogHandler(request({ query }), res); - expect(mockGetLogStreamInfo).toHaveBeenCalledWith(...expectedArgs); - expect(res.body).toEqual({ source: 'kubernetes', streamUrl: '/logs' }); - }); - - it.each([ - [new Error('Build not found'), 404, { error: 'Build not found' }], - [kubernetesError(403), 502, { error: 'Failed to communicate with Kubernetes.' }], - [new Error('Kubernetes request failed'), 502, { error: 'Failed to communicate with Kubernetes.' }], - [ - Object.assign(new Error('gateway'), { statusCode: 502 }), - 502, - { error: 'Failed to communicate with Kubernetes.' }, - ], - [{ statusCode: 502 }, 502, { error: 'Failed to communicate with Kubernetes.' }], - [new Error('unexpected'), 500, { error: 'Internal server error occurred.' }], - ])('maps stream metadata failures: %s', async (error, status, body) => { - mockGetLogStreamInfo.mockRejectedValueOnce(error); - const res = response(); - await unifiedLogHandler( - request({ query: { uuid: 'build-1', name: 'api', jobName: 'job-1', type: 'build' } }), - res - ); - expect(res.statusCode).toBe(status); - expect(res.body).toEqual(body); - }); - }); - - describe('typed log proxy routes', () => { - it.each([ - ['build', buildLogProxy, { uuid: 'build-1', name: 'api', jobName: 'build-job' }, 'build', 'api'], - ['deploy', deployLogProxy, { uuid: 'build-1', name: 'api', jobName: 'deploy-job' }, 'deploy', 'api'], - ['webhook', webhookLogProxy, { uuid: 'build-1', name: 'ignored', jobName: 'webhook-job' }, 'webhook', undefined], - ])('sets %s type before delegating to the unified behavior', async (_label, handler, query, type, name) => { - const req = request({ query }); - const res = response(); - await handler(req, res); - expect(req.query.type).toBe(type); - expect(req.query.name).toBe(name); - expect(mockGetLogStreamInfo).toHaveBeenCalledWith('build-1', query.jobName, name, type); - expect(res.statusCode).toBe(200); - }); - }); - - describe('service pod SSE logs', () => { - beforeEach(() => jest.useFakeTimers()); - afterEach(() => jest.useRealTimers()); - - it('disables Next.js body parsing for SSE requests', () => { - expect(serviceLogsConfig).toEqual({ api: { bodyParser: false } }); - }); - - it('answers OPTIONS without querying build or pods', async () => { - const res = response(); - await serviceLogsHandler(request({ method: 'OPTIONS' }), res); - expect(res.statusCode).toBe(200); - expect(res.end).toHaveBeenCalled(); - expect(mockGithubBuildQuery).not.toHaveBeenCalled(); - }); - - it('rejects unsupported methods and malformed path params', async () => { - const method = response(); - await serviceLogsHandler(request({ method: 'POST' }), method); - expect(method.statusCode).toBe(405); - - for (const query of [ - {}, - { uuid: 'build-1' }, - { name: 'api' }, - { uuid: ['build-1'], name: 'api' }, - { uuid: 'build-1', name: ['api'] }, - ]) { - const res = response(); - await serviceLogsHandler(request({ query }), res); - expect(res.statusCode).toBe(400); - } - expect(mockGithubBuildQuery).not.toHaveBeenCalled(); - }); - - it.each(['sidecar', ['app']])('rejects unsupported container type: %j', async (containerType) => { - const res = response(); - await serviceLogsHandler(request({ query: { uuid: 'build-1', name: 'api', containerType } }), res); - expect(res.statusCode).toBe(400); - expect(mockGithubBuildQuery).not.toHaveBeenCalled(); - }); - - it('opens SSE headers, uses the build namespace, and reports no matching pods', async () => { - mockExec.mockImplementationOnce((_command, callback) => callback(null, { stdout: '\n', stderr: '' })); - const res = response(); - await serviceLogsHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.writeHead).toHaveBeenCalledWith(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - 'Access-Control-Allow-Origin': '*', - }); - expect(mockExec).toHaveBeenCalledWith( - 'kubectl get pods --namespace env-custom -l app.kubernetes.io/instance=api-build-1 -o jsonpath="{range .items[*]}{.metadata.name}{\'\\n\'}{end}"', - expect.any(Function) - ); - expect(res.chunks).toContain('data: No pods found for deployment "api-build-1" in namespace "env-custom"\n\n'); - expect(res.end).toHaveBeenCalled(); - }); - - it.each([ - [undefined, "Cannot read properties of undefined (reading 'namespace')"], - [{}, 'Deployment namespace not configured'], - ])('reports a missing deployment namespace as SSE error: %j', async (build, message) => { - mockGithubBuildQuery.mockReturnValueOnce(queryResult(build)); - const res = response(); - await serviceLogsHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.chunks).toContain(`data: Error: ${message}\n\n`); - expect(mockExec).not.toHaveBeenCalled(); - expect(res.end).toHaveBeenCalled(); - }); - - it('reports kubectl stderr without spawning log followers', async () => { - mockExec.mockImplementationOnce((_command, callback) => callback(null, { stdout: '', stderr: 'forbidden' })); - const res = response(); - await serviceLogsHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.chunks).toContain('data: Error: Failed to retrieve pods: forbidden\n\n'); - expect(mockSpawn).not.toHaveBeenCalled(); - expect(res.end).toHaveBeenCalled(); - }); - - it('streams at most five app pod processes and forwards process events', async () => { - mockExec.mockImplementationOnce((_command, callback) => - callback(null, { stdout: 'pod-a\npod-b\npod-c\npod-d\npod-e\npod-f\n', stderr: '' }) - ); - const processes = Array.from({ length: 5 }, () => childProcess()); - processes.forEach((process) => mockSpawn.mockReturnValueOnce(process)); - const req = request({ query: { uuid: 'build-1', name: 'api', containerType: 'app' } }); - const res = response(); - await serviceLogsHandler(req, res); - - expect(mockSpawn).toHaveBeenCalledTimes(5); - expect(mockSpawn).toHaveBeenNthCalledWith(1, 'kubectl', [ - 'logs', - 'pod-a', - '--namespace', - 'env-custom', - '-f', - '--tail=100', - '--all-containers=true', - '--prefix=true', - ]); - - processes[0].stdout.emit('data', Buffer.from('line one\npartial')); - processes[0].stdout.emit('data', Buffer.from(' line\n\n')); - processes[0].stderr.emit('data', Buffer.from('stderr text')); - processes[0].emit('error', new Error('spawn failed')); - processes[0].stdout.emit('data', Buffer.from('tail without newline')); - processes[0].emit('close', 0); - expect(res.chunks).toEqual( - expect.arrayContaining([ - 'data: line one\n\n', - 'data: partial line\n\n', - 'data: Error: stderr text\n\n', - 'data: Process error: spawn failed\n\n', - 'data: tail without newline\n\n', - 'data: Log streaming ended with code 0\n\n', - ]) - ); - - const closeCallback = req.on.mock.calls.find(([event]) => event === 'close')?.[1]; - closeCallback(); - processes.forEach((process) => expect(process.kill).toHaveBeenCalled()); - }); - - it('targets the init container when requested', async () => { - const process = childProcess(); - mockSpawn.mockReturnValueOnce(process); - const req = request({ query: { uuid: 'build-1', name: 'api', containerType: 'init' } }); - await serviceLogsHandler(req, response()); - expect(mockSpawn).toHaveBeenCalledWith('kubectl', [ - 'logs', - 'pod-a', - '--namespace', - 'env-custom', - '-f', - '--tail=100', - '--container=init-container', - ]); - const closeCallback = req.on.mock.calls.find(([event]) => event === 'close')?.[1]; - closeCallback(); - }); - - it('kills active processes and ends the response after the one-hour timeout', async () => { - const process = childProcess(); - mockSpawn.mockReturnValueOnce(process); - const res = response(); - await serviceLogsHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - jest.advanceTimersByTime(3_600_000); - expect(process.kill).toHaveBeenCalled(); - expect(res.end).toHaveBeenCalled(); - }); - - it('stringifies a non-Error kubectl rejection in the SSE error', async () => { - mockExec.mockImplementationOnce((_command, callback) => callback('plain failure')); - const res = response(); - await serviceLogsHandler(request({ query: { uuid: 'build-1', name: 'api' } }), res); - expect(res.chunks).toContain('data: Error: plain failure\n\n'); - expect(res.end).toHaveBeenCalled(); - }); - }); -}); diff --git a/src/__tests__/pages-api/operationalRoutes.test.ts b/src/__tests__/pages-api/operationalRoutes.test.ts deleted file mode 100644 index 605520b6..00000000 --- a/src/__tests__/pages-api/operationalRoutes.test.ts +++ /dev/null @@ -1,215 +0,0 @@ -import { request, response } from 'src/test-utils/pagesApi'; - -const mockGetAllConfigs = jest.fn(); -const mockVerifyBearerToken = jest.fn(); -jest.mock('server/lib/auth', () => ({ verifyBearerToken: (...args: unknown[]) => mockVerifyBearerToken(...args) })); -const mockTTLQueueAdd = jest.fn(); -const mockWithLogContext = jest.fn((_context: unknown, callback: () => unknown) => callback()); -const mockNanoid = jest.fn(() => 'fixed-id'); -const mockLogger = { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), -}; - -jest.mock('server/services/globalConfig', () => { - const instance = { getAllConfigs: (...args: unknown[]) => mockGetAllConfigs(...args) }; - const Service = jest.fn(() => instance) as jest.Mock & { getInstance: jest.Mock }; - Service.getInstance = jest.fn(() => instance); - return { __esModule: true, default: Service }; -}); - -jest.mock('server/services/ttlCleanup', () => ({ - __esModule: true, - default: jest.fn(() => ({ ttlCleanupQueue: { add: (...args: unknown[]) => mockTTLQueueAdd(...args) } })), -})); - -jest.mock('server/lib/logger', () => ({ - getLogger: () => mockLogger, - withLogContext: (...args: unknown[]) => mockWithLogContext(...(args as [unknown, () => unknown])), - LogStage: { CLEANUP_STARTING: 'cleanup-starting', CLEANUP_FAILED: 'cleanup-failed' }, -})); - -jest.mock('nanoid', () => ({ nanoid: () => mockNanoid() })); - -import ttlHandler from 'src/pages/api/v1/admin/ttl/cleanup'; -import cacheHandler from 'src/pages/api/v1/config/cache'; - -describe('legacy operational API routes', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockWithLogContext.mockImplementation((_context: unknown, callback: () => unknown) => callback()); - mockGetAllConfigs.mockResolvedValue({ ttl_cleanup: { enabled: true, maxAgeDays: 7 } }); - mockTTLQueueAdd.mockResolvedValue({ id: 'job-1' }); - }); - - describe('/admin/ttl/cleanup', () => { - it('advertises allowed methods for unsupported requests', async () => { - const res = response(); - await ttlHandler(request({ method: 'DELETE' }), res); - expect(res.setHeader).toHaveBeenCalledWith('Allow', ['GET', 'POST']); - expect(res.statusCode).toBe(405); - }); - - it('returns TTL configuration and distinguishes missing configuration', async () => { - const success = response(); - await ttlHandler(request(), success); - expect(success.body).toEqual({ config: { enabled: true, maxAgeDays: 7 } }); - - mockGetAllConfigs.mockResolvedValueOnce({}); - const missing = response(); - await ttlHandler(request(), missing); - expect(missing.statusCode).toBe(404); - expect(missing.body).toEqual({ error: 'TTL cleanup configuration not found' }); - }); - - it('maps config-service failures to the route-specific error', async () => { - mockGetAllConfigs.mockRejectedValueOnce(new Error('config unavailable')); - const res = response(); - await ttlHandler(request(), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: 'Unable to retrieve TTL cleanup configuration' }); - }); - - it.each([ - [undefined, false], - [{}, false], - [{ dryRun: true }, true], - ])('queues manual cleanup with normalized dryRun: %j', async (body, dryRun) => { - const res = response(); - await ttlHandler(request({ method: 'POST', body }), res); - expect(mockTTLQueueAdd).toHaveBeenCalledWith('manual-ttl-cleanup', { - dryRun, - correlationId: expect.stringMatching(/^api-ttl-cleanup-\d+-fixed-id$/), - }); - expect(res.body).toEqual({ - message: 'TTL cleanup job triggered successfully', - jobId: 'job-1', - dryRun, - }); - }); - - it('rejects non-boolean dryRun before creating a queue job', async () => { - const res = response(); - await ttlHandler(request({ method: 'POST', body: { dryRun: 'true' } }), res); - expect(res.statusCode).toBe(400); - expect(mockTTLQueueAdd).not.toHaveBeenCalled(); - }); - - it('maps queue failures to the trigger-specific error', async () => { - mockTTLQueueAdd.mockRejectedValueOnce(new Error('queue unavailable')); - const res = response(); - await ttlHandler(request({ method: 'POST', body: { dryRun: false } }), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: 'Unable to trigger TTL cleanup job' }); - }); - - it('currently propagates failures entering log context because the inner promise is returned', async () => { - mockWithLogContext.mockImplementationOnce(() => { - throw new Error('context unavailable'); - }); - const res = response(); - await expect(ttlHandler(request({ method: 'POST' }), res)).rejects.toThrow('context unavailable'); - expect(res.status).not.toHaveBeenCalled(); - }); - - it('maps synchronous routing failures to the outer stable error', async () => { - const res = response(); - (res.setHeader as jest.Mock).mockImplementationOnce(() => { - throw new Error('response unavailable'); - }); - await ttlHandler(request({ method: 'DELETE' }), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: 'An unexpected error occurred.' }); - }); - }); - - describe('/config/cache', () => { - const priorAuth = process.env.ENABLE_AUTH; - const priorIssuer = process.env.KEYCLOAK_ISSUER; - const adminRequest = (overrides: Parameters[0] = {}) => - request({ ...overrides, headers: { authorization: 'Bearer verified-test-token' } }); - beforeEach(() => { - process.env.ENABLE_AUTH = 'true'; - process.env.KEYCLOAK_ISSUER = 'https://idp.test/realms/lifecycle'; - mockVerifyBearerToken.mockResolvedValue({ - success: true, - payload: { sub: 'admin-1', iss: 'https://idp.test/realms/lifecycle', realm_access: { roles: ['admin'] } }, - }); - }); - afterAll(() => { - if (priorAuth === undefined) delete process.env.ENABLE_AUTH; - else process.env.ENABLE_AUTH = priorAuth; - if (priorIssuer === undefined) delete process.env.KEYCLOAK_ISSUER; - else process.env.KEYCLOAK_ISSUER = priorIssuer; - }); - it('denies ordinary/missing identity before loading configuration', async () => { - const res = response(); - await cacheHandler(request(), res); - expect(res.statusCode).toBe(403); - expect(mockGetAllConfigs).not.toHaveBeenCalled(); - }); - it('denies auth-off even with cached admin claims', async () => { - process.env.ENABLE_AUTH = 'false'; - const res = response(); - await cacheHandler(adminRequest(), res); - expect(res.statusCode).toBe(403); - expect(mockGetAllConfigs).not.toHaveBeenCalled(); - }); - it.each([ - ['GET', false], - ['PUT', true], - ])('%s returns cached configuration with the expected refresh flag', async (method, refresh) => { - mockGetAllConfigs.mockResolvedValueOnce({ feature: 'value' }); - const res = response(); - await cacheHandler(adminRequest({ method }), res); - expect(mockGetAllConfigs).toHaveBeenCalledWith(refresh); - expect(res.body).toEqual({ configs: { feature: 'value' } }); - }); - - it('advertises allowed methods', async () => { - const res = response(); - await cacheHandler(adminRequest({ method: 'POST' }), res); - expect(res.setHeader).toHaveBeenCalledWith('Allow', ['GET', 'PUT']); - expect(res.statusCode).toBe(405); - }); - - it('maps config retrieval failures to the route-specific error', async () => { - mockGetAllConfigs.mockRejectedValueOnce(new Error('config unavailable')); - const res = response(); - await cacheHandler(adminRequest(), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: 'Unable to retrieve global config values' }); - }); - - it('ignores forged x-user admin claims even with an invalid bearer', async () => { - mockVerifyBearerToken.mockResolvedValue({ success: false }); - const res = response(); - await cacheHandler( - request({ - headers: { - authorization: 'Bearer forged', - 'x-user': Buffer.from( - JSON.stringify({ - sub: 'admin-1', - iss: 'https://idp.test/realms/lifecycle', - realm_access: { roles: ['admin'] }, - }) - ).toString('base64'), - }, - }), - res - ); - expect(res.statusCode).toBe(403); - expect(mockGetAllConfigs).not.toHaveBeenCalled(); - expect(mockVerifyBearerToken).toHaveBeenCalledWith('forged'); - }); - it('denies malformed claims before configuration reads', async () => { - const res = response(); - await cacheHandler(request({ headers: { 'x-user': 'malformed' } }), res); - expect(res.statusCode).toBe(403); - expect(mockGetAllConfigs).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/src/__tests__/pages-api/resourceRoutes.test.ts b/src/__tests__/pages-api/resourceRoutes.test.ts deleted file mode 100644 index d30565fb..00000000 --- a/src/__tests__/pages-api/resourceRoutes.test.ts +++ /dev/null @@ -1,431 +0,0 @@ -import { request, response } from 'src/test-utils/pagesApi'; - -type Query = Record & PromiseLike; - -function queryResult(value: T): Query { - const query = {} as Query; - for (const method of [ - 'select', - 'where', - 'whereNotIn', - 'whereNotNull', - 'whereIn', - 'orderBy', - 'distinct', - 'findById', - 'findOne', - 'withGraphFetched', - 'offset', - 'limit', - 'countDistinct', - ]) { - query[method] = jest.fn(() => query); - } - query.resultSize = jest.fn().mockResolvedValue(Array.isArray(value) ? value.length : 0); - query.first = jest.fn().mockResolvedValue(value); - query.then = jest.fn((resolve, reject) => Promise.resolve(value).then(resolve, reject)); - return query; -} - -const mockBuildQuery = jest.fn(); -const mockDeployQuery = jest.fn(); -const mockDeployableQuery = jest.fn(); -const mockPullRequestQuery = jest.fn(); -const mockRaw = jest.fn(); -const mockLogger = { - debug: jest.fn(), - error: jest.fn(), -}; - -const mockBuildService = { - db: { - models: { - Build: { query: (...args: unknown[]) => mockBuildQuery(...args) }, - Deploy: { query: (...args: unknown[]) => mockDeployQuery(...args) }, - Deployable: { query: (...args: unknown[]) => mockDeployableQuery(...args) }, - }, - knex: { raw: (...args: unknown[]) => mockRaw(...args) }, - }, -}; - -const mockPullRequestService = { - db: { - models: { - PullRequest: { query: (...args: unknown[]) => mockPullRequestQuery(...args) }, - }, - }, -}; - -jest.mock('server/services/build', () => ({ - __esModule: true, - default: jest.fn(() => mockBuildService), -})); - -jest.mock('server/services/pullRequest', () => ({ - __esModule: true, - default: jest.fn(() => mockPullRequestService), -})); - -jest.mock('server/lib/logger', () => ({ - getLogger: () => mockLogger, -})); - -import buildsHandler from 'src/pages/api/v1/builds'; -import deploySummaryHandler from 'src/pages/api/v1/deploy-summary'; -import deployablesHandler from 'src/pages/api/v1/deployables'; -import deploysHandler from 'src/pages/api/v1/deploys'; -import pullRequestBuildsHandler from 'src/pages/api/v1/pull-requests/[id]/builds'; -import pullRequestHandler from 'src/pages/api/v1/pull-requests/[id]'; -import pullRequestsHandler from 'src/pages/api/v1/pull-requests'; -import reposHandler from 'src/pages/api/v1/repos'; -import usersHandler from 'src/pages/api/v1/users'; - -describe('legacy read-only resource routes', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - - describe.each([ - ['deploy summary', deploySummaryHandler], - ['deployables', deployablesHandler], - ['deploys', deploysHandler], - ])('GET /%s', (_name, handler) => { - it('rejects unsupported methods before creating a service', async () => { - const res = response(); - await handler(request({ method: 'POST' }), res); - expect(res.statusCode).toBe(405); - expect(mockBuildQuery).not.toHaveBeenCalled(); - }); - - it.each([ - [{}, 'Invalid build ID'], - [{ buildId: ['1'] }, 'Invalid build ID'], - [{ buildId: 'not-a-number' }, 'Invalid build ID'], - ])('validates buildId: %j', async (query, error) => { - const res = response(); - await handler(request({ query }), res); - expect(res.statusCode).toBe(400); - expect(res.body).toEqual({ error }); - expect(mockBuildQuery).not.toHaveBeenCalled(); - }); - }); - - describe('GET /deploy-summary', () => { - it('returns 404 without issuing the summary SQL when the build is absent', async () => { - mockBuildQuery.mockReturnValue(queryResult(undefined)); - const res = response(); - await deploySummaryHandler(request({ query: { buildId: '42' } }), res); - expect(res.statusCode).toBe(404); - expect(mockRaw).not.toHaveBeenCalled(); - }); - - it('queries the summary view with the parsed build ID', async () => { - mockBuildQuery.mockReturnValue(queryResult({ id: 42 })); - mockRaw.mockResolvedValue({ rows: [{ name: 'api', status: 'ready' }] }); - const res = response(); - await deploySummaryHandler(request({ query: { buildId: '42' } }), res); - expect(mockRaw).toHaveBeenCalledWith(expect.stringContaining('FROM "deploySummary"'), [42]); - expect(res.body).toEqual([{ name: 'api', status: 'ready' }]); - }); - - it('returns a stable 500 response on query failure', async () => { - mockBuildQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await deploySummaryHandler(request({ query: { buildId: '42' } }), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: 'An unexpected error occurred' }); - }); - }); - - describe('GET /deployables', () => { - it('returns 404 before querying deployables when the build is absent', async () => { - mockBuildQuery.mockReturnValue(queryResult(undefined)); - const res = response(); - await deployablesHandler(request({ query: { buildId: '42' } }), res); - expect(res.statusCode).toBe(404); - expect(mockDeployableQuery).not.toHaveBeenCalled(); - }); - - it.each([ - [{ buildId: '42', name: 'api' }, true], - [{ buildId: '42' }, false], - [{ buildId: '42', name: ['api'] }, false], - ])('returns deployables and conditionally filters a string name: %j', async (params, filtered) => { - const buildQuery = queryResult({ id: 42 }); - const deployableQuery = queryResult([{ id: 7, name: 'api' }]); - mockBuildQuery.mockReturnValue(buildQuery); - mockDeployableQuery.mockReturnValue(deployableQuery); - const res = response(); - await deployablesHandler(request({ query: params }), res); - expect(deployableQuery.where).toHaveBeenCalledWith('buildId', 42); - if (filtered) expect(deployableQuery.where).toHaveBeenCalledWith('name', 'api'); - else expect(deployableQuery.where).not.toHaveBeenCalledWith('name', expect.anything()); - expect(res.body).toEqual([{ id: 7, name: 'api' }]); - }); - - it('returns a stable 500 response on query failure', async () => { - mockBuildQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await deployablesHandler(request({ query: { buildId: '42' } }), res); - expect(res.statusCode).toBe(500); - }); - }); - - describe('GET /deploys', () => { - it.each([ - { buildId: '42', deployableId: ['7'] }, - { buildId: '42', deployableId: 'invalid' }, - ])('rejects an invalid deployableId: %j', async (query) => { - const res = response(); - await deploysHandler(request({ query }), res); - expect(res.statusCode).toBe(400); - expect(res.body).toEqual({ error: 'Invalid deployable ID' }); - }); - - it('returns 404 before querying deploys when the build is absent', async () => { - mockBuildQuery.mockReturnValue(queryResult(undefined)); - const res = response(); - await deploysHandler(request({ query: { buildId: '42' } }), res); - expect(res.statusCode).toBe(404); - expect(mockDeployQuery).not.toHaveBeenCalled(); - }); - - it.each([ - [{ buildId: '42', deployableId: '7' }, true], - [{ buildId: '42' }, false], - [{ buildId: '42', deployableId: '0' }, false], - ])('returns deploys and follows the current truthy parsed-ID filter: %j', async (params, filtered) => { - mockBuildQuery.mockReturnValue(queryResult({ id: 42 })); - const deployQuery = queryResult([{ id: 8, deployableId: 7 }]); - mockDeployQuery.mockReturnValue(deployQuery); - const res = response(); - await deploysHandler(request({ query: params }), res); - expect(deployQuery.where).toHaveBeenCalledWith('buildId', 42); - if (filtered) expect(deployQuery.where).toHaveBeenCalledWith('deployableId', 7); - else expect(deployQuery.where).not.toHaveBeenCalledWith('deployableId', expect.anything()); - expect(res.body).toEqual([{ id: 8, deployableId: 7 }]); - }); - - it('returns a stable 500 response on query failure', async () => { - mockBuildQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await deploysHandler(request({ query: { buildId: '42' } }), res); - expect(res.statusCode).toBe(500); - }); - }); - - describe.each([ - ['pull request', pullRequestHandler], - ['pull request builds', pullRequestBuildsHandler], - ])('GET /%s/:id', (_name, handler) => { - it('rejects methods and malformed IDs before querying', async () => { - const methodRes = response(); - await handler(request({ method: 'POST' }), methodRes); - expect(methodRes.statusCode).toBe(405); - - for (const id of [undefined, ['1'], 'invalid']) { - const res = response(); - await handler(request({ query: id === undefined ? {} : { id } }), res); - expect(res.statusCode).toBe(400); - } - expect(mockPullRequestQuery).not.toHaveBeenCalled(); - }); - - it('returns 404 when the pull request does not exist', async () => { - mockPullRequestQuery.mockReturnValue(queryResult(undefined)); - const res = response(); - await handler(request({ query: { id: '17' } }), res); - expect(res.statusCode).toBe(404); - expect(res.body).toEqual({ error: 'Pull request not found' }); - }); - - it('maps lookup errors to the stable 500 response', async () => { - mockPullRequestQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await handler(request({ query: { id: '17' } }), res); - expect(res.statusCode).toBe(500); - }); - }); - - it('returns a pull request by parsed database ID', async () => { - const pullRequest = { id: 17, fullName: 'goodrx/lifecycle' }; - const query = queryResult(pullRequest); - mockPullRequestQuery.mockReturnValue(query); - const res = response(); - await pullRequestHandler(request({ query: { id: '17' } }), res); - expect(query.findById).toHaveBeenCalledWith(17); - expect(res.body).toEqual(pullRequest); - }); - - it('returns builds for an existing pull request', async () => { - mockPullRequestQuery.mockReturnValue(queryResult({ id: 17 })); - const builds = [{ id: 1, pullRequestId: 17 }]; - const buildQuery = queryResult(builds); - mockBuildQuery.mockReturnValue(buildQuery); - const res = response(); - await pullRequestBuildsHandler(request({ query: { id: '17' } }), res); - expect(buildQuery.where).toHaveBeenCalledWith('pullRequestId', 17); - expect(res.body).toEqual(builds); - }); - - describe('GET /builds', () => { - it('rejects unsupported methods', async () => { - const res = response(); - await buildsHandler(request({ method: 'POST' }), res); - expect(res.statusCode).toBe(405); - }); - - it.each([ - [{}, ['torn_down', 'pending']], - [{ exclude: '["failed",7]' }, ['failed']], - [{ exclude: 'ready' }, ['ready']], - [{ exclude: '"ready"' }, ['torn_down', 'pending']], - ])('normalizes exclusion formats and returns all builds: %j', async (queryParams, exclusions) => { - const builds = [{ uuid: 'build-1', status: 'ready' }]; - const query = queryResult(builds); - mockBuildQuery.mockReturnValue(query); - const res = response(); - await buildsHandler(request({ query: queryParams }), res); - expect(query.whereNotIn).toHaveBeenCalledWith('status', exclusions); - expect(res.body).toEqual({ - builds, - metadata: { currentPage: 1, totalPages: 1, total: 1, limit: 1 }, - }); - }); - - it.each([ - [{ page: '2', limit: '5' }, { currentPage: 2, totalPages: 3, total: 12, limit: 5 }, 5], - [{ page: 'bad', limit: '0' }, { currentPage: 1, totalPages: 1, total: 12, limit: 20 }, 0], - ])('paginates with bounded numeric parameters: %j', async (queryParams, metadata, expectedOffset) => { - const builds = [{ uuid: 'build-1', status: 'ready' }]; - const query = queryResult(builds); - query.resultSize.mockResolvedValue(12); - mockBuildQuery.mockReturnValue(query); - const res = response(); - await buildsHandler(request({ query: queryParams }), res); - expect(query.offset).toHaveBeenCalledWith(expectedOffset); - expect(query.limit).toHaveBeenCalledWith(metadata.limit); - expect(res.body).toEqual({ builds, metadata }); - }); - - it('maps query failures to the stable 500 response', async () => { - mockBuildQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await buildsHandler(request(), res); - expect(res.statusCode).toBe(500); - }); - }); - - describe('GET /pull-requests', () => { - it('rejects unsupported methods before querying', async () => { - const res = response(); - await pullRequestsHandler(request({ method: 'POST' }), res); - expect(res.statusCode).toBe(405); - expect(mockPullRequestQuery).not.toHaveBeenCalled(); - }); - - it.each([ - [{}, undefined], - [{ exclude: '["closed",1]' }, ['closed']], - [{ exclude: 'closed' }, ['closed']], - [{ exclude: '"closed"' }, ['"closed"']], - ])('normalizes filters and paginates: %j', async (params, exclusions) => { - const rows = [{ id: 1, githubLogin: 'octocat' }]; - const query = queryResult(rows); - query.resultSize.mockResolvedValue(26); - mockPullRequestQuery.mockReturnValue(query); - const res = response(); - await pullRequestsHandler( - request({ query: { user: 'octocat', repo: 'goodrx/lifecycle', page: '2', limit: '25', ...params } }), - res - ); - expect(query.where).toHaveBeenCalledWith('githubLogin', 'octocat'); - expect(query.where).toHaveBeenCalledWith('fullName', 'goodrx/lifecycle'); - if (exclusions?.length) expect(query.whereNotIn).toHaveBeenCalledWith('status', exclusions); - else expect(query.whereNotIn).not.toHaveBeenCalled(); - expect(res.body).toEqual({ - pull_requests: rows, - metadata: { currentPage: 2, totalPages: 2, total: 26, limit: 25 }, - }); - }); - - it('ignores non-string user/repo and defaults malformed pagination', async () => { - const rows: unknown[] = []; - const query = queryResult(rows); - mockPullRequestQuery.mockReturnValue(query); - const res = response(); - await pullRequestsHandler( - request({ query: { user: ['octocat'], repo: ['repo'], page: 'bad', limit: '0' } }), - res - ); - expect(query.where).not.toHaveBeenCalled(); - expect(query.offset).toHaveBeenCalledWith(0); - expect(query.limit).toHaveBeenCalledWith(25); - }); - - it('maps query failures to the stable 500 response', async () => { - mockPullRequestQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await pullRequestsHandler(request(), res); - expect(res.statusCode).toBe(500); - }); - }); - - describe.each([ - ['repos', reposHandler, 'fullName', 'repos'], - ['users', usersHandler, 'githubLogin', 'users'], - ])('GET /%s', (_route, handler, field, responseKey) => { - it('rejects unsupported methods', async () => { - const res = response(); - await handler(request({ method: 'POST' }), res); - expect(res.statusCode).toBe(405); - }); - - it('returns all distinct values without a pagination query', async () => { - const base = queryResult([{ [field]: 'one' }, { [field]: 'two' }]); - mockPullRequestQuery.mockReturnValue(base); - const res = response(); - await handler(request(), res); - expect(res.body).toEqual({ - [responseKey]: ['one', 'two'], - metadata: { currentPage: 1, totalPages: 1, total: 2, limit: 2 }, - }); - expect(mockPullRequestQuery).toHaveBeenCalledTimes(1); - }); - - it.each([ - [{ page: '2', limit: '2' }, { currentPage: 2, totalPages: 3, total: 5, limit: 2 }, 2], - [{ page: 'bad', limit: '0' }, { currentPage: 1, totalPages: 0, total: 0, limit: 20 }, 0], - ])('returns a distinct page and count: %j', async (params, metadata, expectedOffset) => { - const base = queryResult([{ [field]: 'one' }]); - const count = queryResult(metadata.total ? { count: String(metadata.total) } : undefined); - mockPullRequestQuery.mockReturnValueOnce(base).mockReturnValueOnce(count); - const res = response(); - await handler(request({ query: params }), res); - expect(base.offset).toHaveBeenCalledWith(expectedOffset); - expect(base.limit).toHaveBeenCalledWith(metadata.limit); - expect(res.body).toEqual({ [responseKey]: ['one'], metadata }); - }); - - it('maps query failures to the stable 500 response', async () => { - mockPullRequestQuery.mockImplementation(() => { - throw new Error('db unavailable'); - }); - const res = response(); - await handler(request(), res); - expect(res.statusCode).toBe(500); - }); - }); -}); diff --git a/src/__tests__/pages-api/schemaValidate.test.ts b/src/__tests__/pages-api/schemaValidate.test.ts deleted file mode 100644 index 76a57465..00000000 --- a/src/__tests__/pages-api/schemaValidate.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { request, response } from 'src/test-utils/pagesApi'; - -const mockFetchYaml = jest.fn(); -const mockParse = jest.fn(); -const mockValidate = jest.fn(); -const mockLogger = { error: jest.fn() }; - -jest.mock('server/lib/github', () => ({ - getYamlFileContentFromBranch: (...args: unknown[]) => mockFetchYaml(...args), - ConfigFileNotFound: class ConfigFileNotFound extends Error {}, -})); - -jest.mock('server/lib/yamlConfigParser', () => ({ - YamlConfigParser: jest.fn(() => ({ parseYamlConfigFromString: (...args: unknown[]) => mockParse(...args) })), - ParsingError: class ParsingError extends Error {}, -})); - -jest.mock('server/lib/yamlConfigValidator', () => ({ - YamlConfigValidator: jest.fn(() => ({ validate: (...args: unknown[]) => mockValidate(...args) })), - ValidationError: class ValidationError extends Error {}, -})); - -jest.mock('server/lib/logger', () => ({ getLogger: () => mockLogger })); - -import handler from 'src/pages/api/v1/schema/validate'; -import { ConfigFileNotFound } from 'server/lib/github'; -import { ParsingError } from 'server/lib/yamlConfigParser'; -import { ValidationError } from 'server/lib/yamlConfigValidator'; - -describe('POST /api/v1/schema/validate', () => { - beforeEach(() => { - jest.clearAllMocks(); - mockParse.mockReturnValue({ version: 'v2', services: {} }); - mockValidate.mockReturnValue(true); - mockFetchYaml.mockResolvedValue('version: v2'); - }); - - it('rejects unsupported methods before parsing a request', async () => { - const res = response(); - await handler(request({ method: 'GET' }), res); - expect(res.statusCode).toBe(405); - expect(mockParse).not.toHaveBeenCalled(); - }); - - it.each([undefined, 7, 'url'])('rejects unsupported source values: %j', async (source) => { - const res = response(); - await handler(request({ method: 'POST', body: { source } }), res); - expect(res.statusCode).toBe(400); - expect(res.body).toEqual({ valid: false, error: ['Invalid source in request body'] }); - }); - - it('validates decoded base64 content', async () => { - const content = Buffer.from('version: v2\nservices: {}').toString('base64'); - const res = response(); - await handler(request({ method: 'POST', body: { source: 'content', content } }), res); - expect(mockParse).toHaveBeenCalledWith('version: v2\nservices: {}'); - expect(mockValidate).toHaveBeenCalledWith('v2', { version: 'v2', services: {} }); - expect(res.body).toEqual({ valid: true, error: null }); - }); - - it('passes an absent parsed content version through to the validator', async () => { - mockParse.mockReturnValueOnce(undefined); - const res = response(); - await handler( - request({ method: 'POST', body: { source: 'content', content: Buffer.from('').toString('base64') } }), - res - ); - expect(mockValidate).toHaveBeenCalledWith(undefined, undefined); - }); - - it.each([undefined, 7])('requires content to be a string: %j', async (content) => { - const res = response(); - await handler(request({ method: 'POST', body: { source: 'content', content } }), res); - expect(res.statusCode).toBe(400); - expect(mockParse).not.toHaveBeenCalled(); - }); - - it.each([[{ repo: '', branch: 'main' }], [{ repo: 'goodrx/lifecycle', branch: ' ' }], [{ repo: 7, branch: 'main' }]])( - 'requires nonblank string path coordinates: %j', - async (body) => { - const res = response(); - await handler(request({ method: 'POST', body: { source: 'path', ...body } }), res); - expect(res.statusCode).toBe(400); - expect(mockFetchYaml).not.toHaveBeenCalled(); - } - ); - - it('fetches and validates repository YAML by repo and branch', async () => { - mockValidate.mockReturnValue(false); - const res = response(); - await handler( - request({ method: 'POST', body: { source: 'path', repo: 'goodrx/lifecycle', branch: 'feature' } }), - res - ); - expect(mockFetchYaml).toHaveBeenCalledWith('goodrx/lifecycle', 'feature'); - expect(mockParse).toHaveBeenCalledWith('version: v2'); - expect(res.body).toEqual({ valid: false, error: null }); - }); - - it('passes an absent parsed path version through to the validator', async () => { - mockParse.mockReturnValueOnce(undefined); - const res = response(); - await handler( - request({ method: 'POST', body: { source: 'path', repo: 'goodrx/lifecycle', branch: 'empty' } }), - res - ); - expect(mockValidate).toHaveBeenCalledWith(undefined, undefined); - }); - - it.each([ - [new ParsingError('line one\nline two'), 400, { valid: false, error: ['line one', 'line two'] }], - [new ValidationError('invalid service'), 400, { valid: false, error: ['invalid service'] }], - [new ConfigFileNotFound('missing'), 404, { valid: false, error: ['Config file not found'] }], - [new Error('unexpected'), 500, { error: 'Internal server error' }], - ])('maps validation failures without leaking internals: %s', async (error, status, body) => { - mockParse.mockImplementationOnce(() => { - throw error; - }); - const res = response(); - await handler( - request({ - method: 'POST', - body: { source: 'content', content: Buffer.from('invalid').toString('base64') }, - }), - res - ); - expect(res.statusCode).toBe(status); - expect(res.body).toEqual(body); - if (status === 500) expect(mockLogger.error).toHaveBeenCalledWith({ error }, 'Schema: YAML validation failed'); - }); - - it('maps repository file-not-found failures', async () => { - mockFetchYaml.mockRejectedValueOnce(new ConfigFileNotFound('missing')); - const res = response(); - await handler(request({ method: 'POST', body: { source: 'path', repo: 'goodrx/lifecycle', branch: 'main' } }), res); - expect(res.statusCode).toBe(404); - }); -}); diff --git a/src/__tests__/pages-api/setupRoutes.test.ts b/src/__tests__/pages-api/setupRoutes.test.ts deleted file mode 100644 index dfd71f7f..00000000 --- a/src/__tests__/pages-api/setupRoutes.test.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { request, response } from 'src/test-utils/pagesApi'; - -const mockGetConfig = jest.fn(); -const mockSetConfig = jest.fn(); -const mockGetAllConfigs = jest.fn(); -const mockUpdateSecret = jest.fn(); -const mockGetNamespace = jest.fn(); -const mockShellPromise = jest.fn(); -const mockRandomBytes = jest.fn(); -const mockLogger = { - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), -}; -let mockAppHost = 'https://lifecycle.example.test'; - -jest.mock('server/services/globalConfig', () => { - const instance = { - getConfig: (...args: unknown[]) => mockGetConfig(...args), - setConfig: (...args: unknown[]) => mockSetConfig(...args), - getAllConfigs: (...args: unknown[]) => mockGetAllConfigs(...args), - }; - const Service = jest.fn(() => instance) as jest.Mock & { getInstance: jest.Mock }; - Service.getInstance = jest.fn(() => instance); - return { __esModule: true, default: Service }; -}); - -jest.mock('server/lib/kubernetes', () => ({ - updateSecret: (...args: unknown[]) => mockUpdateSecret(...args), - getCurrentNamespaceFromFile: (...args: unknown[]) => mockGetNamespace(...args), -})); - -jest.mock('server/lib/shell', () => ({ - shellPromise: (...args: unknown[]) => mockShellPromise(...args), -})); - -jest.mock('server/lib/logger', () => ({ - getLogger: () => mockLogger, -})); - -jest.mock('crypto', () => ({ - randomBytes: (...args: unknown[]) => mockRandomBytes(...args), -})); - -jest.mock('shared/config', () => ({ - get APP_HOST() { - return mockAppHost; - }, - GITHUB_APP_AUTH_CALLBACK: 'https://auth.example.test/callback', - SECRET_BOOTSTRAP_NAME: 'lifecycle-bootstrap', -})); - -import callbackHandler from 'src/pages/api/v1/setup/callback'; -import configureHandler from 'src/pages/api/v1/setup/configure'; -import setupHandler from 'src/pages/api/v1/setup'; -import installedHandler from 'src/pages/api/v1/setup/installed'; -import statusHandler from 'src/pages/api/v1/setup/status'; - -describe('legacy setup API routes', () => { - const originalReleaseName = process.env.HELM_RELEASE_NAME; - - beforeEach(() => { - jest.clearAllMocks(); - mockAppHost = 'https://lifecycle.example.test'; - mockGetNamespace.mockReturnValue('lifecycle-system'); - mockRandomBytes.mockReturnValue({ toString: jest.fn(() => 'fixed-state') }); - mockGetConfig.mockImplementation(async (key: string) => { - if (key === 'app_setup') return { state: 'fixed-state', installed: false }; - if (key === 'lifecycleDefaults') return { existing: 'lifecycle' }; - if (key === 'domainDefaults') return { existing: 'domain' }; - return undefined; - }); - process.env.HELM_RELEASE_NAME = 'lifecycle'; - global.fetch = jest.fn().mockResolvedValue({ - ok: true, - status: 200, - json: jest.fn().mockResolvedValue({ - id: 'app-1', - client_id: 'client-1', - client_secret: 'client-secret', - webhook_secret: 'webhook-secret', - pem: 'line1\nline2', - html_url: 'https://github.com/apps/lifecycle-test', - slug: 'lifecycle-test', - }), - }) as typeof fetch; - }); - - afterAll(() => { - if (originalReleaseName === undefined) delete process.env.HELM_RELEASE_NAME; - else process.env.HELM_RELEASE_NAME = originalReleaseName; - }); - - describe('GET /setup', () => { - it('rejects unsupported methods and installed applications before generating state', async () => { - const methodRes = response(); - await setupHandler(request({ method: 'POST' }), methodRes); - expect(methodRes.statusCode).toBe(405); - expect(mockGetConfig).not.toHaveBeenCalled(); - - mockGetConfig.mockResolvedValueOnce({ installed: true }); - const installedRes = response(); - await setupHandler(request(), installedRes); - expect(installedRes.redirect).toHaveBeenCalledWith('/setup'); - expect(mockRandomBytes).not.toHaveBeenCalled(); - }); - - it.each([ - [{}, 'App name is not valid.'], - [{ app_name: 'x'.repeat(35) }, 'App name is not valid.'], - [{ app_name: 'valid-app', org: 'bad org' }, 'Organization name is not valid.'], - [{ app_name: 'valid-app', org: 'x'.repeat(40) }, 'Organization name is not valid.'], - ])('validates manifest identity fields: %j', async (query, error) => { - const res = response(); - await setupHandler(request({ query }), res); - expect(res.statusCode).toBe(400); - expect(res.body).toEqual({ error }); - expect(mockSetConfig).not.toHaveBeenCalled(); - }); - - it('validates input when app setup state has not been created yet', async () => { - mockGetConfig.mockResolvedValueOnce(undefined); - const res = response(); - await setupHandler(request({ query: {} }), res); - expect(res.statusCode).toBe(400); - expect(res.body).toEqual({ error: 'App name is not valid.' }); - }); - - it.each(['', 'not a url'])('rejects an invalid configured public URL: %j', async (appHost) => { - mockAppHost = appHost; - const res = response(); - await setupHandler(request({ query: { app_name: 'valid-app' } }), res); - expect(res.statusCode).toBe(400); - expect(res.body).toEqual({ error: 'Application public URL is not valid.' }); - }); - - it.each([ - [{ app_name: ' lifecycle-app ' }, 'https://github.com/settings/apps/new?state=fixed-state'], - [ - { app_name: 'lifecycle-app', org: ' goodrx ' }, - 'https://github.com/organizations/goodrx/settings/apps/new?state=fixed-state', - ], - ])('persists state and renders the GitHub manifest form', async (query, actionUrl) => { - const res = response(); - await setupHandler(request({ query }), res); - - expect(mockSetConfig).toHaveBeenCalledWith('app_setup', { - state: 'fixed-state', - created: false, - installed: false, - org: 'org' in query ? 'goodrx' : '', - appUrl: 'https://lifecycle.example.test', - }); - expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'text/html; charset=utf-8'); - expect(res.send).toHaveBeenCalledWith(expect.stringContaining(`action="${actionUrl}"`)); - expect(res.send).toHaveBeenCalledWith(expect.stringContaining('"workflows":"write"')); - expect(res.send).toHaveBeenCalledWith(expect.stringContaining('https://auth.example.test/callback')); - }); - }); - - describe('GET /setup/callback', () => { - it.each([ - [{ state: 'fixed-state' }, { error: 'Missing authorization code' }], - [{ code: 'code-1' }, { error: 'Missing state parameter' }], - ])('requires OAuth callback parameters: %j', async (query, body) => { - const res = response(); - await callbackHandler(request({ query }), res); - expect(res.statusCode).toBe(400); - expect(res.body).toEqual(body); - expect(global.fetch).not.toHaveBeenCalled(); - }); - - it('rejects a mismatched state and an already-installed app', async () => { - mockGetConfig.mockResolvedValueOnce({ state: 'other-state' }); - const stateRes = response(); - await callbackHandler(request({ query: { code: 'code-1', state: 'fixed-state' } }), stateRes); - expect(stateRes.body).toEqual({ error: 'Invalid state parameter' }); - - mockGetConfig - .mockResolvedValueOnce({ state: 'fixed-state' }) - .mockResolvedValueOnce({ state: 'fixed-state', installed: true }); - const installedRes = response(); - await callbackHandler(request({ query: { code: 'code-1', state: 'fixed-state' } }), installedRes); - expect(installedRes.body).toEqual({ error: 'App already installed' }); - expect(global.fetch).not.toHaveBeenCalled(); - }); - - it('maps GitHub conversion failure and invalid credentials without mutating secrets', async () => { - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: false, - status: 422, - json: jest.fn().mockResolvedValue({ message: 'expired' }), - }); - const githubRes = response(); - await callbackHandler(request({ query: { code: 'expired', state: 'fixed-state' } }), githubRes); - expect(githubRes.statusCode).toBe(422); - expect(githubRes.body).toEqual({ error: 'Failed to convert manifest code' }); - - (global.fetch as jest.Mock).mockResolvedValueOnce({ - ok: true, - json: jest.fn().mockResolvedValue({ id: 'incomplete' }), - }); - const invalidRes = response(); - await callbackHandler(request({ query: { code: 'code-1', state: 'fixed-state' } }), invalidRes); - expect(invalidRes.statusCode).toBe(400); - expect(invalidRes.body).toEqual({ error: 'Invalid response from GitHub' }); - expect(mockUpdateSecret).not.toHaveBeenCalled(); - }); - - it('stores converted credentials and redirects to GitHub installation', async () => { - const res = response(); - await callbackHandler(request({ query: { code: 'code-1', state: 'fixed-state' } }), res); - - expect(global.fetch).toHaveBeenCalledWith('https://api.github.com/app-manifests/code-1/conversions', { - method: 'POST', - headers: { Accept: 'application/vnd.github.v3+json' }, - }); - expect(mockUpdateSecret).toHaveBeenCalledWith( - 'lifecycle-bootstrap', - { - GITHUB_APP_ID: 'app-1', - GITHUB_CLIENT_ID: 'client-1', - GITHUB_CLIENT_SECRET: 'client-secret', - GITHUB_WEBHOOK_SECRET: 'webhook-secret', - GITHUB_PRIVATE_KEY: 'line1\\nline2', - }, - 'lifecycle-system' - ); - expect(mockSetConfig).toHaveBeenCalledWith('app_setup', { - state: 'fixed-state', - installed: false, - created: true, - url: 'https://github.com/apps/lifecycle-test', - name: 'lifecycle-test', - }); - expect(res.redirect).toHaveBeenCalledWith('https://github.com/apps/lifecycle-test/installations/new'); - }); - - it('returns the stable setup error on unexpected failures', async () => { - mockGetConfig.mockRejectedValueOnce(new Error('database unavailable')); - const res = response(); - await callbackHandler(request({ query: { code: 'code-1', state: 'fixed-state' } }), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: 'An error occurred during GitHub app setup' }); - }); - }); - - describe('GET /setup/installed', () => { - it.each([ - [{}, 400, { error: 'Missing installation_id' }], - [{ installation_id: 'install-1', setup_action: 'update' }, 500, { error: 'Invalid setup_action' }], - ])('validates installation callback query: %j', async (query, status, body) => { - const res = response(); - await installedHandler(request({ query }), res); - expect(res.statusCode).toBe(status); - expect(res.body).toEqual(body); - expect(mockSetConfig).not.toHaveBeenCalled(); - }); - - it.each([ - [undefined, 404, { error: 'No app_setup found.' }], - [{ installed: true }, 400, { error: 'App already installed.' }], - ])('rejects unusable app_setup state', async (appSetup, status, body) => { - mockGetConfig.mockResolvedValueOnce(appSetup); - const res = response(); - await installedHandler(request({ query: { installation_id: 'install-1' } }), res); - expect(res.statusCode).toBe(status); - expect(res.body).toEqual(body); - expect(mockUpdateSecret).not.toHaveBeenCalled(); - }); - - it('records installation, updates the bootstrap secret, and redirects with state', async () => { - const res = response(); - await installedHandler(request({ query: { installation_id: 'install-1', setup_action: 'install' } }), res); - - const installedState = { state: 'fixed-state', installed: true }; - expect(mockSetConfig).toHaveBeenCalledWith('app_setup', installedState); - expect(mockUpdateSecret).toHaveBeenCalledWith( - 'lifecycle-bootstrap', - { GITHUB_APP_INSTALLATION_ID: 'install-1' }, - 'lifecycle-system' - ); - expect(res.redirect).toHaveBeenCalledWith( - `/setup/complete?app_setup=${encodeURIComponent(JSON.stringify(installedState))}` - ); - }); - }); - - describe('POST /setup/configure', () => { - it('fails before reading config when namespace or release name is missing', async () => { - mockGetNamespace.mockReturnValueOnce(''); - const noNamespace = response(); - await configureHandler(request({ method: 'POST' }), noNamespace); - expect(noNamespace.statusCode).toBe(500); - - mockGetNamespace.mockReturnValueOnce('lifecycle-system'); - delete process.env.HELM_RELEASE_NAME; - const noRelease = response(); - await configureHandler(request({ method: 'POST' }), noRelease); - expect(noRelease.statusCode).toBe(500); - expect(mockGetConfig).not.toHaveBeenCalled(); - }); - - it('does not request a second restart', async () => { - process.env.HELM_RELEASE_NAME = 'lifecycle'; - mockGetConfig.mockResolvedValueOnce({ restarted: true }); - const res = response(); - await configureHandler(request({ method: 'POST' }), res); - expect(res.statusCode).toBe(400); - expect(mockShellPromise).not.toHaveBeenCalled(); - }); - - it('persists domain defaults, refreshes config, restarts, and marks setup', async () => { - process.env.HELM_RELEASE_NAME = 'lifecycle'; - const res = response(); - await configureHandler(request({ method: 'POST' }), res); - - expect(mockSetConfig).toHaveBeenNthCalledWith(1, 'lifecycleDefaults', { - existing: 'lifecycle', - ecrDomain: 'distribution.example.test', - defaultPublicUrl: 'dev-0.example.test', - }); - expect(mockSetConfig).toHaveBeenNthCalledWith(2, 'domainDefaults', { - existing: 'domain', - http: 'example.test', - grpc: 'example.test', - publicScheme: 'https', - }); - expect(mockGetAllConfigs).toHaveBeenCalledWith(true); - expect(mockShellPromise).toHaveBeenCalledWith( - 'kubectl rollout restart deployment -l app.kubernetes.io/instance=lifecycle,app.kubernetes.io/name=lifecycle -n lifecycle-system' - ); - expect(mockSetConfig).toHaveBeenNthCalledWith(3, 'app_setup', { - state: 'fixed-state', - installed: false, - restarted: true, - }); - expect(res.statusCode).toBe(200); - }); - - it('returns a stable error when configuration or restart fails', async () => { - process.env.HELM_RELEASE_NAME = 'lifecycle'; - mockShellPromise.mockRejectedValueOnce(new Error('rollout failed')); - const res = response(); - await configureHandler(request({ method: 'POST' }), res); - expect(res.statusCode).toBe(500); - expect(res.body).toEqual({ error: 'Restarting deployment failed' }); - }); - - it('can configure when app_setup has not been initialized', async () => { - process.env.HELM_RELEASE_NAME = 'lifecycle'; - mockGetConfig - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce({ existing: 'lifecycle' }) - .mockResolvedValueOnce({ existing: 'domain' }); - const res = response(); - await configureHandler(request({ method: 'POST' }), res); - expect(res.statusCode).toBe(200); - expect(mockSetConfig).toHaveBeenLastCalledWith('app_setup', { restarted: true }); - }); - }); - - describe('GET /setup/status', () => { - it('enforces GET and returns defaults for absent setup state', async () => { - const methodRes = response(); - await statusHandler(request({ method: 'POST' }), methodRes); - expect(methodRes.statusCode).toBe(405); - - mockGetConfig.mockResolvedValueOnce(undefined); - const emptyRes = response(); - await statusHandler(request(), emptyRes); - expect(emptyRes.body).toEqual({ installed: false, created: false, restarted: false, url: '' }); - }); - - it('returns persisted setup status fields', async () => { - mockGetConfig.mockResolvedValueOnce({ installed: true, created: true, restarted: true, url: 'app-url' }); - const res = response(); - await statusHandler(request(), res); - expect(res.body).toEqual({ installed: true, created: true, restarted: true, url: 'app-url' }); - }); - }); -}); diff --git a/src/__tests__/pages-api/v1Retirement.test.ts b/src/__tests__/pages-api/v1Retirement.test.ts new file mode 100644 index 00000000..72b0069c --- /dev/null +++ b/src/__tests__/pages-api/v1Retirement.test.ts @@ -0,0 +1,39 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { existsSync, readdirSync, readFileSync } from 'fs'; +import path from 'path'; + +const root = path.resolve(__dirname, '../../..'); +function sourceFiles(directory: string): string[] { + if (!existsSync(directory)) return []; + return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const name = path.join(directory, entry.name); + return entry.isDirectory() ? sourceFiles(name) : /\.[jt]sx?$/.test(name) ? [name] : []; + }); +} + +it('does not register the retired v1 API in either Next router', () => { + expect(sourceFiles(path.join(root, 'src/pages/api/v1'))).toEqual([]); + expect(sourceFiles(path.join(root, 'src/app/api/v1'))).toEqual([]); +}); + +it('does not leave first-party pages or the environment banner calling the retired API', () => { + const files = [...sourceFiles(path.join(root, 'src/pages')), path.join(root, 'public/utils/0-banner.js')]; + const callers = files.filter((file) => readFileSync(file, 'utf8').includes('/api/v1')); + expect(callers).toEqual([]); + expect(readFileSync(path.join(root, 'public/utils/0-banner.js'), 'utf8')).not.toContain('new EventSource'); +}); diff --git a/src/app/api/setup/__tests__/setupRoutes.test.ts b/src/app/api/setup/__tests__/setupRoutes.test.ts new file mode 100644 index 00000000..964d22de --- /dev/null +++ b/src/app/api/setup/__tests__/setupRoutes.test.ts @@ -0,0 +1,364 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createHmac } from 'crypto'; +import { NextRequest } from 'next/server'; + +let mockSetupToken = 'operator-token'; +let mockEnvCredentials: Record = {}; +let mockRecords: Record = {}; +let mockSecret: any; +let mockCommitFailure = false; +let mockTransactionQueue = Promise.resolve(); +const mockLock = jest.fn(); +const mockInvalidate = jest.fn(); +const mockExec = jest.fn(); +const mockReadSecret = jest.fn(); +const mockWriteSecret = jest.fn(); +const mockAppAuth = jest.fn(); +const mockLogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; + +function mockTrx(_table: string) { + let key = ''; + return { + insert(value: { key: string; config: unknown }) { + return { + onConflict: () => ({ + ignore: async () => { + mockRecords[value.key] ??= structuredClone(value.config); + }, + }), + }; + }, + where(value: { key: string }) { + key = value.key; + return this; + }, + forUpdate() { + mockLock(key); + return this; + }, + async first() { + return { config: structuredClone(mockRecords[key]) }; + }, + async update(value: { config: unknown }) { + mockRecords[key] = structuredClone(value.config); + }, + }; +} +mockTrx.raw = jest.fn().mockResolvedValue(undefined); +const mockTransaction = jest.fn((action) => { + const result = mockTransactionQueue.then(async () => { + const snapshot = structuredClone(mockRecords); + try { + const value = await action(mockTrx); + if (mockCommitFailure) { + mockCommitFailure = false; + throw new Error('simulated commit failure'); + } + return value; + } catch (error) { + mockRecords = snapshot; + throw error; + } + }); + mockTransactionQueue = result.then( + () => undefined, + () => undefined + ); + return result; +}); + +jest.mock('server/services/globalConfig', () => ({ + __esModule: true, + default: { + getInstance: () => ({ + db: { knex: { transaction: mockTransaction } }, + getConfig: async (key: string) => mockRecords[key], + invalidateCache: (...args: unknown[]) => mockInvalidate(...args), + }), + }, +})); +jest.mock('server/lib/kubernetes', () => ({ + getCurrentNamespaceFromFile: () => 'lifecycle', + getK8sApi: () => ({ + addInterceptor: jest.fn(), + readNamespacedSecret: mockReadSecret, + replaceNamespacedSecret: mockWriteSecret, + createNamespacedSecret: mockWriteSecret, + }), +})); +jest.mock('@octokit/auth-app', () => ({ createAppAuth: () => mockAppAuth })); +jest.mock('child_process', () => ({ execFile: (...args: unknown[]) => mockExec(...args) })); +jest.mock('server/lib/logger', () => ({ getLogger: () => mockLogger })); +jest.mock('server/lib/dependencies', () => ({})); +jest.mock('server/services/apiToken', () => ({ scopeSatisfies: () => true })); +jest.mock('server/services/authRateLimit', () => ({ checkApiKeyRateLimit: async () => ({ allowed: true }) })); +jest.mock('server/services/authAudit', () => ({ recordAuthAuditEvent: jest.fn() })); +jest.mock('server/lib/principal', () => ({ resolvePrincipal: jest.fn() })); +jest.mock('shared/config', () => ({ + APP_HOST: 'https://lifecycle.example.test', + GITHUB_APP_AUTH_CALLBACK: 'https://auth.example.test/callback', + SECRET_BOOTSTRAP_NAME: 'bootstrap', + get LIFECYCLE_SETUP_TOKEN() { + return mockSetupToken; + }, + get GITHUB_APP_ID() { + return mockEnvCredentials.GITHUB_APP_ID ?? 'YOUR_VALUE_HERE'; + }, + get GITHUB_CLIENT_ID() { + return mockEnvCredentials.GITHUB_CLIENT_ID ?? 'YOUR_VALUE_HERE'; + }, + get GITHUB_CLIENT_SECRET() { + return mockEnvCredentials.GITHUB_CLIENT_SECRET ?? 'YOUR_VALUE_HERE'; + }, + get GITHUB_PRIVATE_KEY() { + return mockEnvCredentials.GITHUB_PRIVATE_KEY ?? 'YOUR_VALUE_HERE'; + }, + get GITHUB_WEBHOOK_SECRET() { + return mockEnvCredentials.GITHUB_WEBHOOK_SECRET ?? 'YOUR_VALUE_HERE'; + }, +})); + +import { POST as manifest } from '../manifest/route'; +import { GET as callback } from '../callback/route'; +import { GET as installed } from '../installed/route'; +import { POST as configure } from '../configure/route'; +import { GET as status } from '../status/route'; +import { getSetupToken } from '../setupAccess'; + +const originalEnv = { ...process.env }; +const originalFetch = global.fetch; +function req(path: string, options: { method?: string; token?: string; body?: unknown } = {}) { + return new NextRequest(`https://lifecycle.example.test/api/setup/${path}`, { + method: options.method || 'GET', + headers: options.token === undefined ? {} : { 'x-setup-token': options.token }, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); +} +const post = (path: string, body?: unknown) => req(path, { method: 'POST', token: 'operator-token', body }); +const decodeSecret = (key: string) => Buffer.from(mockSecret.data[key], 'base64').toString('utf8'); + +beforeEach(() => { + jest.clearAllMocks(); + mockSetupToken = 'operator-token'; + mockEnvCredentials = {}; + mockCommitFailure = false; + mockTransactionQueue = Promise.resolve(); + mockRecords = { + app_setup: { created: false, installed: false, restarted: false, state: '', retained: 'setting' }, + lifecycleDefaults: { + ecrDomain: 'distribution.example.com', + defaultPublicUrl: 'dev-0.127.0.0.1.nip.io', + extra: true, + }, + domainDefaults: { http: '127.0.0.1.nip.io', grpc: '127.0.0.1.nip.io' }, + }; + mockSecret = { + metadata: { name: 'bootstrap', resourceVersion: '1', annotations: { existing: 'annotation' } }, + data: { OTHER_KEY: Buffer.from('preserved').toString('base64') }, + }; + mockReadSecret.mockReset().mockImplementation(async () => ({ body: structuredClone(mockSecret) })); + mockWriteSecret.mockReset().mockImplementation(async (_name, _namespace, body) => { + const next = body || _namespace; + if (next.metadata.resourceVersion && next.metadata.resourceVersion !== mockSecret.metadata.resourceVersion) + throw new Error('resourceVersion conflict'); + mockSecret = structuredClone(next); + mockSecret.metadata.resourceVersion = String(Number(mockSecret.metadata.resourceVersion || 0) + 1); + }); + mockInvalidate.mockResolvedValue(undefined); + mockExec.mockReset().mockImplementation((_file, _args, _options, done) => done(null, '', '')); + mockAppAuth.mockResolvedValue({ token: 'app-jwt' }); + process.env.HELM_RELEASE_NAME = 'lifecycle'; + delete process.env.ENCRYPTION_KEY; + global.fetch = jest.fn().mockImplementation(async (url: string) => ({ + ok: true, + json: async () => + url.includes('/conversions') + ? { + id: 123, + client_id: 'client', + client_secret: 'client-secret', + webhook_secret: 'webhook-secret', + pem: 'private-key', + html_url: 'https://github.com/apps/fresh-app', + slug: 'fresh-app', + } + : { app_id: 123 }, + })) as any; +}); +afterAll(() => { + process.env = originalEnv; + global.fetch = originalFetch; +}); + +async function start() { + const response = await manifest(post('manifest', { appName: 'fresh-app', org: 'example' })); + expect(response.status).toBe(200); + return mockRecords.app_setup.state; +} +async function create() { + const state = await start(); + const response = await callback(req(`callback?code=code&state=${state}`)); + expect(response.status).toBe(307); + return mockRecords.app_setup.state; +} +async function install() { + const state = await create(); + const response = await installed(req(`installed?installation_id=456&setup_action=install&state=${state}`)); + expect(response.status).toBe(307); +} + +it('requires the operator token before reading settings or mutating setup', async () => { + for (const token of [undefined, 'wrong-token']) { + const response = await manifest(req('manifest', { method: 'POST', token, body: { appName: 'fresh-app' } })); + expect(response.status).toBe(401); + } + expect(mockTransaction).not.toHaveBeenCalled(); + expect(mockReadSecret).not.toHaveBeenCalled(); +}); +it('uses a shared derivation and refuses missing keys instead of generating per-pod tokens', () => { + mockSetupToken = ''; + expect(() => getSetupToken()).toThrow('shared setup token'); + process.env.ENCRYPTION_KEY = 'shared-encryption-key'; + expect(getSetupToken()).toBe( + createHmac('sha256', 'shared-encryption-key').update('lifecycle-setup-token').digest('hex').slice(0, 32) + ); + expect(mockLogger.info).not.toHaveBeenCalled(); +}); +it('completes a fresh setup, verifies the installation and retains unrelated data', async () => { + await install(); + const response = await configure(post('configure')); + expect(response.status).toBe(200); + expect(mockLock).toHaveBeenCalledWith('app_setup'); + expect(mockRecords.app_setup).toMatchObject({ phase: 'complete', retained: 'setting', state: '', restarted: true }); + expect(decodeSecret('GITHUB_APP_INSTALLATION_ID')).toBe('456'); + expect(decodeSecret('OTHER_KEY')).toBe('preserved'); + expect(mockSecret.metadata.annotations.existing).toBe('annotation'); + expect(mockRecords.domainDefaults).toEqual({ http: 'example.test', grpc: 'example.test', publicScheme: 'https' }); + expect(mockRecords.lifecycleDefaults).toEqual({ + ecrDomain: 'distribution.example.test', + defaultPublicUrl: 'dev-0.example.test', + extra: true, + }); + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.github.com/app/installations/456', + expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer app-jwt' }) }) + ); + expect(mockExec).toHaveBeenCalledWith( + 'kubectl', + [ + 'rollout', + 'restart', + 'deployment', + '-l', + 'app.kubernetes.io/instance=lifecycle,app.kubernetes.io/name=lifecycle', + '-n', + 'lifecycle', + ], + { timeout: 15000 }, + expect.any(Function) + ); + expect((await configure(post('configure'))).status).toBe(403); + expect((await manifest(post('manifest', { appName: 'replacement' }))).status).toBe(403); +}); +it.each(['env', 'secret'])('locks configured %s credentials even if app_setup flags are false', async (source) => { + if (source === 'env') mockEnvCredentials.GITHUB_APP_ID = '123'; + else mockSecret.data.GITHUB_APP_ID = Buffer.from('123').toString('base64'); + expect((await manifest(post('manifest', { appName: 'replacement' }))).status).toBe(403); + expect(mockWriteSecret).not.toHaveBeenCalled(); + expect(global.fetch).not.toHaveBeenCalled(); +}); +it('fails closed when the live bootstrap Secret cannot be read', async () => { + mockReadSecret.mockRejectedValue({ response: { statusCode: 403 } }); + expect((await manifest(post('manifest', { appName: 'fresh-app' }))).status).toBe(503); + expect(mockWriteSecret).not.toHaveBeenCalled(); +}); +it('rejects legacy, expired and wrong callback state before contacting GitHub', async () => { + mockRecords.app_setup = { state: 'old-state', created: false }; + expect((await callback(req('callback?code=x&state=old-state'))).status).toBe(400); + const state = await start(); + expect((await callback(req('callback?code=x&state=wrong'))).status).toBe(400); + mockRecords.app_setup.expiresAt = Date.now() - 1; + expect((await callback(req(`callback?code=x&state=${state}`))).status).toBe(400); + expect(global.fetch).not.toHaveBeenCalled(); +}); +it('serializes concurrent callback replays and converts the manifest once', async () => { + const state = await start(); + const responses = await Promise.all([ + callback(req(`callback?code=x&state=${state}`)), + callback(req(`callback?code=x&state=${state}`)), + ]); + expect(responses.map((response) => response.status)).toEqual([307, 400]); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(mockWriteSecret).toHaveBeenCalledTimes(1); +}); +it('recovers the same created App after the Secret is saved but the DB transaction fails', async () => { + const state = await start(); + mockCommitFailure = true; + expect((await callback(req(`callback?code=x&state=${state}`))).status).toBe(500); + expect(mockRecords.app_setup.phase).toBe('manifest'); + expect(decodeSecret('GITHUB_APP_ID')).toBe('123'); + expect((await callback(req(`callback?code=x&state=${state}`))).status).toBe(307); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(mockWriteSecret).toHaveBeenCalledTimes(1); +}); +it('does not adopt externally changed credentials that retained setup annotations', async () => { + await create(); + mockSecret.data.GITHUB_CLIENT_SECRET = Buffer.from('operator-replacement').toString('base64'); + expect((await manifest(post('manifest'))).status).toBe(403); + expect((await installed(req(`installed?installation_id=456&state=${mockRecords.app_setup.state}`))).status).toBe(403); +}); +it('rejects an installation from another App without marking setup complete', async () => { + const state = await create(); + (global.fetch as jest.Mock).mockResolvedValueOnce({ ok: true, json: async () => ({ app_id: 999 }) }); + expect((await installed(req(`installed?installation_id=456&state=${state}`))).status).toBe(403); + expect(mockRecords.app_setup.phase).toBe('installation'); + expect(mockSecret.data.GITHUB_APP_INSTALLATION_ID).toBeUndefined(); +}); +it('keeps installation callback retryable when its Secret write fails', async () => { + const state = await create(); + mockWriteSecret.mockRejectedValueOnce(new Error('unavailable')); + expect((await installed(req(`installed?installation_id=456&state=${state}`))).status).toBe(503); + expect(mockRecords.app_setup.phase).toBe('installation'); + expect((await installed(req(`installed?installation_id=456&state=${state}`))).status).toBe(307); +}); +it('preserves operator domain and registry settings and permits a failed restart to retry', async () => { + await install(); + mockRecords.domainDefaults = { http: 'custom.test', grpc: 'grpc.custom.test', publicScheme: 'http' }; + mockRecords.lifecycleDefaults = { ecrDomain: 'registry.custom.test', defaultPublicUrl: 'dev.custom.test' }; + mockExec.mockImplementationOnce((_file, _args, _options, done) => done(new Error('unavailable'))); + expect((await configure(post('configure'))).status).toBe(503); + expect(mockRecords.app_setup.phase).toBe('installed'); + expect((await configure(post('configure'))).status).toBe(200); + expect(mockRecords.domainDefaults).toEqual({ http: 'custom.test', grpc: 'grpc.custom.test', publicScheme: 'http' }); + expect(mockRecords.lifecycleDefaults).toEqual({ + ecrDomain: 'registry.custom.test', + defaultPublicUrl: 'dev.custom.test', + }); +}); +it('returns no state or token from status and prevents caching bootstrap state responses', async () => { + const response = await manifest(post('manifest', { appName: 'fresh-app' })); + const payload = await response.json(); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(payload.data.manifest.setup_url).toBe('https://lifecycle.example.test/api/setup/installed'); + expect(payload.data.manifest.redirect_url).toBe('https://lifecycle.example.test/api/setup/callback'); + const statusResponse = await status(req('status')); + const statusText = await statusResponse.text(); + expect(statusText).not.toContain(mockRecords.app_setup.state); + expect(statusText).not.toContain('operator-token'); + expect(statusResponse.headers.get('cache-control')).toBe('private, no-store'); +}); diff --git a/src/app/api/setup/callback/route.ts b/src/app/api/setup/callback/route.ts new file mode 100644 index 00000000..764308e7 --- /dev/null +++ b/src/app/api/setup/callback/route.ts @@ -0,0 +1,98 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { NextRequest, NextResponse } from 'next/server'; +import { AppError, BadRequestError } from 'server/lib/appError'; +import { createPublicApiHandler } from 'server/lib/createApiHandler'; +import { withNoStore } from 'server/lib/response'; +import { + assertSetupState, + credentialFingerprint, + hasCredentials, + isEnvConfigured, + nextFlow, + readBootstrapSecret, + recoveredApp, + setupLocked, + validGithubAppUrl, + withSetupTransaction, + writeBootstrapSecret, +} from '../setupAccess'; + +export const runtime = 'nodejs'; +export const GET = createPublicApiHandler(async (req: NextRequest) => { + const code = req.nextUrl.searchParams.get('code'); + const state = req.nextUrl.searchParams.get('state'); + if (!code || !state) + throw new BadRequestError( + 'GitHub did not return the setup code and state. Start setup again.', + 'missing_callback_params' + ); + return withSetupTransaction(async (flow, _trx, save) => { + assertSetupState(flow, state, 'manifest'); + let secret = await readBootstrapSecret(); + let app = recoveredApp(flow, secret); + if (!app) { + if (hasCredentials(secret) || isEnvConfigured()) throw setupLocked(); + const response = await fetch(`https://api.github.com/app-manifests/${encodeURIComponent(code)}/conversions`, { + method: 'POST', + headers: { Accept: 'application/vnd.github+json' }, + signal: AbortSignal.timeout(15_000), + }); + if (!response.ok) + throw new AppError({ + httpStatus: 502, + code: 'github_conversion_failed', + message: 'GitHub could not create the App. Start setup again.', + }); + const { id, client_id, client_secret, webhook_secret, pem, html_url, slug } = await response.json(); + if ( + !id || + ![client_id, client_secret, webhook_secret, pem, slug].every( + (value) => typeof value === 'string' && value.length > 0 + ) || + !validGithubAppUrl(html_url) + ) { + throw new AppError({ + httpStatus: 502, + code: 'github_incomplete_credentials', + message: 'GitHub returned incomplete App settings. Start setup again.', + }); + } + await writeBootstrapSecret( + secret, + { + GITHUB_APP_ID: String(id), + GITHUB_CLIENT_ID: client_id, + GITHUB_CLIENT_SECRET: client_secret, + GITHUB_WEBHOOK_SECRET: webhook_secret, + GITHUB_PRIVATE_KEY: pem.replace(/\n/g, '\\n'), + }, + { id: flow.flowId!, url: html_url, name: slug } + ); + secret = await readBootstrapSecret(); + app = recoveredApp(flow, secret); + if (!app) throw setupLocked(); + } + const next = nextFlow( + { ...flow, created: true, url: app.url, name: app.name, credentialFingerprint: credentialFingerprint(secret) }, + 'installation' + ); + await save(next); + const response = withNoStore(NextResponse.redirect(`${app.url}/installations/new?state=${next.state}`)); + response.headers.set('Referrer-Policy', 'no-referrer'); + return response; + }); +}); diff --git a/src/app/api/setup/configure/route.ts b/src/app/api/setup/configure/route.ts new file mode 100644 index 00000000..ba1f7ab1 --- /dev/null +++ b/src/app/api/setup/configure/route.ts @@ -0,0 +1,96 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import type { NextRequest } from 'next/server'; +import { AppError, ConflictError } from 'server/lib/appError'; +import { createPublicApiHandler } from 'server/lib/createApiHandler'; +import { getCurrentNamespaceFromFile } from 'server/lib/kubernetes'; +import { successResponse, withNoStore } from 'server/lib/response'; +import { APP_HOST } from 'shared/config'; +import { + assertCurrentApp, + assertSetupActor, + readBootstrapSecret, + setupLocked, + withSetupTransaction, +} from '../setupAccess'; + +export const runtime = 'nodejs'; +const execFileAsync = promisify(execFile); +const DEFAULT_VALUES = new Set([ + '', + '127.0.0.1.nip.io', + 'dev-0.127.0.0.1.nip.io', + 'distribution.example.com', + '10.96.188.230:5000', +]); + +export const POST = createPublicApiHandler(async (req: NextRequest) => { + assertSetupActor(req); + return withSetupTransaction(async (flow, trx, save) => { + if (flow.phase === 'complete' || flow.restarted) throw setupLocked(); + if (flow.phase !== 'installed' || !flow.installed) + throw new ConflictError('Install the GitHub App before configuring Lifecycle.', 'app_not_installed'); + assertCurrentApp(flow, await readBootstrapSecret()); + const namespace = getCurrentNamespaceFromFile(); + const releaseName = process.env.HELM_RELEASE_NAME; + if (!namespace || !releaseName) + throw new AppError({ + httpStatus: 503, + code: 'restart_unavailable', + message: 'Configure the namespace and Helm release before restarting Lifecycle.', + }); + const publicUrl = new URL(APP_HOST); + const domain = publicUrl.hostname.split('.').slice(1).join('.') || publicUrl.hostname; + for (const [key, defaults] of [ + ['lifecycleDefaults', { ecrDomain: `distribution.${domain}`, defaultPublicUrl: `dev-0.${domain}` }], + ['domainDefaults', { http: domain, grpc: domain, publicScheme: publicUrl.protocol.replace(':', '') }], + ] as const) { + await trx('global_config').insert({ key, config: {} }).onConflict('key').ignore(); + const row = await trx('global_config').where({ key }).forUpdate().first(); + const current = typeof row.config === 'string' ? JSON.parse(row.config) : row.config || {}; + const next = { ...current }; + for (const [name, value] of Object.entries(defaults)) { + if (current[name] == null || DEFAULT_VALUES.has(current[name])) next[name] = value; + } + await trx('global_config').where({ key }).update({ config: next }); + } + try { + await execFileAsync( + 'kubectl', + [ + 'rollout', + 'restart', + 'deployment', + '-l', + `app.kubernetes.io/instance=${releaseName},app.kubernetes.io/name=lifecycle`, + '-n', + namespace, + ], + { timeout: 15_000 } + ); + } catch { + throw new AppError({ + httpStatus: 503, + code: 'restart_failed', + message: 'Lifecycle could not restart its deployments. Retry or restart them through your deployment tools.', + }); + } + await save({ ...flow, state: '', phase: 'complete', restarted: true }); + return withNoStore(successResponse({ restarted: true }, { status: 200 }, req)); + }); +}); diff --git a/src/app/api/setup/installed/route.ts b/src/app/api/setup/installed/route.ts new file mode 100644 index 00000000..3946ce72 --- /dev/null +++ b/src/app/api/setup/installed/route.ts @@ -0,0 +1,67 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createAppAuth } from '@octokit/auth-app'; +import { NextRequest, NextResponse } from 'next/server'; +import { AppError, BadRequestError } from 'server/lib/appError'; +import { createPublicApiHandler } from 'server/lib/createApiHandler'; +import { withNoStore } from 'server/lib/response'; +import { APP_HOST } from 'shared/config'; +import { + assertCurrentApp, + assertSetupState, + readBootstrapSecret, + withSetupTransaction, + writeBootstrapSecret, +} from '../setupAccess'; + +export const runtime = 'nodejs'; +export const GET = createPublicApiHandler(async (req: NextRequest) => { + const installationId = req.nextUrl.searchParams.get('installation_id'); + const setupAction = req.nextUrl.searchParams.get('setup_action'); + if (!installationId || !/^\d{1,20}$/.test(installationId)) + throw new BadRequestError( + 'GitHub did not return a valid installation ID. Install the App again.', + 'invalid_installation_id' + ); + if (setupAction && setupAction !== 'install') + throw new BadRequestError('Only a new installation can finish setup here.', 'unsupported_setup_action'); + return withSetupTransaction(async (flow, _trx, save) => { + assertSetupState(flow, req.nextUrl.searchParams.get('state'), 'installation'); + const secret = await readBootstrapSecret(); + assertCurrentApp(flow, secret); + const auth = createAppAuth({ + appId: secret.values.GITHUB_APP_ID, + privateKey: secret.values.GITHUB_PRIVATE_KEY.replace(/\\n/g, '\n'), + }); + const { token } = await auth({ type: 'app' }); + const installation = await fetch(`https://api.github.com/app/installations/${installationId}`, { + headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${token}` }, + signal: AbortSignal.timeout(15_000), + }); + if (!installation.ok || String((await installation.json()).app_id) !== secret.values.GITHUB_APP_ID) { + throw new AppError({ + httpStatus: 403, + code: 'installation_not_allowed', + message: 'This installation does not belong to the GitHub App created by setup.', + }); + } + await writeBootstrapSecret(secret, { GITHUB_APP_INSTALLATION_ID: installationId }); + await save({ ...flow, state: '', phase: 'installed', installed: true }); + const response = withNoStore(NextResponse.redirect(new URL('/setup/complete', APP_HOST))); + response.headers.set('Referrer-Policy', 'no-referrer'); + return response; + }); +}); diff --git a/src/app/api/setup/manifest/route.ts b/src/app/api/setup/manifest/route.ts new file mode 100644 index 00000000..f4ca15c2 --- /dev/null +++ b/src/app/api/setup/manifest/route.ts @@ -0,0 +1,112 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { NextRequest } from 'next/server'; +import { BadRequestError } from 'server/lib/appError'; +import { createPublicApiHandler } from 'server/lib/createApiHandler'; +import { successResponse, withNoStore } from 'server/lib/response'; +import { APP_HOST, GITHUB_APP_AUTH_CALLBACK } from 'shared/config'; +import { + assertSetupActor, + hasCredentials, + isEnvConfigured, + readBootstrapSecret, + recoveredApp, + credentialFingerprint, + nextFlow, + setupLocked, + withSetupTransaction, +} from '../setupAccess'; + +export const runtime = 'nodejs'; + +export const POST = createPublicApiHandler(async (req: NextRequest) => { + assertSetupActor(req); + return withSetupTransaction(async (flow, _trx, save) => { + if (flow.installed || flow.restarted) throw setupLocked(); + const secret = await readBootstrapSecret(); + if (hasCredentials(secret) || isEnvConfigured()) { + const app = recoveredApp(flow, secret); + if (!app || flow.installed) throw setupLocked(); + const next = nextFlow( + { ...flow, created: true, url: app.url, name: app.name, credentialFingerprint: credentialFingerprint(secret) }, + 'installation' + ); + await save(next); + return withNoStore( + successResponse({ installUrl: `${app.url}/installations/new?state=${next.state}` }, { status: 200 }, req) + ); + } + const body = await req.json().catch(() => null); + if (!body || typeof body !== 'object' || Array.isArray(body)) + throw new BadRequestError('Enter the GitHub App name.', 'invalid_body'); + const appName = typeof body.appName === 'string' ? body.appName.trim() : ''; + const org = typeof body.org === 'string' ? body.org.trim() : ''; + if (!/^[a-zA-Z0-9-]{1,34}$/.test(appName)) throw new BadRequestError('App name is not valid.', 'invalid_app_name'); + if (org && !/^[a-zA-Z0-9-]{1,39}$/.test(org)) + throw new BadRequestError('Organization name is not valid.', 'invalid_organization'); + const appUrl = APP_HOST; + let publicUrl: URL; + try { + publicUrl = new URL(appUrl); + } catch { + throw new BadRequestError('Application public URL is not valid.', 'invalid_app_host'); + } + if (!['https:', 'http:'].includes(publicUrl.protocol) || publicUrl.username || publicUrl.password) + throw new BadRequestError('Application public URL is not valid.', 'invalid_app_host'); + const manifest = { + name: appName, + url: appUrl, + hook_attributes: { url: `${appUrl}/api/webhooks/github`, active: true }, + setup_url: `${appUrl}/api/setup/installed`, + redirect_url: `${appUrl}/api/setup/callback`, + callback_urls: [`${appUrl}/api/setup/callback`, `${GITHUB_APP_AUTH_CALLBACK}`], + public: false, + default_permissions: { + contents: 'write', + deployments: 'write', + issues: 'write', + members: 'read', + metadata: 'read', + pull_requests: 'write', + statuses: 'read', + // GitHub refuses .github/workflows writes under contents:write alone; the agent's update_file tool needs this. + workflows: 'write', + emails: 'read', + }, + default_events: [ + 'issues', + 'issue_comment', + 'label', + 'membership', + 'organization', + 'repository', + 'public', + 'pull_request', + 'push', + 'team', + ], + }; + const next = nextFlow( + { ...flow, flowId: undefined, created: false, installed: false, restarted: false, org, url: '' }, + 'manifest' + ); + await save(next); + const actionUrl = org + ? `https://github.com/organizations/${org}/settings/apps/new?state=${next.state}` + : `https://github.com/settings/apps/new?state=${next.state}`; + return withNoStore(successResponse({ actionUrl, manifest }, { status: 200 }, req)); + }); +}); diff --git a/src/app/api/setup/setupAccess.ts b/src/app/api/setup/setupAccess.ts new file mode 100644 index 00000000..d28a4206 --- /dev/null +++ b/src/app/api/setup/setupAccess.ts @@ -0,0 +1,253 @@ +/** + * Copyright 2026 GoodRx, Inc. + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy at http://www.apache.org/licenses/LICENSE-2.0 + */ +import { createHash, createHmac, randomBytes, timingSafeEqual } from 'crypto'; +import type { V1Secret } from '@kubernetes/client-node'; +import type { Knex } from 'knex'; +import type { NextRequest } from 'next/server'; +import { AppError, BadRequestError } from 'server/lib/appError'; +import { getCurrentNamespaceFromFile, getK8sApi } from 'server/lib/kubernetes'; +import { getLogger } from 'server/lib/logger'; +import GlobalConfigService from 'server/services/globalConfig'; +import type { AppSetup } from 'server/services/types/globalConfig'; +import { + GITHUB_APP_ID, + GITHUB_CLIENT_ID, + GITHUB_CLIENT_SECRET, + GITHUB_PRIVATE_KEY, + GITHUB_WEBHOOK_SECRET, + LIFECYCLE_SETUP_TOKEN, + SECRET_BOOTSTRAP_NAME, +} from 'shared/config'; + +const PLACEHOLDERS = new Set(['', 'your_value_here', 'not_setup', 'replace_me', 'changeme']); +const CREDENTIAL_KEYS = [ + 'GITHUB_APP_ID', + 'GITHUB_CLIENT_ID', + 'GITHUB_CLIENT_SECRET', + 'GITHUB_PRIVATE_KEY', + 'GITHUB_WEBHOOK_SECRET', +] as const; +const FLOW_ANNOTATION = 'lifecycle.dev/setup-flow'; +const URL_ANNOTATION = 'lifecycle.dev/setup-app-url'; +const NAME_ANNOTATION = 'lifecycle.dev/setup-app-name'; +const FINGERPRINT_ANNOTATION = 'lifecycle.dev/setup-credential-sha256'; +const FLOW_TTL_MS = 60 * 60 * 1000; + +export type SetupFlow = Partial & { + flowVersion?: 1; + flowId?: string; + phase?: 'manifest' | 'installation' | 'installed' | 'complete'; + expiresAt?: number; + credentialFingerprint?: string; +}; +export type BootstrapSecret = { body: V1Secret | null; values: Record }; + +function real(value: unknown): boolean { + return !PLACEHOLDERS.has( + String(value ?? '') + .trim() + .toLowerCase() + ); +} +function equal(expected: string, presented: string): boolean { + const digest = (value: string) => createHash('sha256').update(value).digest(); + return timingSafeEqual(digest(expected), digest(presented)); +} +export function isEnvConfigured(): boolean { + return [GITHUB_APP_ID, GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_PRIVATE_KEY, GITHUB_WEBHOOK_SECRET].some(real); +} +export function hasCredentials(secret: BootstrapSecret): boolean { + return CREDENTIAL_KEYS.some((key) => real(secret.values[key])); +} +export function credentialFingerprint(secret: BootstrapSecret): string { + return createHash('sha256') + .update(JSON.stringify(CREDENTIAL_KEYS.map((key) => secret.values[key] || ''))) + .digest('hex'); +} +export function getSetupToken(): string { + if (real(LIFECYCLE_SETUP_TOKEN)) return String(LIFECYCLE_SETUP_TOKEN).trim(); + const key = process.env.ENCRYPTION_KEY?.trim(); + if (real(key)) return createHmac('sha256', key!).update('lifecycle-setup-token').digest('hex').slice(0, 32); + throw new AppError({ + httpStatus: 503, + code: 'setup_token_unavailable', + message: 'Configure a shared setup token before starting setup.', + }); +} +export function assertSetupActor(req: NextRequest): void { + const presented = req.headers.get('x-setup-token'); + if (!presented) + throw new AppError({ + httpStatus: 401, + code: 'setup_token_required', + message: 'Enter the setup token to continue.', + }); + if (!equal(getSetupToken(), presented)) + throw new AppError({ httpStatus: 401, code: 'invalid_setup_token', message: 'That setup token is not valid.' }); +} +export function setupLocked(): AppError { + return new AppError({ + httpStatus: 403, + code: 'setup_locked', + message: 'GitHub App setup is locked. Manage the configured App through your installation settings.', + }); +} +export function newSetupState(): string { + return randomBytes(24).toString('base64url'); +} +export function activeFlow(flow: SetupFlow): boolean { + return ( + flow.flowVersion === 1 && + !!flow.flowId && + Number.isFinite(flow.expiresAt) && + flow.expiresAt! > Date.now() && + flow.phase !== 'complete' + ); +} +export function assertSetupState(flow: SetupFlow, presented: string | null, phase: SetupFlow['phase']): void { + if (!activeFlow(flow) || flow.phase !== phase || !flow.state || !presented || !equal(flow.state, presented)) { + throw new BadRequestError('This GitHub link has expired or was used. Start setup again.', 'invalid_setup_state'); + } +} +export function nextFlow(flow: SetupFlow, phase: SetupFlow['phase']): SetupFlow { + return { + ...flow, + flowVersion: 1, + flowId: flow.flowId || newSetupState(), + phase, + state: newSetupState(), + expiresAt: Date.now() + FLOW_TTL_MS, + }; +} +function bootstrapApi() { + const api = getK8sApi(); + api.addInterceptor((options) => { + options.timeout = 15_000; + }); + return api; +} +export async function readBootstrapSecret(): Promise { + try { + const { body } = await bootstrapApi().readNamespacedSecret(SECRET_BOOTSTRAP_NAME, getCurrentNamespaceFromFile()); + return { + body, + values: Object.fromEntries( + Object.entries(body.data || {}).map(([key, value]) => [key, Buffer.from(value, 'base64').toString('utf8')]) + ), + }; + } catch (error) { + const status = + (error as { response?: { statusCode?: number }; statusCode?: number }).response?.statusCode ?? + (error as { statusCode?: number }).statusCode; + if (status === 404) return { body: null, values: {} }; + throw new AppError({ + httpStatus: 503, + code: 'setup_state_unavailable', + message: 'Lifecycle could not read its GitHub App settings. Try again.', + }); + } +} +export async function writeBootstrapSecret( + secret: BootstrapSecret, + values: Record, + flow?: { id: string; url: string; name: string } +): Promise { + const body: V1Secret = { + ...secret.body, + apiVersion: 'v1', + kind: 'Secret', + metadata: { + ...secret.body?.metadata, + name: SECRET_BOOTSTRAP_NAME, + ...(flow + ? { + annotations: { + ...secret.body?.metadata?.annotations, + [FLOW_ANNOTATION]: flow.id, + [URL_ANNOTATION]: flow.url, + [NAME_ANNOTATION]: flow.name, + [FINGERPRINT_ANNOTATION]: credentialFingerprint({ body: null, values: { ...secret.values, ...values } }), + }, + } + : {}), + }, + data: { + ...secret.body?.data, + ...Object.fromEntries(Object.entries(values).map(([key, value]) => [key, Buffer.from(value).toString('base64')])), + }, + }; + try { + const api = bootstrapApi(); + if (secret.body) await api.replaceNamespacedSecret(SECRET_BOOTSTRAP_NAME, getCurrentNamespaceFromFile(), body); + else await api.createNamespacedSecret(getCurrentNamespaceFromFile(), body); + } catch { + throw new AppError({ + httpStatus: 503, + code: 'setup_secret_write_failed', + message: 'Lifecycle could not save the GitHub App settings. Retry this step.', + }); + } +} +export function recoveredApp(flow: SetupFlow, secret: BootstrapSecret): { url: string; name: string } | null { + const annotations = secret.body?.metadata?.annotations; + if ( + flow.flowVersion !== 1 || + !flow.flowId || + flow.phase === 'complete' || + !hasCredentials(secret) || + annotations?.[FLOW_ANNOTATION] !== flow.flowId + ) + return null; + const fingerprint = credentialFingerprint(secret); + if ( + annotations[FINGERPRINT_ANNOTATION] !== fingerprint || + (flow.credentialFingerprint && flow.credentialFingerprint !== fingerprint) + ) + return null; + if (!CREDENTIAL_KEYS.every((key) => real(secret.values[key]))) return null; + const url = annotations[URL_ANNOTATION]; + if (!validGithubAppUrl(url)) return null; + return { url, name: annotations[NAME_ANNOTATION] || '' }; +} +export function validGithubAppUrl(value: unknown): value is string { + if (typeof value !== 'string') return false; + try { + const url = new URL(value); + return ( + url.protocol === 'https:' && + url.hostname === 'github.com' && + /^\/apps\/[a-zA-Z0-9-]+$/.test(url.pathname) && + !url.username && + !url.password && + !url.search && + !url.hash + ); + } catch { + return false; + } +} +export function assertCurrentApp(flow: SetupFlow, secret: BootstrapSecret): void { + if (!recoveredApp(flow, secret) || flow.credentialFingerprint !== credentialFingerprint(secret)) throw setupLocked(); +} +export async function withSetupTransaction( + action: (flow: SetupFlow, trx: Knex.Transaction, save: (flow: SetupFlow) => Promise) => Promise +): Promise { + const service = GlobalConfigService.getInstance(); + let changed = false; + const result = await service.db.knex.transaction(async (trx) => { + await trx.raw("SET LOCAL lock_timeout = '5s'"); + await trx('global_config').insert({ key: 'app_setup', config: {} }).onConflict('key').ignore(); + const row = await trx('global_config').where({ key: 'app_setup' }).forUpdate().first(); + const flow = (typeof row.config === 'string' ? JSON.parse(row.config) : row.config) as SetupFlow; + return action(flow || {}, trx, async (next) => { + await trx('global_config').where({ key: 'app_setup' }).update({ config: next }); + changed = true; + }); + }); + if (changed) await service.invalidateCache().catch(() => getLogger().warn('Setup: config cache invalidation failed')); + return result; +} diff --git a/src/app/api/setup/status/route.ts b/src/app/api/setup/status/route.ts new file mode 100644 index 00000000..1a40b3cf --- /dev/null +++ b/src/app/api/setup/status/route.ts @@ -0,0 +1,41 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import type { NextRequest } from 'next/server'; +import { createPublicApiHandler } from 'server/lib/createApiHandler'; +import { successResponse, withNoStore } from 'server/lib/response'; +import GlobalConfigService from 'server/services/globalConfig'; +import { hasCredentials, isEnvConfigured, readBootstrapSecret, recoveredApp, type SetupFlow } from '../setupAccess'; + +export const runtime = 'nodejs'; +export const GET = createPublicApiHandler(async (req: NextRequest) => { + const flow = ((await GlobalConfigService.getInstance().getConfig('app_setup')) || {}) as SetupFlow; + const secret = await readBootstrapSecret(); + const locked = + (hasCredentials(secret) || isEnvConfigured()) && (!recoveredApp(flow, secret) || flow.phase === 'complete'); + return withNoStore( + successResponse( + { + installed: !!flow.installed, + created: !!flow.created, + restarted: !!flow.restarted, + url: flow.url || '', + locked, + }, + { status: 200 }, + req + ) + ); +}); diff --git a/src/app/api/v2/ai/agent/mcp-connections/[slug]/route.test.ts b/src/app/api/v2/ai/agent/mcp-connections/[slug]/route.test.ts index 7a8c87aa..18a3dbf6 100644 --- a/src/app/api/v2/ai/agent/mcp-connections/[slug]/route.test.ts +++ b/src/app/api/v2/ai/agent/mcp-connections/[slug]/route.test.ts @@ -257,6 +257,38 @@ describe('MCP user connection route', () => { ); }); + it('saves a stdio preset connection pending discovery without running the command here', async () => { + mockGetBySlugAndScope.mockResolvedValue({ + ...connectorConfig, + transport: { type: 'stdio', command: 'npx', args: ['-y', 'sample-mcp'] }, + authConfig: { + mode: 'user-fields', + schema: { + fields: [{ key: 'apiToken', label: 'API token', required: true, inputType: 'password' }], + bindings: [{ target: 'env', key: 'API_TOKEN', fieldKey: 'apiToken' }], + }, + }, + }); + + const response = await PUT(makeRequest({ values: { apiToken: 'sample-token' } }), { + params: Promise.resolve({ slug: 'sample-connector' }), + }); + + expect(response.status).toBe(200); + expect(mockDiscoverTools).not.toHaveBeenCalled(); + expect(mockUpsertConnection).toHaveBeenCalledTimes(1); + expect(mockUpsertConnection).toHaveBeenCalledWith( + expect.objectContaining({ + slug: 'sample-connector', + state: { type: 'fields', values: { apiToken: 'sample-token' } }, + discoveredTools: [], + validationError: null, + validatedAt: null, + }) + ); + expect(mockGetMaskedState).toHaveBeenCalled(); + }); + it('stores validation errors on the user connection without mutating shared discovery', async () => { mockDiscoverTools.mockRejectedValue( new Error('HTTP 401 Unauthorized for Authorization Bearer invalid-token and token invalid-token') diff --git a/src/app/api/v2/ai/agent/mcp-connections/[slug]/route.ts b/src/app/api/v2/ai/agent/mcp-connections/[slug]/route.ts index 5549a112..365ea815 100644 --- a/src/app/api/v2/ai/agent/mcp-connections/[slug]/route.ts +++ b/src/app/api/v2/ai/agent/mcp-connections/[slug]/route.ts @@ -28,6 +28,7 @@ import { normalizeUserConnectionValues, } from 'server/services/agentRuntime/mcp/connectionConfig'; import { McpConfigService, sanitizeMcpErrorMessage } from 'server/services/agentRuntime/mcp/config'; +import { usesSessionWorkspaceGatewayExecution } from 'server/services/agentRuntime/mcp/sessionPod'; import UserMcpConnectionService from 'server/services/userMcpConnection'; /** @@ -39,7 +40,9 @@ import UserMcpConnectionService from 'server/services/userMcpConnection'; * Saves encrypted per-user connection values for a shared MCP * that uses field-based user auth, validates them against the MCP * schema, and discovers tools using the current user's effective runtime - * config. + * config. Command-based (stdio) MCPs are never started here: the + * connection is saved with no tools and they are discovered the first + * time a session uses it. * tags: * - Agent Sessions * operationId: upsertAgentMcpConnection @@ -180,7 +183,6 @@ const putHandler = async (req: NextRequest, { params }: { params: Promise<{ slug ); } - const validatedAt = new Date().toISOString(); const definitionFingerprint = buildMcpDefinitionFingerprint({ preset: config.preset, transport: config.transport, @@ -190,34 +192,7 @@ const putHandler = async (req: NextRequest, { params }: { params: Promise<{ slug const compiledConfig = mergeCompiledConnectionConfig(config.sharedConfig || {}, compiledUserConfig); const transport = applyCompiledConnectionConfigToTransport(config.transport, compiledConfig); - try { - const discoveredTools = await configService.discoverTools(transport, config.timeout); - if (discoveredTools.length === 0) { - throw new Error(`MCP validation failed for ${slug}: server returned 0 tools`); - } - - await UserMcpConnectionService.upsertConnection({ - userId: userIdentity.userId, - ownerGithubUsername: userIdentity.githubUsername, - scope, - slug, - state: { type: 'fields', values }, - definitionFingerprint, - discoveredTools, - validationError: null, - validatedAt, - }); - } catch (error) { - const message = sanitizeMcpErrorMessage(error, [ - { - values, - compiledConfig: compiledUserConfig, - }, - { - compiledConfig, - transport, - }, - ]); + if (usesSessionWorkspaceGatewayExecution(transport)) { await UserMcpConnectionService.upsertConnection({ userId: userIdentity.userId, ownerGithubUsername: userIdentity.githubUsername, @@ -226,18 +201,60 @@ const putHandler = async (req: NextRequest, { params }: { params: Promise<{ slug state: { type: 'fields', values }, definitionFingerprint, discoveredTools: [], - validationError: message, - validatedAt, + validationError: null, + validatedAt: null, }); + } else { + const validatedAt = new Date().toISOString(); + try { + const discoveredTools = await configService.discoverTools(transport, config.timeout); + if (discoveredTools.length === 0) { + throw new Error(`MCP validation failed for ${slug}: server returned 0 tools`); + } - return NextResponse.json( - { - request_id: req.headers.get('x-request-id'), - data: null, - error: { message }, - }, - { status: 422 } - ); + await UserMcpConnectionService.upsertConnection({ + userId: userIdentity.userId, + ownerGithubUsername: userIdentity.githubUsername, + scope, + slug, + state: { type: 'fields', values }, + definitionFingerprint, + discoveredTools, + validationError: null, + validatedAt, + }); + } catch (error) { + const message = sanitizeMcpErrorMessage(error, [ + { + values, + compiledConfig: compiledUserConfig, + }, + { + compiledConfig, + transport, + }, + ]); + await UserMcpConnectionService.upsertConnection({ + userId: userIdentity.userId, + ownerGithubUsername: userIdentity.githubUsername, + scope, + slug, + state: { type: 'fields', values }, + definitionFingerprint, + discoveredTools: [], + validationError: message, + validatedAt, + }); + + return NextResponse.json( + { + request_id: req.headers.get('x-request-id'), + data: null, + error: { message }, + }, + { status: 422 } + ); + } } const state = await UserMcpConnectionService.getMaskedState( diff --git a/src/app/api/v2/logs/stream-tickets/route.test.ts b/src/app/api/v2/logs/stream-tickets/route.test.ts new file mode 100644 index 00000000..8694cf46 --- /dev/null +++ b/src/app/api/v2/logs/stream-tickets/route.test.ts @@ -0,0 +1,319 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const mockVerifyAuth = jest.fn(); +const mockGetBuildByUUID = jest.fn(); +const mockAssertBuildRepositoryAllowed = jest.fn(); +const mockAssertLogStreamPodContainer = jest.fn(); + +jest.mock('server/lib/auth', () => ({ verifyAuth: (...args: unknown[]) => mockVerifyAuth(...args) })); +jest.mock('server/models/ApiToken'); +jest.mock('server/models/Repository'); +jest.mock('server/lib/dependencies', () => ({})); +jest.mock('server/lib/logger', () => ({ + getLogger: () => ({ warn: jest.fn(), info: jest.fn(), error: jest.fn(), debug: jest.fn() }), +})); +jest.mock('server/services/authRateLimit', () => ({ + checkApiKeyRateLimit: jest.fn().mockResolvedValue({ allowed: true, retryAfterSeconds: 0 }), +})); +jest.mock('server/services/authAudit', () => ({ recordAuthAuditEvent: jest.fn() })); +jest.mock('server/services/apiToken', () => { + const actual = jest.requireActual('server/services/apiToken'); + return { __esModule: true, ...actual, default: { verifyToken: jest.fn(), touchLastUsed: jest.fn() } }; +}); +jest.mock('server/services/globalConfig', () => ({ + __esModule: true, + default: { + getInstance: () => ({ + getConfig: jest.fn().mockResolvedValue({ serviceAuthEnabled: true, personalAuthEnabled: true }), + }), + }, +})); +jest.mock('server/services/build', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ getBuildByUUID: mockGetBuildByUUID })), +})); +jest.mock('server/lib/repositoryAuthorization', () => ({ + assertBuildRepositoryAllowed: (...args: unknown[]) => mockAssertBuildRepositoryAllowed(...args), +})); +jest.mock('server/lib/logStreamPodAccess', () => ({ + assertLogStreamPodContainer: (...args: unknown[]) => mockAssertLogStreamPodContainer(...args), +})); + +import { NextRequest } from 'next/server'; +import ApiTokenService from 'server/services/apiToken'; +import { AppError } from 'server/lib/appError'; +import { readLogStreamTicketClaims } from 'server/lib/logStreamTicket'; +import { POST } from './route'; +import { authMiddleware } from 'server/middlewares/auth'; + +const verifyToken = ApiTokenService.verifyToken as jest.Mock; +const originalMode = process.env.LIFECYCLE_MODE; +const originalAppEnv = process.env.APP_ENV; +const originalEnableAuth = process.env.ENABLE_AUTH; +const originalSecret = process.env.CHAT_PREVIEW_GRANT_SECRET; +const KEY = `lfc_svc_${'a'.repeat(40)}`; + +const encodeUser = (payload: unknown) => Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url'); +const sessionHeaders = { 'x-user': encodeUser({ sub: 'user-1', realm_access: { roles: ['user'] } }) }; +const keyHeaders = { authorization: `Bearer ${KEY}` }; + +function makeRequest(body: unknown, headers: Record = sessionHeaders): NextRequest { + return new NextRequest('http://localhost/api/v2/logs/stream-tickets', { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }); +} + +const validBody = { buildUuid: 'build-1', podName: 'web-0', containerName: 'web' }; + +describe('POST /api/v2/logs/stream-tickets', () => { + beforeEach(() => { + jest.clearAllMocks(); + process.env.ENABLE_AUTH = 'true'; + process.env.LIFECYCLE_MODE = 'web'; + process.env.APP_ENV = 'production'; + process.env.CHAT_PREVIEW_GRANT_SECRET = 'test-ticket-secret'; + mockGetBuildByUUID.mockResolvedValue({ uuid: 'build-1', namespace: 'env-build-1' }); + mockAssertBuildRepositoryAllowed.mockResolvedValue(undefined); + mockAssertLogStreamPodContainer.mockResolvedValue(undefined); + verifyToken.mockResolvedValue(null); + mockVerifyAuth.mockResolvedValue({ + success: false, + error: { status: 401, message: 'Authentication is required.' }, + }); + }); + + afterAll(() => { + if (originalMode === undefined) delete process.env.LIFECYCLE_MODE; + else process.env.LIFECYCLE_MODE = originalMode; + if (originalAppEnv === undefined) delete process.env.APP_ENV; + else process.env.APP_ENV = originalAppEnv; + if (originalEnableAuth === undefined) delete process.env.ENABLE_AUTH; + else process.env.ENABLE_AUTH = originalEnableAuth; + if (originalSecret === undefined) delete process.env.CHAT_PREVIEW_GRANT_SECRET; + else process.env.CHAT_PREVIEW_GRANT_SECRET = originalSecret; + }); + + it('mints a ticket pinned to the environment namespace once the pod and container are verified there', async () => { + const response = await POST(makeRequest(validBody)); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(mockGetBuildByUUID).toHaveBeenCalledWith('build-1'); + expect(mockAssertBuildRepositoryAllowed).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-1' }), + expect.objectContaining({ uuid: 'build-1' }) + ); + expect(mockAssertLogStreamPodContainer).toHaveBeenCalledWith({ + namespace: 'env-build-1', + podName: 'web-0', + containerName: 'web', + }); + expect(response.headers.get('cache-control')).toBe('private, no-store'); + expect(response.headers.get('pragma')).toBe('no-cache'); + expect(body.data.maxAgeSeconds).toBe(300); + expect(readLogStreamTicketClaims(body.data.ticket)).toMatchObject({ + buildUuid: 'build-1', + namespace: 'env-build-1', + podName: 'web-0', + containerName: 'web', + actor: 'user-1', + }); + }); + + it('derives the namespace from the environment when the build row has none', async () => { + mockGetBuildByUUID.mockResolvedValueOnce({ uuid: 'build-1', namespace: null }); + + const response = await POST(makeRequest(validBody)); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(readLogStreamTicketClaims(body.data.ticket)).toMatchObject({ namespace: 'env-build-1' }); + }); + + it('never lets the caller choose the namespace', async () => { + const response = await POST(makeRequest({ ...validBody, namespace: 'kube-system' })); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.error.code).toBe('invalid_body'); + expect(mockAssertLogStreamPodContainer).not.toHaveBeenCalled(); + }); + + it('does not search another namespace when the pod is absent from the environment', async () => { + mockAssertLogStreamPodContainer.mockRejectedValueOnce( + new AppError({ httpStatus: 404, code: 'log_stream_pod_not_found', message: 'Pod not found.' }) + ); + const response = await POST(makeRequest(validBody)); + expect(response.status).toBe(404); + expect(mockAssertLogStreamPodContainer).toHaveBeenCalledTimes(1); + expect(mockAssertLogStreamPodContainer).toHaveBeenCalledWith({ + namespace: 'env-build-1', + podName: 'web-0', + containerName: 'web', + }); + }); + + it.each(['job', 'unknown'])('does not issue tickets in %s mode', async (mode) => { + process.env.LIFECYCLE_MODE = mode; + const response = await POST(makeRequest(validBody)); + expect(response.status).toBe(403); + expect(mockGetBuildByUUID).not.toHaveBeenCalled(); + expect(mockAssertLogStreamPodContainer).not.toHaveBeenCalled(); + }); + + it.each([undefined, 'false'])('denies production issuance with ENABLE_AUTH=%s', async (auth) => { + if (auth === undefined) delete process.env.ENABLE_AUTH; + else process.env.ENABLE_AUTH = auth; + const response = await POST(makeRequest(validBody, {})); + expect(response.status).toBe(503); + expect((await response.json()).error.code).toBe('authentication_unavailable'); + expect(mockGetBuildByUUID).not.toHaveBeenCalled(); + expect(mockAssertLogStreamPodContainer).not.toHaveBeenCalled(); + }); + + it('preserves issuance for explicit auth-disabled local development', async () => { + process.env.APP_ENV = 'development'; + delete process.env.ENABLE_AUTH; + const response = await POST(makeRequest(validBody, {})); + expect(response.status).toBe(200); + expect((await response.json()).data.ticket).toMatch(/^lfclt_v1\./); + }); + + it('refuses a token that may not read the environment repository before any Kubernetes call', async () => { + mockAssertBuildRepositoryAllowed.mockRejectedValueOnce( + new AppError({ httpStatus: 403, code: 'forbidden_repository', message: 'Not allowed.' }) + ); + + const response = await POST(makeRequest(validBody)); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body.error.code).toBe('forbidden_repository'); + expect(mockAssertLogStreamPodContainer).not.toHaveBeenCalled(); + }); + + it('refuses a pod outside the environment', async () => { + mockAssertLogStreamPodContainer.mockRejectedValue( + new AppError({ httpStatus: 404, code: 'log_stream_pod_not_found', message: 'Pod not found in this environment.' }) + ); + + const response = await POST(makeRequest({ ...validBody, podName: 'kube-apiserver-control-plane' })); + const body = await response.json(); + + expect(response.status).toBe(404); + expect(body.error.code).toBe('log_stream_pod_not_found'); + expect(body.data).toBeNull(); + }); + + it('returns 404 for an unknown environment', async () => { + mockGetBuildByUUID.mockResolvedValueOnce(null); + + const response = await POST(makeRequest(validBody)); + const body = await response.json(); + + expect(response.status).toBe(404); + expect(body.error.code).toBe('environment_not_found'); + expect(mockAssertLogStreamPodContainer).not.toHaveBeenCalled(); + }); + + it.each([ + ['a path-like pod name', { ...validBody, podName: '../kube-system/etcd' }], + ['an uppercase container name', { ...validBody, containerName: 'Web' }], + ['a missing build uuid', { podName: 'web-0', containerName: 'web' }], + ['a non-object body', '[]'], + ])('rejects %s before looking anything up', async (_label, body) => { + const response = await POST(makeRequest(body)); + + expect(response.status).toBe(400); + expect(mockGetBuildByUUID).not.toHaveBeenCalled(); + expect(mockAssertLogStreamPodContainer).not.toHaveBeenCalled(); + }); + + it('requires authentication', async () => { + const response = await POST(makeRequest(validBody, {})); + const body = await response.json(); + + expect(response.status).toBe(401); + expect(body.error.code).toBe('authentication_required'); + expect(mockGetBuildByUUID).not.toHaveBeenCalled(); + }); + + it('requires the env:read scope on API keys', async () => { + verifyToken.mockResolvedValue({ id: 7, name: 'ci', kind: 'service', scopes: ['sites:read'] }); + + const response = await POST(makeRequest(validBody, keyHeaders)); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body.error.code).toBe('forbidden_scope'); + expect(mockGetBuildByUUID).not.toHaveBeenCalled(); + }); + + it('mints a ticket for a service key with env:read and records the token as the actor', async () => { + verifyToken.mockResolvedValue({ id: 7, name: 'ci', kind: 'service', scopes: ['env:read'] }); + + const response = await POST(makeRequest(validBody, keyHeaders)); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(readLogStreamTicketClaims(body.data.ticket)).toMatchObject({ actor: 'token:ci', namespace: 'env-build-1' }); + }); + + it('rejects a spoofed principal through the real authentication middleware', async () => { + const response = await authMiddleware(makeRequest(validBody), POST); + expect(response.status).toBe(401); + expect(mockGetBuildByUUID).not.toHaveBeenCalled(); + expect(mockAssertLogStreamPodContainer).not.toHaveBeenCalled(); + }); + + it('uses the verified session principal after middleware replaces a spoofed header', async () => { + mockVerifyAuth.mockResolvedValue({ + success: true, + payload: { sub: 'verified-user', realm_access: { roles: ['user'] } }, + }); + const response = await authMiddleware( + makeRequest(validBody, { ...sessionHeaders, authorization: 'Bearer session-jwt' }), + POST + ); + expect(response.status).toBe(200); + expect(readLogStreamTicketClaims((await response.json()).data.ticket)).toMatchObject({ actor: 'verified-user' }); + }); + + it('does not accept a forged key-shaped token passed through edge middleware', async () => { + const response = await authMiddleware(makeRequest(validBody, { ...sessionHeaders, ...keyHeaders }), POST); + expect(response.status).toBe(401); + expect(mockVerifyAuth).not.toHaveBeenCalled(); + expect(verifyToken).toHaveBeenCalled(); + expect(mockGetBuildByUUID).not.toHaveBeenCalled(); + }); + + it('verifies service keys after middleware removes spoofed session identity', async () => { + verifyToken.mockResolvedValue({ id: 7, name: 'ci', kind: 'service', scopes: ['env:read'] }); + const response = await authMiddleware(makeRequest(validBody, { ...sessionHeaders, ...keyHeaders }), POST); + expect(response.status).toBe(200); + expect(readLogStreamTicketClaims((await response.json()).data.ticket)).toMatchObject({ actor: 'token:ci' }); + }); + + it('does not issue after a Kubernetes access denial', async () => { + mockAssertLogStreamPodContainer.mockRejectedValueOnce(new Error('Kubernetes access denied')); + const response = await POST(makeRequest(validBody)); + expect(response.status).toBe(500); + expect((await response.json()).data).toBeNull(); + }); +}); diff --git a/src/app/api/v2/logs/stream-tickets/route.ts b/src/app/api/v2/logs/stream-tickets/route.ts new file mode 100644 index 00000000..509b728e --- /dev/null +++ b/src/app/api/v2/logs/stream-tickets/route.ts @@ -0,0 +1,132 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NextRequest } from 'next/server'; +import { BadRequestError, NotFoundError, ForbiddenError } from 'server/lib/appError'; +import { createPrincipalApiHandler } from 'server/lib/createApiHandler'; +import { assertLogStreamPodContainer } from 'server/lib/logStreamPodAccess'; +import { createLogStreamTicket } from 'server/lib/logStreamTicket'; +import type { Principal } from 'server/lib/principal'; +import { assertBuildRepositoryAllowed } from 'server/lib/repositoryAuthorization'; +import { canServeLogs } from 'server/lib/serverAccess'; +import { successResponse, withNoStore } from 'server/lib/response'; +import BuildService from 'server/services/build'; +import { assertNoUnknownFields } from '../../me/tokens/shared'; + +const TICKET_FIELDS = ['buildUuid', 'podName', 'containerName'] as const; +const BUILD_UUID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +const POD_NAME_PATTERN = /^[a-z0-9]([-a-z0-9.]{0,251}[a-z0-9])?$/; +const CONTAINER_NAME_PATTERN = /^(\[init\] )?[a-z0-9]([-a-z0-9]{0,61}[a-z0-9])?$/; + +function readField(body: Record, field: string, pattern: RegExp, message: string): string { + const value = body[field]; + if (typeof value !== 'string' || !pattern.test(value)) { + throw new BadRequestError(message, 'invalid_body'); + } + return value; +} + +/** + * @openapi + * /api/v2/logs/stream-tickets: + * post: + * summary: Mint a ticket for the pod log stream + * security: + * - BearerAuth: [] + * - LifecycleApiKey: [] + * description: | + * Returns a short-lived ticket for `/api/logs/stream` that pins the environment + * namespace, pod and container named in the request. The pod must belong to the + * environment and the container must exist on it. Requires the `env:read` scope. + * tags: + * - Logs + * operationId: createLogStreamTicket + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * additionalProperties: false + * required: [buildUuid, podName, containerName] + * properties: + * buildUuid: + * type: string + * podName: + * type: string + * containerName: + * type: string + * responses: + * '200': + * description: Log stream ticket + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/SuccessApiResponse' + * - type: object + * required: [data] + * properties: + * data: + * type: object + * required: [ticket, maxAgeSeconds] + * properties: + * ticket: + * type: string + * maxAgeSeconds: + * type: integer + * '400': + * description: Invalid request body + * '401': + * description: Missing or invalid credentials + * '403': + * description: Missing env:read scope or repository not allowed for this token + * '404': + * description: Environment, pod or container not found + */ +const postHandler = async (req: NextRequest, principal: Principal) => { + if (!canServeLogs(process.env.LIFECYCLE_MODE)) { + throw new ForbiddenError('Log streaming is unavailable on this server.'); + } + const body: unknown = await req.json().catch(() => null); + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new BadRequestError('Request body must be a JSON object.', 'invalid_body'); + } + assertNoUnknownFields(body, TICKET_FIELDS); + const fields = body as Record; + const buildUuid = readField(fields, 'buildUuid', BUILD_UUID_PATTERN, 'buildUuid is required.'); + const podName = readField(fields, 'podName', POD_NAME_PATTERN, 'podName must be a Kubernetes pod name.'); + const containerName = readField( + fields, + 'containerName', + CONTAINER_NAME_PATTERN, + 'containerName must be a container name on the pod.' + ); + + const build = await new BuildService().getBuildByUUID(buildUuid); + if (!build) { + throw new NotFoundError('Environment not found.', 'environment_not_found'); + } + await assertBuildRepositoryAllowed(principal, build); + + const target = { namespace: build.namespace || `env-${build.uuid}`, podName, containerName }; + await assertLogStreamPodContainer(target); + + const { ticket, maxAgeSeconds } = createLogStreamTicket({ ...target, buildUuid: build.uuid, actor: principal.actor }); + return withNoStore(successResponse({ ticket, maxAgeSeconds }, { status: 200 }, req)); +}; + +export const POST = createPrincipalApiHandler({ scope: 'env:read' }, postHandler); diff --git a/src/components/SetupTokenField.tsx b/src/components/SetupTokenField.tsx new file mode 100644 index 00000000..6ba4cfe7 --- /dev/null +++ b/src/components/SetupTokenField.tsx @@ -0,0 +1,52 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; + +const INPUT_STYLE: React.CSSProperties = { + width: '100%', + marginTop: 4, + padding: '0.7em', + borderRadius: 8, + border: '1px solid #ddd', + fontSize: '1rem', + marginBottom: 2, +}; + +type SetupTokenFieldProps = { + value: string; + onChange: (value: string) => void; +}; + +export function SetupTokenField({ value, onChange }: SetupTokenFieldProps) { + return ( +
+ +
+ Use the operator token from LIFECYCLE_SETUP_TOKEN or the setup instructions. +
+
+ ); +} diff --git a/src/middleware.test.ts b/src/middleware.test.ts index fa8054a0..3fb2d9f0 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -41,6 +41,6 @@ describe('API middleware composition', () => { expect(mockChain).toHaveBeenCalledWith([mockCorsMiddleware, mockRequestIdMiddleware, mockAuthMiddleware]); expect(loaded.middleware).toBe(mockCompiledMiddleware); - expect(loaded.config.matcher).toEqual(['/api/v1/:path*', '/api/v2/:path*']); + expect(loaded.config.matcher).toEqual(['/api/v2/:path*']); }); }); diff --git a/src/middleware.ts b/src/middleware.ts index a91dec77..6a9c6e05 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -23,5 +23,5 @@ const middlewares = [corsMiddleware, requestIdMiddleware, authMiddleware]; export const middleware = chain(middlewares); export const config = { - matcher: ['/api/v1/:path*', '/api/v2/:path*'], + matcher: ['/api/v2/:path*'], }; diff --git a/src/pages/api/v1/admin/ttl/cleanup.ts b/src/pages/api/v1/admin/ttl/cleanup.ts deleted file mode 100644 index 39452bb4..00000000 --- a/src/pages/api/v1/admin/ttl/cleanup.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { NextApiRequest, NextApiResponse } from 'next'; -import { nanoid } from 'nanoid'; -import { withLogContext, getLogger, LogStage } from 'server/lib/logger'; -import GlobalConfigService from 'server/services/globalConfig'; -import TTLCleanupService from 'server/services/ttlCleanup'; - -/** - * @openapi - * /api/v1/admin/ttl/cleanup: - * get: - * summary: Get TTL cleanup configuration - * description: Retrieves the current TTL cleanup configuration from global config - * tags: - * - Admin - * - TTL Cleanup - * responses: - * 200: - * description: Successfully retrieved TTL cleanup configuration - * content: - * application/json: - * schema: - * type: object - * properties: - * config: - * type: object - * properties: - * enabled: - * type: boolean - * description: Whether TTL cleanup is enabled - * dryRun: - * type: boolean - * description: Whether cleanup runs in dry-run mode - * inactivityDays: - * type: number - * description: Number of days of inactivity before cleanup - * checkIntervalMinutes: - * type: number - * description: How often cleanup job runs (in minutes) - * commentTemplate: - * type: string - * description: Template for PR comments - * excludedRepositories: - * type: array - * items: - * type: string - * description: List of repositories excluded from cleanup - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: DELETE is not allowed. - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Unable to retrieve TTL cleanup configuration - * post: - * summary: Manually trigger TTL cleanup - * description: Manually triggers a TTL cleanup job with optional configuration override - * tags: - * - Admin - * - TTL Cleanup - * requestBody: - * required: false - * content: - * application/json: - * schema: - * type: object - * properties: - * dryRun: - * type: boolean - * description: Override dry-run mode for this execution (optional) - * responses: - * 200: - * description: Successfully triggered TTL cleanup job - * content: - * application/json: - * schema: - * type: object - * properties: - * message: - * type: string - * example: TTL cleanup job triggered successfully - * jobId: - * type: string - * description: The ID of the queued job - * dryRun: - * type: boolean - * description: Whether this job will run in dry-run mode - * 400: - * description: Bad request - invalid parameters - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: dryRun must be a boolean value - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: DELETE is not allowed. - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Unable to trigger TTL cleanup job - */ -// eslint-disable-next-line import/no-anonymous-default-export -export default async (req: NextApiRequest, res: NextApiResponse) => { - try { - switch (req.method) { - case 'GET': - return getTTLConfig(res); - case 'POST': - return triggerTTLCleanup(req, res); - default: - res.setHeader('Allow', ['GET', 'POST']); - return res.status(405).json({ error: `${req.method} is not allowed.` }); - } - } catch (error) { - getLogger().error({ error }, 'TTL: cleanup operation failed'); - res.status(500).json({ error: 'An unexpected error occurred.' }); - } -}; - -async function getTTLConfig(res: NextApiResponse) { - try { - const configService = GlobalConfigService.getInstance(); - const globalConfig = await configService.getAllConfigs(); - const ttlConfig = globalConfig.ttl_cleanup; - - if (!ttlConfig) { - getLogger().warn('TTL: config not found'); - return res.status(404).json({ error: 'TTL cleanup configuration not found' }); - } - - return res.status(200).json({ config: ttlConfig }); - } catch (error) { - getLogger().error({ error }, 'TTL: config retrieval failed'); - return res.status(500).json({ error: 'Unable to retrieve TTL cleanup configuration' }); - } -} - -async function triggerTTLCleanup(req: NextApiRequest, res: NextApiResponse) { - const correlationId = `api-ttl-cleanup-${Date.now()}-${nanoid(8)}`; - - return withLogContext({ correlationId }, async () => { - try { - const { dryRun = false } = req.body || {}; - - // Validate dryRun parameter type - if (typeof dryRun !== 'boolean') { - return res.status(400).json({ error: 'dryRun must be a boolean value' }); - } - - // Create new service instance and add job to queue - const ttlCleanupService = new TTLCleanupService(); - const job = await ttlCleanupService.ttlCleanupQueue.add('manual-ttl-cleanup', { dryRun, correlationId }); - - getLogger({ stage: LogStage.CLEANUP_STARTING }).info( - `TTL: cleanup job triggered manually jobId=${job.id} dryRun=${dryRun}` - ); - - return res.status(200).json({ - message: 'TTL cleanup job triggered successfully', - jobId: job.id, - dryRun, - }); - } catch (error) { - getLogger({ stage: LogStage.CLEANUP_FAILED }).error({ error }, 'TTL: cleanup trigger failed'); - return res.status(500).json({ error: 'Unable to trigger TTL cleanup job' }); - } - }); -} diff --git a/src/pages/api/v1/builds/[uuid]/deploy.ts b/src/pages/api/v1/builds/[uuid]/deploy.ts deleted file mode 100644 index 8e4c3204..00000000 --- a/src/pages/api/v1/builds/[uuid]/deploy.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { NextApiRequest, NextApiResponse } from 'next/types'; -import { withLogContext, getLogger, LogStage } from 'server/lib/logger'; -import { Build } from 'server/models'; -import { nanoid } from 'nanoid'; -import BuildService from 'server/services/build'; - -/** - * @openapi - * /api/v1/builds/{uuid}/deploy: - * post: - * summary: Redeploy an entire build - * description: | - * Triggers a redeployment of all services within a build. The build - * will be queued for deployment and its status will be updated accordingly. - * tags: - * - Builds - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * responses: - * 200: - * description: Build has been successfully queued for redeployment - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: success - * message: - * type: string - * example: Redeploy for build abc-123 has been queued - * 404: - * description: Build not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Build not found for abc-123 - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: GET is not allowed - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Unable to proceed with redeploy for build abc-123. - */ -// eslint-disable-next-line import/no-anonymous-default-export -export default async (req: NextApiRequest, res: NextApiResponse) => { - const correlationId = `api-redeploy-${Date.now()}-${nanoid(8)}`; - const { uuid } = req.query; - - return withLogContext({ correlationId, buildUuid: uuid as string }, async () => { - if (req.method !== 'POST') { - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - try { - getLogger({ stage: LogStage.BUILD_QUEUED }).info('Build: redeploy requested'); - - const buildService = new BuildService(); - const build: Build = await buildService.db.models.Build.query() - .findOne({ uuid }) - .withGraphFetched('deploys.deployable'); - - if (!build) { - getLogger().debug('Build not found'); - return res.status(404).json({ error: `Build not found for ${uuid}` }); - } - - const buildId = build.id; - const runUUID = nanoid(); - await buildService.enqueueResolveAndDeployBuild({ - buildId, - runUUID, - }); - - getLogger({ stage: LogStage.BUILD_QUEUED }).info('Build: redeploy queued'); - - return res.status(200).json({ - status: 'success', - message: `Redeploy for build ${uuid} has been queued`, - }); - } catch (error) { - getLogger({ stage: LogStage.BUILD_FAILED }).error({ error }, 'Build: redeploy failed'); - return res.status(500).json({ error: `Unable to proceed with redeploy for build ${uuid}.` }); - } - }); -}; diff --git a/src/pages/api/v1/builds/[uuid]/graph.ts b/src/pages/api/v1/builds/[uuid]/graph.ts deleted file mode 100644 index 7493abcf..00000000 --- a/src/pages/api/v1/builds/[uuid]/graph.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { NextApiRequest, NextApiResponse } from 'next/types'; -import { generateGraph } from 'server/lib/dependencyGraph'; -import { getLogger, withLogContext } from 'server/lib/logger'; -import { Build } from 'server/models'; -import BuildService from 'server/services/build'; - -/** - * @openapi - * /api/v1/builds/{uuid}/graph: - * get: - * summary: Get build dependency graph - * description: | - * Retrieves the dependency graph for a specific build. If the graph doesn't exist, - * it will be generated and stored before being returned. The graph represents the - * relationships between different deployables in the build. - * tags: - * - Builds - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * responses: - * 200: - * description: Successfully retrieved dependency graph - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: success - * message: - * type: string - * example: Dependency graph for abc-123 returned. - * dependencyGraph: - * type: object - * description: The dependency graph structure - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: POST is not allowed - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: An unexpected error occurred. - */ -// eslint-disable-next-line import/no-anonymous-default-export -export default async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== 'GET') { - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - const { uuid } = req.query; - - return withLogContext({ buildUuid: uuid as string }, async () => { - try { - const buildService = new BuildService(); - - const build: Build = await buildService.db.models.Build.query() - .findOne({ - uuid, - }) - .withGraphFetched('[deploys.deployable, deployables]'); - - if (Object.keys(build.dependencyGraph).length === 0) { - const dependencyGraph = await generateGraph(build, 'TB'); - await build.$query().patchAndFetch({ - dependencyGraph, - }); - } - - return res.status(200).json({ - status: 'success', - message: `Dependency graph for ${uuid} returned.`, - dependencyGraph: build.dependencyGraph, - }); - } catch (error) { - getLogger().error({ error }, 'Build: dependency graph fetch failed'); - res.status(500).json({ error: 'An unexpected error occurred.' }); - } - }); -}; diff --git a/src/pages/api/v1/builds/[uuid]/index.ts b/src/pages/api/v1/builds/[uuid]/index.ts deleted file mode 100644 index 974c0ebe..00000000 --- a/src/pages/api/v1/builds/[uuid]/index.ts +++ /dev/null @@ -1,367 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { nanoid } from 'nanoid'; -import { NextApiRequest, NextApiResponse } from 'next/types'; -import { withLogContext, getLogger, LogStage } from 'server/lib/logger'; -import { validateBuildUuidFormat } from 'server/lib/validation/buildUuidValidator'; -import BuildService from 'server/services/build'; -import OverrideService, { BuildUuidValidationError } from 'server/services/override'; - -async function retrieveBuild(req: NextApiRequest, res: NextApiResponse) { - try { - const { uuid } = req.query; - const buildService = new BuildService(); - - const build = await buildService.db.models.Build.query() - .findOne({ uuid }) - .select( - 'id', - 'uuid', - 'status', - 'statusMessage', - 'sha', - 'createdAt', - 'updatedAt', - 'deletedAt', - 'pullRequestId', - 'manifest', - 'webhooksYaml', - 'isStatic', - 'namespace' - ); - - if (!build) { - getLogger().debug('Build not found'); - return res.status(404).json({ error: 'Build not found' }); - } - - return res.status(200).json(build); - } catch (error) { - getLogger({ error }).error('API: build fetch failed'); - return res.status(500).json({ error: 'An unexpected error occurred' }); - } -} - -async function updateBuild(req: NextApiRequest, res: NextApiResponse, correlationId: string) { - const { uuid } = req.query; - const { uuid: newUuid } = req.body; - - if (newUuid == null) { - getLogger().debug('Missing or invalid uuid in request body'); - return res.status(400).json({ error: 'uuid is required' }); - } - - if (typeof newUuid !== 'string') { - getLogger().debug('Invalid uuid in request body'); - return res.status(400).json({ error: 'uuid must be a string' }); - } - - const uuidFormatError = validateBuildUuidFormat(newUuid); - if (uuidFormatError) { - getLogger().debug(`UUID validation failed: error=${uuidFormatError}`); - return res.status(400).json({ error: uuidFormatError }); - } - - try { - const override = new OverrideService(); - - const build = await override.db.models.Build.query().findOne({ uuid }).withGraphFetched('pullRequest'); - - if (!build) { - getLogger().debug('Build not found, cannot patch uuid'); - return res.status(404).json({ error: 'Build not found' }); - } - - if (newUuid === build.uuid) { - getLogger().debug(`Attempted to update UUID to same value: newUuid=${newUuid}`); - return res.status(400).json({ error: 'UUID must be different' }); - } - - const validation = await override.validateUuid(newUuid, build.id); - if (!validation.valid) { - getLogger().debug(`UUID validation failed: error=${validation.error}`); - return res.status(400).json({ error: validation.error }); - } - - const result = await override.updateBuildUuid(build, newUuid); - - if (build.pullRequest?.deployOnUpdate) { - getLogger({ stage: LogStage.BUILD_QUEUED }).info(`Triggering redeploy after UUID update`); - await new BuildService().enqueueResolveAndDeployBuild({ - buildId: build.id, - runUUID: nanoid(), - }); - } - - return res.status(200).json({ - data: { - ...result.build, - }, - }); - } catch (error) { - if (error instanceof BuildUuidValidationError) { - getLogger().debug(`UUID validation failed: error=${error.message}`); - return res.status(400).json({ error: error.message }); - } - - getLogger({ error }).error(`API: UUID update failed newUuid=${newUuid}`); - return res.status(500).json({ error: 'An unexpected error occurred' }); - } -} - -/** - * @openapi - * /api/v1/builds/{uuid}: - * get: - * summary: Get build by UUID - * description: | - * Retrieves detailed information about a specific build by its UUID. - * tags: - * - Builds - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * responses: - * 200: - * description: Successfully retrieved build details - * content: - * application/json: - * schema: - * type: object - * properties: - * id: - * type: integer - * uuid: - * type: string - * status: - * type: string - * statusMessage: - * type: string - * sha: - * type: string - * createdAt: - * type: string - * format: date-time - * updatedAt: - * type: string - * format: date-time - * deletedAt: - * type: string - * format: date-time - * nullable: true - * pullRequestId: - * type: integer - * manifest: - * type: object - * webhooksYaml: - * type: object - * isStatic: - * type: boolean - * namespace: - * type: string - * 404: - * description: Build not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Build not found - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * patch: - * summary: Update build UUID - * description: | - * Updates the UUID (custom identifier) for a build and all related records. - * This changes the build's public URL and namespace. - * tags: - * - Builds - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The current UUID of the build to update - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * uuid: - * type: string - * minLength: 3 - * maxLength: 50 - * pattern: '^[a-z0-9-]+$' - * description: The new UUID (3-50 characters, lowercase letters, numbers, and hyphens) - * example: my-custom-environment - * required: - * - uuid - * responses: - * 200: - * description: UUID updated successfully - * content: - * application/json: - * schema: - * type: object - * properties: - * data: - * type: object - * description: The updated build object - * properties: - * id: - * type: number - * example: 12345 - * uuid: - * type: string - * example: my-custom-environment - * namespace: - * type: string - * example: env-my-custom-environment - * updatedAt: - * type: string - * example: 2025-09-09T10:30:00Z - * status: - * type: string - * example: active - * statusMessage: - * type: string - * example: Build is running - * sha: - * type: string - * example: abc123def456 - * createdAt: - * type: string - * format: date-time - * example: 2025-09-09T09:00:00Z - * deletedAt: - * type: string - * format: date-time - * nullable: true - * example: null - * pullRequestId: - * type: integer - * example: 42 - * manifest: - * type: object - * example: {} - * webhooksYaml: - * type: object - * example: {} - * isStatic: - * type: boolean - * example: false - * 400: - * description: Invalid request - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * examples: - * missing_uuid: - * value: uuid is required - * same_uuid: - * value: UUID must be different - * invalid_format: - * value: UUID can only contain lowercase letters, numbers, and hyphens - * invalid_length: - * value: UUID must be between 3 and 50 characters - * invalid_boundaries: - * value: UUID cannot start or end with a hyphen - * 404: - * description: Build not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Build not found - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: GET is not allowed - * 409: - * description: UUID conflict - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: UUID is not available - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: An unexpected error occurred - */ -// eslint-disable-next-line import/no-anonymous-default-export -export default async (req: NextApiRequest, res: NextApiResponse) => { - const { uuid } = req.query; - - if (!uuid || typeof uuid !== 'string') { - return res.status(400).json({ error: 'Invalid UUID' }); - } - - return withLogContext({ buildUuid: uuid as string }, async () => { - if (req.method === 'PATCH') { - const correlationId = `api-build-update-${Date.now()}-${nanoid(8)}`; - return withLogContext({ correlationId }, async () => { - return updateBuild(req, res, correlationId); - }); - } - - switch (req.method) { - case 'GET': - return retrieveBuild(req, res); - default: - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - }); -}; diff --git a/src/pages/api/v1/builds/[uuid]/jobs/[jobName]/events.ts b/src/pages/api/v1/builds/[uuid]/jobs/[jobName]/events.ts deleted file mode 100644 index 2a1e8cf6..00000000 --- a/src/pages/api/v1/builds/[uuid]/jobs/[jobName]/events.ts +++ /dev/null @@ -1,264 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * @openapi - * /api/v1/builds/{uuid}/jobs/{jobName}/events: - * get: - * summary: Get Kubernetes events for a specific job - * description: | - * Retrieves all Kubernetes events related to a specific job and its pods. - * Events are sorted by timestamp with the most recent events first. - * tags: - * - Jobs - * - Events - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build environment - * - in: path - * name: jobName - * required: true - * schema: - * type: string - * description: The name of the Kubernetes job - * responses: - * 200: - * description: Successful response with events list - * content: - * application/json: - * schema: - * type: object - * properties: - * events: - * type: array - * items: - * type: object - * properties: - * name: - * type: string - * description: Event name - * namespace: - * type: string - * description: Kubernetes namespace - * reason: - * type: string - * description: Event reason (e.g., Created, Started, Failed) - * message: - * type: string - * description: Detailed event message - * type: - * type: string - * description: Event type (Normal or Warning) - * count: - * type: number - * description: Number of times this event has occurred - * firstTimestamp: - * type: string - * format: date-time - * description: When this event first occurred - * lastTimestamp: - * type: string - * format: date-time - * description: When this event last occurred - * eventTime: - * type: string - * format: date-time - * description: Event time (newer API field) - * source: - * type: object - * properties: - * component: - * type: string - * description: Component that reported the event - * host: - * type: string - * description: Host where the event was reported - * 400: - * description: Bad request - missing or invalid parameters - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Missing or invalid uuid or jobName parameters - * 404: - * description: Environment or job not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Environment or job not found. - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: POST is not allowed - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Internal server error occurred. - * 502: - * description: Bad gateway - failed to communicate with Kubernetes - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Failed to communicate with Kubernetes. - */ -import type { NextApiRequest, NextApiResponse } from 'next'; -import { getLogger, withLogContext } from 'server/lib/logger'; -import * as k8s from '@kubernetes/client-node'; -import { HttpError } from '@kubernetes/client-node'; - -interface K8sEvent { - name: string; - namespace: string; - reason: string; - message: string; - type: string; - count: number; - firstTimestamp?: string; - lastTimestamp?: string; - eventTime?: string; - source?: { - component?: string; - host?: string; - }; -} - -interface EventsResponse { - events: K8sEvent[]; -} - -async function getJobEvents(jobName: string, namespace: string): Promise { - const kc = new k8s.KubeConfig(); - kc.loadFromDefault(); - const coreV1Api = kc.makeApiClient(k8s.CoreV1Api); - - try { - const eventsResponse = await coreV1Api.listNamespacedEvent(namespace); - const allEvents = eventsResponse.body.items || []; - - const jobEvents = allEvents.filter((event) => { - const involvedObject = event.involvedObject; - if (!involvedObject) return false; - - if (involvedObject.kind === 'Job' && involvedObject.name === jobName) { - return true; - } - - if (involvedObject.kind === 'Pod' && involvedObject.name?.startsWith(jobName)) { - return true; - } - - return false; - }); - - const events: K8sEvent[] = jobEvents.map((event) => ({ - name: event.metadata?.name || '', - namespace: event.metadata?.namespace || '', - reason: event.reason || '', - message: event.message || '', - type: event.type || 'Normal', - count: event.count || 1, - firstTimestamp: event.firstTimestamp, - lastTimestamp: event.lastTimestamp, - eventTime: event.eventTime, - source: event.source - ? { - component: event.source.component, - host: event.source.host, - } - : undefined, - })); - - events.sort((a, b) => { - const aTime = new Date(a.lastTimestamp || a.eventTime || 0).getTime(); - const bTime = new Date(b.lastTimestamp || b.eventTime || 0).getTime(); - return bTime - aTime; - }); - - return events; - } catch (error) { - getLogger().error({ error }, `API: events fetch failed jobName=${jobName}`); - throw error; - } -} - -const eventsHandler = async (req: NextApiRequest, res: NextApiResponse) => { - const { uuid, jobName } = req.query; - - return withLogContext({ buildUuid: uuid as string }, async () => { - if (req.method !== 'GET') { - getLogger().warn(`API: method not allowed method=${req.method}`); - res.setHeader('Allow', ['GET']); - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - if (typeof uuid !== 'string' || typeof jobName !== 'string') { - getLogger().warn(`API: invalid params uuid=${uuid} jobName=${jobName}`); - return res.status(400).json({ error: 'Missing or invalid uuid or jobName parameters' }); - } - - try { - const namespace = `env-${uuid}`; - - const events = await getJobEvents(jobName, namespace); - - const response: EventsResponse = { - events, - }; - - return res.status(200).json(response); - } catch (error) { - getLogger().error({ error }, `API: events fetch failed jobName=${jobName}`); - - if (error instanceof HttpError) { - if (error.response?.statusCode === 404) { - return res.status(404).json({ error: 'Environment or job not found.' }); - } - return res.status(502).json({ error: 'Failed to communicate with Kubernetes.' }); - } - - return res.status(500).json({ error: 'Internal server error occurred.' }); - } - }); -}; - -export default eventsHandler; diff --git a/src/pages/api/v1/builds/[uuid]/jobs/[jobName]/logs.ts b/src/pages/api/v1/builds/[uuid]/jobs/[jobName]/logs.ts deleted file mode 100644 index 99d08ed4..00000000 --- a/src/pages/api/v1/builds/[uuid]/jobs/[jobName]/logs.ts +++ /dev/null @@ -1,111 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { NextApiRequest, NextApiResponse } from 'next'; -import { getLogger, withLogContext } from 'server/lib/logger'; -import unifiedLogStreamHandler from '../../services/[name]/logs/[jobName]'; - -/** - * @openapi - * /api/v1/builds/{uuid}/jobs/{jobName}/logs: - * get: - * summary: Get job log streaming information for webhooks - * description: | - * Returns WebSocket endpoint and parameters for streaming job logs from Kubernetes. - * This endpoint provides information needed to establish a WebSocket connection - * for real-time log streaming from webhook jobs. - * tags: - * - Webhooks - * - Jobs - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * - in: path - * name: jobName - * required: true - * schema: - * type: string - * description: The name of the job - * responses: - * 200: - * description: Successful response with WebSocket information - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * enum: [Active, Complete, Failed, NotFound, Pending] - * description: Current status of the job - * websocket: - * type: object - * properties: - * endpoint: - * type: string - * example: /api/logs/stream - * parameters: - * type: object - * properties: - * podName: - * type: string - * namespace: - * type: string - * follow: - * type: boolean - * timestamps: - * type: boolean - * container: - * type: string - * required: false - * containers: - * type: array - * items: - * type: object - * properties: - * name: - * type: string - * state: - * type: string - * error: - * type: string - * description: Error message if applicable - * 400: - * description: Bad request - missing or invalid parameters - * 404: - * description: Build or job not found - * 405: - * description: Method not allowed - * 500: - * description: Internal server error - */ -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const { uuid, jobName } = req.query; - - return withLogContext({ buildUuid: uuid as string }, async () => { - getLogger().info(`API: job logs called method=${req.method} jobName=${jobName}`); - - req.query.type = 'webhook'; - - req.query.name = undefined; - - return unifiedLogStreamHandler(req, res); - }); -} diff --git a/src/pages/api/v1/builds/[uuid]/services/[name]/build.ts b/src/pages/api/v1/builds/[uuid]/services/[name]/build.ts deleted file mode 100644 index 85f34993..00000000 --- a/src/pages/api/v1/builds/[uuid]/services/[name]/build.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { NextApiRequest, NextApiResponse } from 'next/types'; -import { withLogContext, getLogger, LogStage } from 'server/lib/logger'; -import { nanoid } from 'nanoid'; -import BuildService from 'server/services/build'; - -/** - * @openapi - * /api/v1/builds/{uuid}/services/{name}/build: - * post: - * summary: Redeploy a service within a build - * description: | - * Triggers a redeployment of a specific service within a build. The service - * will be queued for deployment and its status will be updated accordingly. - * tags: - * - Services - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * - in: path - * name: name - * required: true - * schema: - * type: string - * description: The name of the service to redeploy - * responses: - * 200: - * description: Service has been successfully queued for redeployment - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: success - * message: - * type: string - * example: Redeploy for service example-service in build abc-123 has been queued - * 404: - * description: Build or service not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Build not found for abc-123 - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: GET is not allowed - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Unable to proceed with redeploy for services example-service in build abc-123. - */ -// eslint-disable-next-line import/no-anonymous-default-export -export default async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== 'POST') { - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - const { uuid, name } = req.query; - const correlationId = `api-service-redeploy-${Date.now()}-${nanoid(8)}`; - - return withLogContext({ correlationId, buildUuid: uuid as string }, async () => { - try { - const buildService = new BuildService(); - const build = await buildService.db.models.Build.query() - .findOne({ - uuid, - }) - .withGraphFetched('deploys.deployable'); - - if (!build) { - getLogger().debug(`Build not found`); - return res.status(404).json({ error: `Build not found for ${uuid}` }); - } - - const deploy = build.deploys?.find((candidate) => candidate.deployable?.name === name); - - if (!deploy?.deployable) { - getLogger().debug(`Deployable not found: service=${name}`); - res.status(404).json({ error: `${name} service is not found in ${uuid} build.` }); - return; - } - - const githubRepositoryId = - deploy.deployable.resolvedFromRepositoryId ?? deploy.deployable.repositoryId ?? deploy.githubRepositoryId; - if (githubRepositoryId == null) { - throw new Error(`Cannot redeploy ${name}: source repository is unknown.`); - } - - const runUUID = nanoid(); - await buildService.enqueueResolveAndDeployBuild({ - buildId: build.id, - githubRepositoryId: Number(githubRepositoryId), - runUUID, - }); - - getLogger({ stage: LogStage.BUILD_QUEUED }).info(`Build: service redeploy queued service=${name}`); - return res.status(200).json({ - status: 'success', - message: `Redeploy for service ${name} in build ${uuid} has been queued`, - }); - } catch (error) { - getLogger({ stage: LogStage.BUILD_FAILED }).error( - { error }, - `Unable to proceed with redeploy for services ${name} in build ${uuid}` - ); - return res.status(500).json({ error: `Unable to proceed with redeploy for services ${name} in build ${uuid}.` }); - } - }); -}; diff --git a/src/pages/api/v1/builds/[uuid]/services/[name]/buildLogs.ts b/src/pages/api/v1/builds/[uuid]/services/[name]/buildLogs.ts deleted file mode 100644 index cda9a20b..00000000 --- a/src/pages/api/v1/builds/[uuid]/services/[name]/buildLogs.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { NextApiRequest, NextApiResponse } from 'next'; -import { getLogger, withLogContext } from 'server/lib/logger'; -import { HttpError } from '@kubernetes/client-node'; -import { BuildJobInfo, getNativeBuildJobs } from 'server/lib/kubernetes/getNativeBuildJobs'; - -interface BuildLogsListResponse { - builds: BuildJobInfo[]; -} - -/** - * @openapi - * /api/v1/builds/{uuid}/services/{name}/buildLogs: - * get: - * summary: List build jobs for a service - * description: | - * Returns a list of all build jobs for a specific service within a build. - * This includes both active and completed build jobs with their status, - * timing information, and the build engine used. - * tags: - * - Builds - * - Native Build - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * - in: path - * name: name - * required: true - * schema: - * type: string - * description: The name of the service - * responses: - * '200': - * description: List of build jobs - * content: - * application/json: - * schema: - * type: object - * properties: - * builds: - * type: array - * items: - * type: object - * properties: - * jobName: - * type: string - * description: Kubernetes job name - * example: build-api-abc123-1234567890 - * buildUuid: - * type: string - * description: Deploy UUID - * example: api-abc123 - * sha: - * type: string - * description: Git commit SHA - * example: a1b2c3d4e5f6 - * status: - * type: string - * enum: [Active, Complete, Failed, Pending] - * description: Current status of the build job - * startedAt: - * type: string - * format: date-time - * description: When the job started - * completedAt: - * type: string - * format: date-time - * description: When the job completed - * duration: - * type: number - * description: Build duration in seconds - * engine: - * type: string - * enum: [buildkit, kaniko, unknown] - * description: Build engine used - * podName: - * type: string - * description: Kubernetes pod name associated with the build job - * error: - * type: string - * description: Error message if the build job failed - * source: - * type: string - * enum: [live, archived] - * description: Whether the job is from a live k8s resource or archived in object storage - * '400': - * description: Invalid parameters - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * '404': - * description: Environment or service not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * '405': - * description: Method not allowed (only GET is supported) - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: POST is not allowed - * '502': - * description: Failed to communicate with Kubernetes - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * '500': - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Internal server error occurred. - */ -// eslint-disable-next-line import/no-anonymous-default-export -export default async (req: NextApiRequest, res: NextApiResponse) => { - const { uuid, name } = req.query; - - return withLogContext({ buildUuid: uuid as string }, async () => { - if (req.method !== 'GET') { - getLogger().warn(`API: method not allowed method=${req.method}`); - res.setHeader('Allow', ['GET']); - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - if (typeof uuid !== 'string' || typeof name !== 'string') { - getLogger().warn(`API: invalid params uuid=${uuid} name=${name}`); - return res.status(400).json({ error: 'Missing or invalid uuid or name parameters' }); - } - - try { - const namespace = `env-${uuid}`; - - const buildJobs = await getNativeBuildJobs(name, namespace); - - const response: BuildLogsListResponse = { - builds: buildJobs, - }; - - return res.status(200).json(response); - } catch (error) { - getLogger().error({ error }, `API: build logs fetch failed service=${name}`); - - if (error instanceof HttpError) { - if (error.response?.statusCode === 404) { - return res.status(404).json({ error: 'Environment or service not found.' }); - } - return res.status(502).json({ error: 'Failed to communicate with Kubernetes.' }); - } - - return res.status(500).json({ error: 'Internal server error occurred.' }); - } - }); -}; diff --git a/src/pages/api/v1/builds/[uuid]/services/[name]/buildLogs/[jobName].ts b/src/pages/api/v1/builds/[uuid]/services/[name]/buildLogs/[jobName].ts deleted file mode 100644 index c4577014..00000000 --- a/src/pages/api/v1/builds/[uuid]/services/[name]/buildLogs/[jobName].ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { NextApiRequest, NextApiResponse } from 'next'; -import { getLogger, withLogContext } from 'server/lib/logger'; -import unifiedLogStreamHandler from '../logs/[jobName]'; - -/** - * @openapi - * /api/v1/builds/{uuid}/services/{name}/buildLogs/{jobName}: - * get: - * summary: Get build log streaming information for a specific job - * description: | - * Returns WebSocket endpoint and parameters for streaming build logs from Kubernetes. - * This endpoint provides information needed to establish a WebSocket connection - * for real-time log streaming. - * tags: - * - Builds - * - Native Build - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * - in: path - * name: name - * required: true - * schema: - * type: string - * description: The name of the service - * - in: path - * name: jobName - * required: true - * schema: - * type: string - * description: The name of the build job - * responses: - * 200: - * description: Successful response with WebSocket information - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * enum: [Active, Complete, Failed, NotFound] - * description: Current status of the build job - * websocket: - * type: object - * properties: - * endpoint: - * type: string - * example: wss://example.com/k8s/log/namespace/pod-name/container - * parameters: - * type: object - * properties: - * podName: - * type: string - * namespace: - * type: string - * follow: - * type: boolean - * timestamps: - * type: boolean - * containers: - * type: array - * items: - * type: object - * properties: - * name: - * type: string - * state: - * type: string - * 400: - * description: Bad request - * 404: - * description: Build or deploy not found - * 405: - * description: Method not allowed - * 500: - * description: Internal server error - */ -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const { uuid, jobName } = req.query; - - return withLogContext({ buildUuid: uuid as string }, async () => { - getLogger().info(`API: build logs endpoint called method=${req.method} jobName=${jobName}`); - - req.query.type = 'build'; - - return unifiedLogStreamHandler(req, res); - }); -} diff --git a/src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs.ts b/src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs.ts deleted file mode 100644 index 3faa730a..00000000 --- a/src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { NextApiRequest, NextApiResponse } from 'next'; -import { getLogger, withLogContext } from 'server/lib/logger'; -import { HttpError } from '@kubernetes/client-node'; -import { DeploymentJobInfo, getDeploymentJobs } from 'server/lib/kubernetes/getDeploymentJobs'; - -interface DeployLogsListResponse { - deployments: DeploymentJobInfo[]; -} - -/** - * @openapi - * /api/v1/builds/{uuid}/services/{name}/deployLogs: - * get: - * summary: List deployment jobs for a service - * description: | - * Returns a list of all deployment jobs for a specific service within a build. - * This includes both Helm deployment jobs and GitHub-type deployment jobs. - * tags: - * - Deployments - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * - in: path - * name: name - * required: true - * schema: - * type: string - * description: The name of the service - * responses: - * '200': - * description: List of deployment jobs - * content: - * application/json: - * schema: - * type: object - * properties: - * deployments: - * type: array - * items: - * type: object - * properties: - * jobName: - * type: string - * description: Kubernetes job name - * example: deploy-uuid-helm-123-abc123 - * deployUuid: - * type: string - * description: Deploy UUID - * example: deploy-uuid - * sha: - * type: string - * description: Git commit SHA - * example: abc123 - * status: - * type: string - * enum: [Active, Complete, Failed, Pending] - * description: Current status of the deployment job - * startedAt: - * type: string - * format: date-time - * description: When the job started - * completedAt: - * type: string - * format: date-time - * description: When the job completed - * duration: - * type: number - * description: Deployment duration in seconds - * error: - * type: string - * description: Error message if job failed - * podName: - * type: string - * description: Name of the pod running the job - * deploymentType: - * type: string - * enum: [helm, github] - * description: Type of deployment (helm or github) - * source: - * type: string - * enum: [live, archived] - * description: Whether the job is from a live k8s resource or archived in object storage - * '400': - * description: Invalid parameters - * '404': - * description: Environment or service not found - * '405': - * description: Method not allowed - * '502': - * description: Failed to communicate with Kubernetes - * '500': - * description: Internal server error - */ -const deployLogsHandler = async (req: NextApiRequest, res: NextApiResponse) => { - const { uuid, name } = req.query; - - return withLogContext({ buildUuid: uuid as string }, async () => { - if (req.method !== 'GET') { - getLogger().warn(`API: method not allowed method=${req.method}`); - res.setHeader('Allow', ['GET']); - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - if (typeof uuid !== 'string' || typeof name !== 'string') { - getLogger().warn(`API: invalid params uuid=${uuid} name=${name}`); - return res.status(400).json({ error: 'Missing or invalid uuid or name parameters' }); - } - - try { - const namespace = `env-${uuid}`; - - const deployments = await getDeploymentJobs(name, namespace); - - const response: DeployLogsListResponse = { - deployments, - }; - - return res.status(200).json(response); - } catch (error) { - getLogger().error({ error }, `API: deploy logs fetch failed service=${name}`); - - if (error instanceof HttpError) { - if (error.response?.statusCode === 404) { - return res.status(404).json({ error: 'Environment or service not found.' }); - } - return res.status(502).json({ error: 'Failed to communicate with Kubernetes.' }); - } - - return res.status(500).json({ error: 'Internal server error occurred.' }); - } - }); -}; - -export default deployLogsHandler; diff --git a/src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs/[jobName].ts b/src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs/[jobName].ts deleted file mode 100644 index 02ef1bf9..00000000 --- a/src/pages/api/v1/builds/[uuid]/services/[name]/deployLogs/[jobName].ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * @openapi - * /api/v1/builds/{uuid}/services/{name}/deployLogs/{jobName}: - * get: - * summary: Get deployment log streaming information for a specific job - * description: | - * Returns WebSocket endpoint and parameters for streaming deployment logs from Kubernetes. - * This endpoint provides information needed to establish a WebSocket connection - * for real-time log streaming from deployment jobs. - * tags: - * - Deployments - * - Native Helm - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * - in: path - * name: name - * required: true - * schema: - * type: string - * description: The name of the service - * - in: path - * name: jobName - * required: true - * schema: - * type: string - * description: The name of the deployment job - * responses: - * 200: - * description: Successful response with WebSocket information - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * enum: [Active, Complete, Failed, NotFound, Pending] - * description: Current status of the deployment job - * websocket: - * type: object - * properties: - * endpoint: - * type: string - * example: /api/logs/stream - * parameters: - * type: object - * properties: - * podName: - * type: string - * namespace: - * type: string - * follow: - * type: boolean - * timestamps: - * type: boolean - * container: - * type: string - * required: false - * containers: - * type: array - * items: - * type: object - * properties: - * name: - * type: string - * state: - * type: string - * error: - * type: string - * description: Error message if applicable - * 400: - * description: Bad request - missing or invalid parameters - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Missing or invalid parameters - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: GET is not allowed - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Internal server error occurred. - * 502: - * description: Bad gateway - failed to communicate with Kubernetes - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Failed to communicate with Kubernetes. - */ -import type { NextApiRequest, NextApiResponse } from 'next'; -import { getLogger, withLogContext } from 'server/lib/logger'; -import unifiedLogStreamHandler from '../logs/[jobName]'; - -const deployLogStreamHandler = async (req: NextApiRequest, res: NextApiResponse) => { - const { uuid, jobName } = req.query; - - return withLogContext({ buildUuid: uuid as string }, async () => { - getLogger().info( - `method=${req.method} jobName=${jobName} Deploy logs endpoint called, delegating to unified handler` - ); - - req.query.type = 'deploy'; - - return unifiedLogStreamHandler(req, res); - }); -}; - -export default deployLogStreamHandler; diff --git a/src/pages/api/v1/builds/[uuid]/services/[name]/deployment.ts b/src/pages/api/v1/builds/[uuid]/services/[name]/deployment.ts deleted file mode 100644 index 3fe4bcb8..00000000 --- a/src/pages/api/v1/builds/[uuid]/services/[name]/deployment.ts +++ /dev/null @@ -1,296 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { NextApiRequest, NextApiResponse } from 'next'; -import { getLogger, withLogContext } from 'server/lib/logger'; -import * as k8s from '@kubernetes/client-node'; -import { HttpError } from '@kubernetes/client-node'; -import { Deploy } from 'server/models'; - -const kc = new k8s.KubeConfig(); -kc.loadFromDefault(); - -interface HelmDeploymentDetails { - type: 'helm'; - releaseName: string; - chart: string; - version?: string; - values: Record; - manifest?: string; -} - -interface GitHubDeploymentDetails { - type: 'github'; - manifestConfigMap: string; - manifest: string; -} - -async function getHelmDeploymentDetails(namespace: string, deployUuid: string): Promise { - const coreV1Api = kc.makeApiClient(k8s.CoreV1Api); - - try { - const secretName = `sh.helm.release.v1.${deployUuid}.v1`; - getLogger({}).debug(`Checking for Helm secret: secretName=${secretName} namespace=${namespace}`); - - const secret = await coreV1Api.readNamespacedSecret(secretName, namespace); - - if (!secret.body.data?.release) { - getLogger({}).debug(`Helm secret found but no release data: secretName=${secretName}`); - return null; - } - - const firstDecode = Buffer.from(secret.body.data.release, 'base64').toString(); - - let releaseData: Buffer; - if (/^[A-Za-z0-9+/]/.test(firstDecode) && firstDecode.length % 4 <= 2) { - try { - releaseData = Buffer.from(firstDecode, 'base64'); - } catch { - releaseData = Buffer.from(firstDecode); - } - } else { - releaseData = Buffer.from(firstDecode); - } - - let release: any; - try { - const zlib = require('zlib'); - const decompressed = zlib.gunzipSync(releaseData); - release = JSON.parse(decompressed.toString()); - } catch (decompressError: any) { - try { - release = JSON.parse(releaseData.toString()); - } catch (parseError: any) { - getLogger({}).warn( - `Failed to parse Helm release data: deployUuid=${deployUuid} decompress_error=${decompressError.message} parse_error=${parseError.message}` - ); - return null; - } - } - - return { - type: 'helm', - releaseName: release.name, - chart: release.chart?.metadata?.name || 'unknown', - version: release.chart?.metadata?.version, - values: release.config || {}, - manifest: release.manifest, - }; - } catch (error) { - if (error instanceof HttpError && error.response?.statusCode === 404) { - return null; - } - throw error; - } -} - -async function getGitHubDeploymentDetails( - namespace: string, - deployUuid: string -): Promise { - const coreV1Api = kc.makeApiClient(k8s.CoreV1Api); - - try { - const labelSelector = `deploy_uuid=${deployUuid},app=lifecycle-deploy`; - const configMaps = await coreV1Api.listNamespacedConfigMap( - namespace, - undefined, - undefined, - undefined, - undefined, - labelSelector - ); - - const manifestConfigMap = configMaps.body.items.find((cm) => cm.metadata?.name?.includes('-manifest')); - - if (!manifestConfigMap || !manifestConfigMap.data?.['manifest.yaml']) { - const deploy = await Deploy.query().where('uuid', deployUuid).withGraphFetched('[deployable]').first(); - - if (!deploy?.manifest) { - return null; - } - - return { - type: 'github', - manifestConfigMap: 'stored-in-database', - manifest: deploy.manifest, - }; - } - - return { - type: 'github', - manifestConfigMap: manifestConfigMap.metadata?.name || '', - manifest: manifestConfigMap.data['manifest.yaml'], - }; - } catch (error) { - if (error instanceof HttpError && error.response?.statusCode === 404) { - return null; - } - throw error; - } -} - -/** - * @openapi - * /api/v1/builds/{uuid}/services/{name}/deployment: - * get: - * summary: Get deployment details - * description: | - * Returns detailed information about a specific deployment. - * For Helm deployments, this includes the release information and values. - * For GitHub-type deployments, this includes the Kubernetes manifest. - * tags: - * - Deployments - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * - in: path - * name: name - * required: true - * schema: - * type: string - * description: The name of the service - * responses: - * '200': - * description: Deployment details - * content: - * application/json: - * schema: - * oneOf: - * - type: object - * properties: - * type: - * type: string - * const: helm - * releaseName: - * type: string - * example: my-service - * chart: - * type: string - * example: my-chart - * version: - * type: string - * example: 1.2.3 - * values: - * type: object - * description: Helm values used for deployment - * manifest: - * type: string - * description: Rendered Kubernetes manifest - * - type: object - * properties: - * type: - * type: string - * const: github - * manifestConfigMap: - * type: string - * example: deploy-uuid-manifest - * manifest: - * type: string - * description: Kubernetes manifest YAML - * '400': - * description: Invalid parameters - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * '404': - * description: Deployment not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * '405': - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * '500': - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - */ -const handler = async (req: NextApiRequest, res: NextApiResponse) => { - const { uuid, name } = req.query; - - return withLogContext({ buildUuid: uuid as string }, async () => { - if (req.method !== 'GET') { - getLogger().warn(`API: method not allowed method=${req.method}`); - res.setHeader('Allow', ['GET']); - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - if (typeof uuid !== 'string' || typeof name !== 'string') { - getLogger().warn(`API: invalid query params uuid=${uuid} name=${name}`); - return res.status(400).json({ error: 'Missing or invalid parameters' }); - } - - const deployUuid = `${name}-${uuid}`; - - try { - const namespace = `env-${uuid}`; - - getLogger().debug(`Fetching deployment details: deployUuid=${deployUuid} namespace=${namespace} service=${name}`); - - const helmDetails = await getHelmDeploymentDetails(namespace, deployUuid); - if (helmDetails) { - getLogger().debug(`Found Helm deployment details: deployUuid=${deployUuid}`); - return res.status(200).json(helmDetails); - } - - const githubDetails = await getGitHubDeploymentDetails(namespace, deployUuid); - if (githubDetails) { - getLogger().debug(`Found GitHub-type deployment details: deployUuid=${deployUuid}`); - return res.status(200).json(githubDetails); - } - - getLogger().warn(`API: deployment not found deployUuid=${deployUuid}`); - return res.status(404).json({ error: 'Deployment not found' }); - } catch (error) { - getLogger().error({ error }, `API: deployment details error deployUuid=${deployUuid}`); - - if (error instanceof HttpError) { - if (error.response?.statusCode === 404) { - return res.status(404).json({ error: 'Deployment not found' }); - } - return res.status(502).json({ error: 'Failed to communicate with Kubernetes' }); - } - - return res.status(500).json({ error: 'Internal server error' }); - } - }); -}; - -export default handler; diff --git a/src/pages/api/v1/builds/[uuid]/services/[name]/logs.ts b/src/pages/api/v1/builds/[uuid]/services/[name]/logs.ts deleted file mode 100644 index 339e94c8..00000000 --- a/src/pages/api/v1/builds/[uuid]/services/[name]/logs.ts +++ /dev/null @@ -1,245 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { NextApiRequest, NextApiResponse } from 'next'; -import { spawn, exec } from 'child_process'; -import { promisify } from 'util'; -import GithubService from 'server/services/github'; -import { Build } from 'server/models'; - -// Constants -const MAX_CONCURRENT_PODS = 5; -const LOG_TIMEOUT_MS = 3600000; // 1 hour -const MAX_INITIAL_LINES = 100; - -const execAsync = promisify(exec); - -// Types -interface LogStreamOptions { - podName: string; - namespace: string; - sendSSE: (msg: string) => void; // eslint-disable-line no-unused-vars -} - -type ContainerType = 'app' | 'init'; - -// Input validation helpers -const isValidContainerType = (type: string | undefined): type is ContainerType => { - return type === undefined || type === 'app' || type === 'init'; -}; - -/** - * @openapi - * /api/v1/builds/{uuid}/services/{name}/logs: - * get: - * summary: Stream logs from a service's containers - * description: | - * Streams logs from either application containers or init containers of a service - * using Server-Sent Events (SSE). The stream will continue until the client disconnects - * or the timeout is reached. - * deprecated: true - * tags: - * - Logs - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * - in: path - * name: name - * required: true - * schema: - * type: string - * description: The name of the service - * - in: query - * name: containerType - * required: false - * schema: - * type: string - * enum: [app, init] - * description: Type of containers to stream logs from. Defaults to 'app' - * responses: - * 200: - * description: Success. Returns a stream of Server-Sent Events - * content: - * text/event-stream: - * schema: - * type: string - * example: | - * data: [pod/name] Log message content - * 400: - * description: Bad request - invalid parameters - * 405: - * description: Method not allowed - */ - -export const config = { - api: { - bodyParser: false, - }, -}; - -async function getPodNames(namespace: string, deployment: string): Promise { - const getPodsCmd = `kubectl get pods --namespace ${namespace} -l app.kubernetes.io/instance=${deployment} -o jsonpath="{range .items[*]}{.metadata.name}{'\\n'}{end}"`; - const { stdout, stderr } = await execAsync(getPodsCmd); - - if (stderr) { - throw new Error(`Failed to retrieve pods: ${stderr}`); - } - - return stdout - .trim() - .split('\n') - .filter((name) => name.trim() !== ''); -} - -function createPodLogStream({ - podName, - namespace, - sendSSE, - containerType = 'app', -}: LogStreamOptions & { containerType?: ContainerType }) { - const kubectlArgs = ['logs', podName, '--namespace', namespace, '-f', `--tail=${MAX_INITIAL_LINES}`]; - - if (containerType === 'init') { - kubectlArgs.push('--container=init-container'); - } else { - kubectlArgs.push('--all-containers=true'); - kubectlArgs.push('--prefix=true'); - } - - const proc = spawn('kubectl', kubectlArgs); - - let buffer = ''; - const flush = () => { - if (buffer) { - sendSSE(buffer); - buffer = ''; - } - }; - - proc.stdout.on('data', (data: Buffer) => { - buffer += data.toString(); - const lines = buffer.split('\n'); - // Keep the last, possibly incomplete, line in the buffer - buffer = lines.pop() || ''; - lines.forEach((line) => { - if (line.trim()) { - sendSSE(line); - } - }); - }); - - proc.stderr.on('data', (data: Buffer) => { - sendSSE(`Error: ${data.toString()}`); - }); - - proc.on('error', (error: Error) => { - sendSSE(`Process error: ${error.message}`); - }); - - proc.on('close', (code: number) => { - flush(); - sendSSE(`Log streaming ended with code ${code}`); - }); - - return proc; -} - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method === 'OPTIONS') { - res.status(200).end(); - return; - } - - if (req.method !== 'GET') { - res.status(405).json({ error: 'Method Not Allowed' }); - return; - } - - const { uuid, name } = req.query; - if (!uuid || !name || typeof uuid !== 'string' || typeof name !== 'string') { - res.status(400).json({ error: 'Invalid path parameters' }); - return; - } - - const deployment = `${name}-${uuid}`; - const containerType = req.query.containerType as string | undefined; - if (!isValidContainerType(containerType)) { - res.status(400).json({ error: 'Invalid container type. Must be "app" or "init"' }); - return; - } - - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache, no-transform', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - 'Access-Control-Allow-Origin': '*', - }); - - const sendSSE = (data: string) => { - res.write(`data: ${data}\n\n`); - }; - - try { - const githubService = new GithubService(); - const build: Build = await githubService.db.models.Build.query().findOne({ - uuid, - }); - const deployNamespace = build.namespace; - if (!deployNamespace) { - throw new Error('Deployment namespace not configured'); - } - - const podNames = await getPodNames(deployNamespace, deployment); - - if (podNames.length === 0) { - sendSSE(`No pods found for deployment "${deployment}" in namespace "${deployNamespace}"`); - res.end(); - return; - } - - if (podNames.length > MAX_CONCURRENT_PODS) { - podNames.length = MAX_CONCURRENT_PODS; - } - - const logProcesses = podNames.map((podName) => { - return createPodLogStream({ - podName, - namespace: deployNamespace, - sendSSE, - containerType: (containerType as ContainerType) || 'app', - }); - }); - - const timeout = setTimeout(() => { - logProcesses.forEach((proc) => proc.kill()); - res.end(); - }, LOG_TIMEOUT_MS); - - req.on('close', () => { - clearTimeout(timeout); - logProcesses.forEach((proc) => proc.kill()); - }); - } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); - sendSSE(`Error: ${errorMessage}`); - res.end(); - } -} diff --git a/src/pages/api/v1/builds/[uuid]/services/[name]/logs/[jobName].ts b/src/pages/api/v1/builds/[uuid]/services/[name]/logs/[jobName].ts deleted file mode 100644 index ce48cd3e..00000000 --- a/src/pages/api/v1/builds/[uuid]/services/[name]/logs/[jobName].ts +++ /dev/null @@ -1,219 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * @openapi - * /api/v1/builds/{uuid}/services/{name}/logs/{jobName}: - * get: - * summary: Get log streaming information for a specific job (build or deploy) - * description: | - * Returns WebSocket endpoint and parameters for streaming logs from Kubernetes. - * This unified endpoint handles both build and deployment logs, providing information - * needed to establish a WebSocket connection for real-time log streaming. - * tags: - * - Logs - * - Builds - * - Deployments - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * - in: path - * name: name - * required: true - * schema: - * type: string - * description: The name of the service - * - in: path - * name: jobName - * required: true - * schema: - * type: string - * description: The name of the job (build or deploy) - * - in: query - * name: type - * required: false - * schema: - * type: string - * enum: [build, deploy, webhook] - * description: The type of logs to retrieve (defaults to auto-detection based on job name) - * responses: - * 200: - * description: Successful response with WebSocket information - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * enum: [Active, Complete, Failed, NotFound, Pending, Archived] - * description: Current status of the job - * streamingRequired: - * type: boolean - * description: Whether streaming is required for active logs - * podName: - * type: string - * nullable: true - * description: Name of the pod running the job - * websocket: - * type: object - * properties: - * endpoint: - * type: string - * example: /api/logs/stream - * parameters: - * type: object - * properties: - * podName: - * type: string - * namespace: - * type: string - * follow: - * type: boolean - * timestamps: - * type: boolean - * container: - * type: string - * required: false - * containers: - * type: array - * items: - * type: object - * properties: - * name: - * type: string - * state: - * type: string - * message: - * type: string - * description: Additional message about the job status - * error: - * type: string - * description: Error message if applicable - * archivedLogs: - * type: string - * description: Full log content from object storage (only present when status is Archived) - * 400: - * description: Bad request - missing or invalid parameters - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Missing or invalid parameters - * 404: - * description: Build not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Build not found - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: GET is not allowed - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Internal server error occurred. - * 502: - * description: Bad gateway - failed to communicate with Kubernetes - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Failed to communicate with Kubernetes. - */ -import type { NextApiRequest, NextApiResponse } from 'next'; -import { getLogger, withLogContext } from 'server/lib/logger'; -import { LogStreamingService } from 'server/services/logStreaming'; -import type { LogType } from 'server/services/types/logStreaming'; -import { HttpError } from '@kubernetes/client-node'; - -const unifiedLogStreamHandler = async (req: NextApiRequest, res: NextApiResponse) => { - const { uuid, name, jobName, type } = req.query; - - return withLogContext({ buildUuid: uuid as string }, async () => { - if (req.method !== 'GET') { - getLogger().warn(`API: method not allowed method=${req.method}`); - res.setHeader('Allow', ['GET']); - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - const isWebhookRequest = type === 'webhook'; - - if (typeof uuid !== 'string' || typeof jobName !== 'string' || (!isWebhookRequest && typeof name !== 'string')) { - getLogger().warn(`API: invalid params uuid=${uuid} name=${name} jobName=${jobName} type=${type}`); - return res.status(400).json({ error: 'Missing or invalid parameters' }); - } - - if (type && (typeof type !== 'string' || !['build', 'deploy', 'webhook'].includes(type))) { - getLogger().warn(`API: invalid type param type=${type}`); - return res.status(400).json({ error: 'Invalid type parameter. Must be "build", "deploy", or "webhook"' }); - } - - try { - const logService = new LogStreamingService(); - - const response = await logService.getLogStreamInfo( - uuid, - jobName, - name as string | undefined, - type as LogType | undefined - ); - - return res.status(200).json(response); - } catch (error: any) { - getLogger().error({ error }, `API: log streaming info failed jobName=${jobName} service=${name}`); - - if (error.message === 'Build not found') { - return res.status(404).json({ error: 'Build not found' }); - } - - if (error instanceof HttpError || error.message?.includes('Kubernetes') || error.statusCode === 502) { - return res.status(502).json({ error: 'Failed to communicate with Kubernetes.' }); - } - - return res.status(500).json({ error: 'Internal server error occurred.' }); - } - }); -}; - -export default unifiedLogStreamHandler; diff --git a/src/pages/api/v1/builds/[uuid]/torndown.ts b/src/pages/api/v1/builds/[uuid]/torndown.ts deleted file mode 100644 index 4450dde5..00000000 --- a/src/pages/api/v1/builds/[uuid]/torndown.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { NextApiRequest, NextApiResponse } from 'next/types'; -import { getLogger, withLogContext } from 'server/lib/logger'; -import { Build } from 'server/models'; - -import { BuildStatus, DeployStatus } from 'shared/constants'; -import BuildService from 'server/services/build'; - -/** - * @openapi - * /api/v1/builds/{uuid}/torndown: - * patch: - * summary: Tear down a build environment - * description: | - * Changes the status of all Deploys, Builds and Deployables associated with the specified - * UUID to torn_down. This effectively marks the environment as deleted. - * tags: - * - Builds - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build to tear down - * responses: - * 200: - * description: Build successfully torn down - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: The namespace env-noisy-mud-690038 it was delete sucessfuly - * namespacesUpdated: - * type: array - * items: - * type: object - * properties: - * id: - * type: number - * example: 64087 - * uuid: - * type: string - * example: noisy-mud-690038 - * status: - * type: string - * example: torn_down - * 404: - * description: Build not found or is a static environment - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: The build doesn't exist or is static environment - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: GET is not allowed - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: An unexpected error occurred. - */ -// eslint-disable-next-line import/no-anonymous-default-export -export default async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== 'PATCH') { - getLogger().debug(`Method not allowed: method=${req.method}`); - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - const uuid = req.query?.uuid; - - if (!uuid) { - getLogger().debug('The uuid is required'); - return res.status(500).json({ error: 'The uuid is required' }); - } - - return withLogContext({ buildUuid: uuid as string }, async () => { - try { - const buildService = new BuildService(); - - const build: Build = await buildService.db.models.Build.query() - .findOne({ - uuid, - }) - .withGraphFetched('[deploys]'); - - if (build.isStatic || !build) { - getLogger().debug('Build does not exist or is static environment'); - return res.status(404).json({ error: `The build doesn't exist or is static environment` }); - } - - const deploysIds = build.deploys.map((deploy) => deploy.id); - - await buildService.db.models.Build.query().findById(build.id).patch({ - status: BuildStatus.TORN_DOWN, - statusMessage: 'Namespace was deleted successfully', - }); - - await buildService.db.models.Deploy.query() - .whereIn('id', deploysIds) - .patch({ status: DeployStatus.TORN_DOWN, statusMessage: 'Namespace was deleted successfully' }); - - const updatedDeploys = await buildService.db.models.Deploy.query() - .whereIn('id', deploysIds) - .select('id', 'uuid', 'status'); - - return res.status(200).json({ - status: `The namespace env-${uuid} it was delete sucessfuly`, - namespacesUpdated: updatedDeploys, - }); - } catch (error) { - getLogger().error({ error }, 'Build: teardown failed'); - return res.status(500).json({ error: 'An unexpected error occurred.' }); - } - }); -}; diff --git a/src/pages/api/v1/builds/[uuid]/webhooks.ts b/src/pages/api/v1/builds/[uuid]/webhooks.ts deleted file mode 100644 index 7d8c1cb6..00000000 --- a/src/pages/api/v1/builds/[uuid]/webhooks.ts +++ /dev/null @@ -1,300 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { NextApiRequest, NextApiResponse } from 'next/types'; -import { nanoid } from 'nanoid'; -import { withLogContext, getLogger, LogStage } from 'server/lib/logger'; -import GithubService from 'server/services/github'; -import { Build } from 'server/models'; -import WebhookService from 'server/services/webhook'; - -/** - * @openapi - * /api/v1/builds/{uuid}/webhooks: - * get: - * summary: Retrieve webhook invocations for a build - * description: | - * Retrieves a paginated list of webhook invocations for a specific build, - * ordered by creation date in descending order. - * tags: - * - Webhooks - * - Builds - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * - in: query - * name: page - * required: false - * schema: - * type: integer - * minimum: 1 - * default: 1 - * description: Page number for pagination - * - in: query - * name: limit - * required: false - * schema: - * type: integer - * minimum: 1 - * maximum: 100 - * default: 100 - * description: Number of items per page - * responses: - * 200: - * description: Successfully retrieved webhook invocations - * content: - * application/json: - * schema: - * type: object - * properties: - * webhooks: - * type: array - * items: - * type: object - * description: Webhook invocation details - * metadata: - * type: object - * properties: - * currentPage: - * type: integer - * example: 1 - * totalPages: - * type: integer - * example: 5 - * total: - * type: integer - * example: 42 - * limit: - * type: integer - * example: 100 - * 404: - * description: Build not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Build not found for abc-123 - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Unable to retrieve webhooks for build abc-123. - * post: - * summary: Invoke webhooks for a build - * description: | - * Triggers the execution of configured webhooks for a specific build. - * The webhooks must be defined in the build's webhooksYaml configuration. - * tags: - * - Webhooks - * - Builds - * parameters: - * - in: path - * name: uuid - * required: true - * schema: - * type: string - * description: The UUID of the build - * responses: - * 200: - * description: Webhooks successfully queued - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: success - * message: - * type: string - * example: Webhook for build abc-123 has been queued - * 204: - * description: No webhooks configured for the build - * content: - * application/json: - * schema: - * type: object - * properties: - * status: - * type: string - * example: no_content - * message: - * type: string - * example: No webhooks found for build abc-123. - * 400: - * description: Invalid UUID provided - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Invalid UUID - * 404: - * description: Build not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Build not found for abc-123 - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: PUT is not allowed. - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Unable to proceed with triggering webhook for build abc-123. - */ -// eslint-disable-next-line import/no-anonymous-default-export -export default async (req: NextApiRequest, res: NextApiResponse) => { - const { uuid } = req.query; - - if (!uuid || typeof uuid !== 'string') { - return res.status(400).json({ error: 'Invalid UUID' }); - } - - return withLogContext({ buildUuid: uuid }, async () => { - try { - switch (req.method) { - case 'GET': - return retrieveWebhooks(req, res); - case 'POST': - return invokeWebhooks(req, res); - default: - res.setHeader('Allow', ['GET', 'POST']); - return res.status(405).json({ error: `${req.method} is not allowed.` }); - } - } catch (error) { - getLogger({ error }).error(`API: webhook request failed method=${req.method}`); - res.status(500).json({ error: 'An unexpected error occurred.' }); - } - }); -}; - -async function invokeWebhooks(req: NextApiRequest, res: NextApiResponse) { - const { uuid } = req.query; - const correlationId = `api-webhook-invoke-${Date.now()}-${nanoid(8)}`; - - return withLogContext({ correlationId }, async () => { - try { - const githubService = new GithubService(); - const build: Build = await githubService.db.models.Build.query().findOne({ - uuid, - }); - - const buildId = build.id; - - if (!build) { - getLogger().debug('Build not found'); - return res.status(404).json({ error: `Build not found for ${uuid}` }); - } - - if (!build.webhooksYaml) { - getLogger().debug('No webhooks found for build'); - return res.status(204).json({ - status: 'no_content', - message: `No webhooks found for build ${uuid}.`, - }); - } - - const webhookService = new WebhookService(); - await webhookService.webhookQueue.add('webhook', { - buildId, - correlationId, - }); - - getLogger({ stage: LogStage.WEBHOOK_PROCESSING }).info('Webhook invocation queued via API'); - - return res.status(200).json({ - status: 'success', - message: `Webhook for build ${uuid} has been queued`, - }); - } catch (error) { - getLogger({ stage: LogStage.WEBHOOK_PROCESSING, error }).error(`Webhook: invoke failed uuid=${uuid}`); - return res.status(500).json({ error: `Unable to proceed with triggering webhook for build ${uuid}.` }); - } - }); -} - -async function retrieveWebhooks(req: NextApiRequest, res: NextApiResponse) { - const { uuid, page = 1, limit = 100 } = req.query; - if (typeof uuid !== 'string') { - return res.status(400).json({ error: 'Invalid UUID' }); - } - try { - const pageNumber = Math.max(1, parseInt(page as string, 10) || 1); - const limitNumber = Math.max(1, parseInt(limit as string, 10) || 10); - const offset = (pageNumber - 1) * limitNumber; - - const webhookService = new WebhookService(); - - const buildId = webhookService.db.models.Build.query().select('id').where('uuid', uuid).first(); - - if (!buildId) { - return res.status(404).json({ error: `Build not found for ${uuid}` }); - } - - const total = await webhookService.db.models.WebhookInvocations.query().where('buildId', buildId).resultSize(); - - const webhooks = await webhookService.db.models.WebhookInvocations.query() - .where('buildId', buildId) - .orderBy('createdAt', 'desc') - .offset(offset) - .limit(limitNumber); - const totalPages = Math.ceil(total / limitNumber); - res.status(200).json({ - webhooks, - metadata: { - currentPage: pageNumber, - totalPages, - total, - limit: limitNumber, - }, - }); - } catch (error) { - getLogger({ error }).error('API: webhooks fetch failed'); - return res.status(500).json({ error: `Unable to retrieve webhooks for build ${uuid}.` }); - } -} diff --git a/src/pages/api/v1/builds/index.ts b/src/pages/api/v1/builds/index.ts deleted file mode 100644 index 3405df36..00000000 --- a/src/pages/api/v1/builds/index.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { NextApiRequest, NextApiResponse } from 'next/types'; -import { getLogger } from 'server/lib/logger'; -import BuildService from 'server/services/build'; - -/** - * @openapi - * /api/v1/builds: - * get: - * summary: Get all builds with optional status filtering - * description: | - * Retrieves a list of builds with optional status exclusion filtering and pagination support. - * By default, excludes builds with status 'torn_down' and 'pending'. - * tags: - * - Builds - * parameters: - * - in: query - * name: exclude - * required: false - * schema: - * oneOf: - * - type: array - * items: - * type: string - * - type: string - * description: | - * Status values to exclude from results. Can be passed as: - * - JSON array string: exclude=["torn_down","pending"] - * - Multiple query params: exclude=torn_down&exclude=pending - * - Single value: exclude=torn_down - * Default: ["torn_down", "pending"] - * - in: query - * name: page - * required: false - * schema: - * type: integer - * minimum: 1 - * description: Page number for pagination (if not provided with limit, all results are returned) - * - in: query - * name: limit - * required: false - * schema: - * type: integer - * minimum: 1 - * description: Number of items per page (if not provided with page, all results are returned) - * responses: - * 200: - * description: Successfully retrieved builds list - * content: - * application/json: - * schema: - * type: object - * required: - * - builds - * - metadata - * properties: - * builds: - * type: array - * items: - * type: object - * required: - * - uuid - * - status - * properties: - * uuid: - * type: string - * description: Unique identifier for the build - * example: "550e8400-e29b-41d4-a716-446655440000" - * status: - * type: string - * description: Current status of the build - * example: "success" - * metadata: - * type: object - * required: - * - currentPage - * - totalPages - * - total - * - limit - * properties: - * currentPage: - * type: integer - * description: Current page number - * example: 1 - * totalPages: - * type: integer - * description: Total number of pages - * example: 5 - * total: - * type: integer - * description: Total number of builds after filtering - * example: 100 - * limit: - * type: integer - * description: Number of items per page - * example: 20 - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: "POST is not allowed" - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: "An unexpected error occurred" - */ -// eslint-disable-next-line import/no-anonymous-default-export -export default async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== 'GET') { - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - try { - const buildService = new BuildService(); - - // Extract query parameters - const { exclude, page, limit } = req.query; - - // Parse exclude parameter - default to ['torn_down', 'pending'] - let excludeStatuses: string[] = ['torn_down', 'pending']; - if (exclude) { - if (Array.isArray(exclude)) { - excludeStatuses = exclude.filter((status) => typeof status === 'string'); - } else if (typeof exclude === 'string') { - try { - const parsed = JSON.parse(exclude); - if (Array.isArray(parsed)) { - excludeStatuses = parsed.filter((status) => typeof status === 'string'); - } - } catch { - // If not valid JSON, treat as single value - excludeStatuses = [exclude]; - } - } - } - - // Build base query - let query = buildService.db.models.Build.query().select('uuid', 'status').whereNotIn('status', excludeStatuses); - - // Handle pagination - let response: any = {}; - - if (page || limit) { - // If pagination params provided, apply pagination - const pageNumber = Math.max(1, parseInt(page as string, 10) || 1); - const limitNumber = Math.max(1, parseInt(limit as string, 10) || 20); - const offset = (pageNumber - 1) * limitNumber; - - // Get total count - const totalCount = await query.resultSize(); - - // Apply pagination - const builds = await query.offset(offset).limit(limitNumber); - - response = { - builds, - metadata: { - currentPage: pageNumber, - totalPages: Math.ceil(totalCount / limitNumber), - total: totalCount, - limit: limitNumber, - }, - }; - } else { - // No pagination - return all results - const builds = await query; - - response = { - builds, - metadata: { - currentPage: 1, - totalPages: 1, - total: builds.length, - limit: builds.length, - }, - }; - } - - return res.status(200).json(response); - } catch (error) { - getLogger({ error }).error('API: builds fetch failed'); - return res.status(500).json({ error: 'An unexpected error occurred' }); - } -}; diff --git a/src/pages/api/v1/config/cache.ts b/src/pages/api/v1/config/cache.ts deleted file mode 100644 index 78c0e248..00000000 --- a/src/pages/api/v1/config/cache.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { NextApiRequest, NextApiResponse } from 'next'; -import { getLogger } from 'server/lib/logger'; -import { verifyBearerToken } from 'server/lib/auth'; -import { getIdentityFromClaims } from 'server/lib/get-user'; -import GlobalConfigService from 'server/services/globalConfig'; - -/** - * @openapi - * /api/v1/config/cache: - * get: - * summary: Retrieve global configuration - * description: Fetches the current global configuration values from cache - * tags: - * - Configuration - * responses: - * 200: - * description: Successfully retrieved configuration - * content: - * application/json: - * schema: - * type: object - * properties: - * configs: - * type: object - * description: Global configuration values - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: POST is not allowed. - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Unable to retrieve global config values - * put: - * summary: Refresh and retrieve global configuration - * description: Forces a refresh of the cached configuration values and returns the updated configuration - * tags: - * - Configuration - * responses: - * 200: - * description: Successfully refreshed and retrieved configuration - * content: - * application/json: - * schema: - * type: object - * properties: - * configs: - * type: object - * description: Updated global configuration values - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: POST is not allowed. - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Unable to retrieve global config values - */ -// eslint-disable-next-line import/no-anonymous-default-export -export default async (req: NextApiRequest, res: NextApiResponse) => { - res.setHeader('Cache-Control', 'no-store'); - // V1 bypasses auth middleware: verify bearer cryptographically here; x-user is attacker-controlled. - if (process.env.ENABLE_AUTH !== 'true') return res.status(403).json({ error: 'Administrator session required.' }); - const authorization = req.headers.authorization; - const bearer = - typeof authorization === 'string' && authorization.length <= 16384 ? /^Bearer\s+(\S+)$/i.exec(authorization) : null; - if (!bearer) return res.status(403).json({ error: 'Administrator session required.' }); - const verified = await verifyBearerToken(bearer[1]); - const identity = verified.success ? getIdentityFromClaims(verified.payload ?? null) : null; - if (!identity?.issuer || !identity.roles.includes('admin')) { - return res.status(403).json({ error: 'Administrator session required.' }); - } - try { - switch (req.method) { - case 'GET': - return getCachedConfig(res); - case 'PUT': - return getCachedConfig(res, true); - default: - res.setHeader('Allow', ['GET', 'PUT']); - return res.status(405).json({ error: `${req.method} is not allowed.` }); - } - } catch (error) { - getLogger().error({ error }, 'Config: cache operation failed'); - res.status(500).json({ error: 'An unexpected error occurred.' }); - } -}; - -async function getCachedConfig(res: NextApiResponse, refresh: boolean = false) { - try { - const configService = new GlobalConfigService(); - const configs = await configService.getAllConfigs(refresh); - return res.status(200).json({ configs }); - } catch (error) { - getLogger().error({ error }, 'Config: cache retrieval failed'); - return res.status(500).json({ error: `Unable to retrieve global config values` }); - } -} diff --git a/src/pages/api/v1/deploy-summary.ts b/src/pages/api/v1/deploy-summary.ts deleted file mode 100644 index ea89d418..00000000 --- a/src/pages/api/v1/deploy-summary.ts +++ /dev/null @@ -1,161 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { NextApiRequest, NextApiResponse } from 'next/types'; -import { getLogger } from 'server/lib/logger'; -import BuildService from 'server/services/build'; - -/** - * @openapi - * /api/v1/deploy-summary: - * get: - * summary: Get deploy summary by build ID - * description: | - * Retrieves deploy summary information from the deploySummary view for a specific build ID. - * tags: - * - Deploys - * parameters: - * - in: query - * name: buildId - * required: true - * schema: - * type: integer - * description: The ID of the build - * responses: - * 200: - * description: Successfully retrieved deploy summary - * content: - * application/json: - * schema: - * type: array - * items: - * type: object - * properties: - * deployableID: - * type: integer - * name: - * type: string - * buildId: - * type: integer - * buildUUID: - * type: string - * branchName: - * type: string - * type: - * type: string - * active: - * type: boolean - * serviceId: - * type: integer - * defaultenv: - * type: object - * status: - * type: string - * publicUrl: - * type: string - * public: - * type: boolean - * hostPortMapping: - * type: object - * grpc: - * type: object - * capacityType: - * type: string - * 400: - * description: Invalid build ID - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Invalid build ID - * 404: - * description: Build not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Build not found - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - */ -// eslint-disable-next-line import/no-anonymous-default-export -export default async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== 'GET') { - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - const { buildId } = req.query; - const parsedBuildId = parseInt(buildId as string, 10); - - if (!buildId || typeof buildId !== 'string' || isNaN(parsedBuildId)) { - return res.status(400).json({ error: 'Invalid build ID' }); - } - - try { - const buildService = new BuildService(); - - // First check if build exists - const build = await buildService.db.models.Build.query().findById(parsedBuildId).select('id'); - - if (!build) { - getLogger().debug(`Build not found: buildId=${parsedBuildId}`); - return res.status(404).json({ error: 'Build not found' }); - } - - // Query the deploySummary view using raw SQL - const result = await buildService.db.knex.raw( - ` - SELECT - "deployableID", - "name", - "buildId", - "buildUUID", - "branchName", - "type", - "active", - "serviceId", - "defaultenv", - "status", - "publicUrl", - "public", - "hostPortMapping", - "grpc", - "capacityType" - FROM "deploySummary" - WHERE "buildId" = ? - `, - [parsedBuildId] - ); - - return res.status(200).json(result.rows); - } catch (error) { - getLogger({ error }).error(`API: deploy summary fetch failed buildId=${parsedBuildId}`); - return res.status(500).json({ error: 'An unexpected error occurred' }); - } -}; diff --git a/src/pages/api/v1/deployables.ts b/src/pages/api/v1/deployables.ts deleted file mode 100644 index afb0ce3a..00000000 --- a/src/pages/api/v1/deployables.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { NextApiRequest, NextApiResponse } from 'next/types'; -import { getLogger } from 'server/lib/logger'; -import BuildService from 'server/services/build'; - -/** - * @openapi - * /api/v1/deployables: - * get: - * summary: Get deployables by build ID - * description: | - * Retrieves all deployables associated with a specific build ID. - * tags: - * - Deployables - * parameters: - * - in: query - * name: buildId - * required: true - * schema: - * type: integer - * description: The ID of the build - * - in: query - * name: name - * required: false - * schema: - * type: string - * description: Filter deployables by name - * responses: - * 200: - * description: Successfully retrieved deployables - * content: - * application/json: - * schema: - * type: array - * items: - * type: object - * properties: - * id: - * type: integer - * buildUUID: - * type: string - * buildId: - * type: integer - * name: - * type: string - * branchName: - * type: string - * type: - * type: string - * active: - * type: boolean - * grpc: - * type: object - * capacityType: - * type: string - * public: - * type: boolean - * commentBranchName: - * type: string - * deploymentDependsOn: - * type: object - * dockerBuildPipelineName: - * type: string - * builder: - * type: object - * 400: - * description: Invalid build ID - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Invalid build ID - * 404: - * description: Build not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Build not found - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - */ -// eslint-disable-next-line import/no-anonymous-default-export -export default async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== 'GET') { - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - const { buildId, name } = req.query; - const parsedBuildId = parseInt(buildId as string, 10); - - if (!buildId || typeof buildId !== 'string' || isNaN(parsedBuildId)) { - return res.status(400).json({ error: 'Invalid build ID' }); - } - - try { - const buildService = new BuildService(); - - // First check if build exists - const build = await buildService.db.models.Build.query().findById(parsedBuildId).select('id'); - - if (!build) { - getLogger().debug(`Build not found: buildId=${parsedBuildId}`); - return res.status(404).json({ error: 'Build not found' }); - } - - // Get deployables for this build - let query = buildService.db.models.Deployable.query().where('buildId', parsedBuildId); - - // Apply name filter if provided - if (name && typeof name === 'string') { - query = query.where('name', name); - } - - const deployables = await query.select( - 'id', - 'buildUUID', - 'buildId', - 'name', - 'branchName', - 'type', - 'active', - 'grpc', - 'capacityType', - 'public', - 'commentBranchName', - 'deploymentDependsOn', - 'dockerBuildPipelineName', - 'builder' - ); - - return res.status(200).json(deployables); - } catch (error) { - getLogger({ error }).error(`API: deployables fetch failed buildId=${parsedBuildId}`); - return res.status(500).json({ error: 'An unexpected error occurred' }); - } -}; diff --git a/src/pages/api/v1/deploys.ts b/src/pages/api/v1/deploys.ts deleted file mode 100644 index a3e6e34b..00000000 --- a/src/pages/api/v1/deploys.ts +++ /dev/null @@ -1,216 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { NextApiRequest, NextApiResponse } from 'next/types'; -import { getLogger } from 'server/lib/logger'; -import BuildService from 'server/services/build'; - -/** - * @openapi - * /api/v1/deploys: - * get: - * summary: Get deploys by build ID - * description: | - * Retrieves all deploys associated with a specific build ID. - * tags: - * - Deploys - * parameters: - * - in: query - * name: buildId - * required: true - * schema: - * type: integer - * description: The ID of the build - * - in: query - * name: deployableId - * required: false - * schema: - * type: integer - * description: Filter deploys by deployable ID - * responses: - * 200: - * description: Successfully retrieved deploys - * content: - * application/json: - * schema: - * type: array - * items: - * type: object - * properties: - * id: - * type: integer - * status: - * type: string - * statusMessage: - * type: string - * uuid: - * type: string - * dockerImage: - * type: string - * internalHostname: - * type: string - * publicUrl: - * type: string - * env: - * type: object - * serviceId: - * type: integer - * buildId: - * type: integer - * createdAt: - * type: string - * format: date-time - * updatedAt: - * type: string - * format: date-time - * deletedAt: - * type: string - * format: date-time - * nullable: true - * branchName: - * type: string - * tag: - * type: string - * githubRepositoryId: - * type: integer - * sha: - * type: string - * initDockerImage: - * type: string - * initEnv: - * type: object - * active: - * type: boolean - * cname: - * type: string - * runUUID: - * type: string - * replicaCount: - * type: integer - * deployableId: - * type: integer - * isRunningLatest: - * type: boolean - * runningImage: - * type: string - * deployPipelineId: - * type: string - * buildJobName: - * type: string - * 400: - * description: Invalid build ID - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Invalid build ID - * 404: - * description: Build not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Build not found - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - */ -// eslint-disable-next-line import/no-anonymous-default-export -export default async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== 'GET') { - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - const { buildId, deployableId } = req.query; - const parsedBuildId = parseInt(buildId as string, 10); - const parsedDeployableId = deployableId ? parseInt(deployableId as string, 10) : undefined; - - if (!buildId || typeof buildId !== 'string' || isNaN(parsedBuildId)) { - return res.status(400).json({ error: 'Invalid build ID' }); - } - - if (deployableId && (typeof deployableId !== 'string' || isNaN(parsedDeployableId!))) { - return res.status(400).json({ error: 'Invalid deployable ID' }); - } - - try { - const buildService = new BuildService(); - - // First check if build exists - const build = await buildService.db.models.Build.query().findById(parsedBuildId).select('id'); - - if (!build) { - getLogger().debug(`Build not found: buildId=${parsedBuildId}`); - return res.status(404).json({ error: 'Build not found' }); - } - - // Get deploys for this build - let query = buildService.db.models.Deploy.query().where('buildId', parsedBuildId); - - // Apply deployableId filter if provided - if (parsedDeployableId) { - query = query.where('deployableId', parsedDeployableId); - } - - const deploys = await query.select( - 'id', - 'status', - 'statusMessage', - 'uuid', - 'dockerImage', - 'internalHostname', - 'publicUrl', - 'env', - 'serviceId', - 'buildId', - 'createdAt', - 'updatedAt', - 'deletedAt', - 'branchName', - 'tag', - 'githubRepositoryId', - 'sha', - 'initDockerImage', - 'initEnv', - 'active', - 'cname', - 'runUUID', - 'replicaCount', - 'deployableId', - 'isRunningLatest', - 'runningImage', - 'deployPipelineId', - 'buildJobName' - ); - - return res.status(200).json(deploys); - } catch (error) { - getLogger({ error }).error(`API: deploys fetch failed buildId=${parsedBuildId}`); - return res.status(500).json({ error: 'An unexpected error occurred' }); - } -}; diff --git a/src/pages/api/v1/pull-requests/[id]/builds.ts b/src/pages/api/v1/pull-requests/[id]/builds.ts deleted file mode 100644 index b26ba41d..00000000 --- a/src/pages/api/v1/pull-requests/[id]/builds.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { NextApiRequest, NextApiResponse } from 'next/types'; -import { getLogger } from 'server/lib/logger'; -import BuildService from 'server/services/build'; -import PullRequestService from 'server/services/pullRequest'; - -/** - * @openapi - * /api/v1/pull-requests/{id}/builds: - * get: - * summary: Get builds by pull request ID - * description: | - * Retrieves all builds associated with a specific pull request ID. - * tags: - * - Builds - * - Pull Requests - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * description: The ID of the pull request - * responses: - * 200: - * description: Successfully retrieved builds - * content: - * application/json: - * schema: - * type: array - * items: - * type: object - * properties: - * id: - * type: integer - * uuid: - * type: string - * status: - * type: string - * statusMessage: - * type: string - * sha: - * type: string - * createdAt: - * type: string - * format: date-time - * updatedAt: - * type: string - * format: date-time - * deletedAt: - * type: string - * format: date-time - * nullable: true - * pullRequestId: - * type: integer - * manifest: - * type: object - * webhooksYaml: - * type: object - * isStatic: - * type: boolean - * 400: - * description: Invalid pull request ID - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Invalid pull request ID - * 404: - * description: Pull request not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Pull request not found - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - */ -// eslint-disable-next-line import/no-anonymous-default-export -export default async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== 'GET') { - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - const { id } = req.query; - const parsedId = parseInt(id as string, 10); - - if (!id || typeof id !== 'string' || isNaN(parsedId)) { - return res.status(400).json({ error: 'Invalid pull request ID' }); - } - - try { - const pullRequestService = new PullRequestService(); - const buildService = new BuildService(); - - // First check if pull request exists - const pullRequest = await pullRequestService.db.models.PullRequest.query().findById(parsedId).select('id'); - - if (!pullRequest) { - getLogger().debug(`Pull request not found: id=${parsedId}`); - return res.status(404).json({ error: 'Pull request not found' }); - } - - // Get builds for this pull request - const builds = await buildService.db.models.Build.query() - .where('pullRequestId', parsedId) - .select( - 'id', - 'uuid', - 'status', - 'statusMessage', - 'sha', - 'createdAt', - 'updatedAt', - 'deletedAt', - 'pullRequestId', - 'manifest', - 'webhooksYaml', - 'isStatic' - ); - - return res.status(200).json(builds); - } catch (error) { - getLogger().error({ error }, `API: builds fetch failed pullRequestId=${parsedId}`); - return res.status(500).json({ error: 'An unexpected error occurred' }); - } -}; diff --git a/src/pages/api/v1/pull-requests/[id]/index.ts b/src/pages/api/v1/pull-requests/[id]/index.ts deleted file mode 100644 index caaf7a14..00000000 --- a/src/pages/api/v1/pull-requests/[id]/index.ts +++ /dev/null @@ -1,150 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { NextApiRequest, NextApiResponse } from 'next/types'; -import { getLogger } from 'server/lib/logger'; -import PullRequestService from 'server/services/pullRequest'; - -/** - * @openapi - * /api/v1/pull-requests/{id}: - * get: - * summary: Get pull request by ID - * description: | - * Retrieves detailed information about a specific pull request by its ID. - * tags: - * - Pull Requests - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * description: The ID of the pull request - * responses: - * 200: - * description: Successfully retrieved pull request details - * content: - * application/json: - * schema: - * type: object - * properties: - * deployOnUpdate: - * type: boolean - * branchName: - * type: string - * fullName: - * type: string - * latestCommit: - * type: string - * pullRequestNumber: - * type: integer - * repositoryId: - * type: integer - * status: - * type: string - * title: - * type: string - * updatedAt: - * type: string - * format: date-time - * createdAt: - * type: string - * format: date-time - * commentId: - * type: string - * githubLogin: - * type: string - * id: - * type: integer - * labels: - * type: object - * 400: - * description: Invalid pull request ID - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Invalid pull request ID - * 404: - * description: Pull request not found - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: Pull request not found - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - */ -// eslint-disable-next-line import/no-anonymous-default-export -export default async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== 'GET') { - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - const { id } = req.query; - const parsedId = parseInt(id as string, 10); - - if (!id || typeof id !== 'string' || isNaN(parsedId)) { - return res.status(400).json({ error: 'Invalid pull request ID' }); - } - - try { - const pullRequestService = new PullRequestService(); - - const pullRequest = await pullRequestService.db.models.PullRequest.query() - .findById(parsedId) - .select( - 'deployOnUpdate', - 'branchName', - 'fullName', - 'latestCommit', - 'pullRequestNumber', - 'repositoryId', - 'status', - 'title', - 'updatedAt', - 'createdAt', - 'commentId', - 'githubLogin', - 'id', - 'labels' - ); - - if (!pullRequest) { - getLogger().debug(`Pull request not found: id=${parsedId}`); - return res.status(404).json({ error: 'Pull request not found' }); - } - - return res.status(200).json(pullRequest); - } catch (error) { - getLogger().error({ error }, `API: pull request fetch failed id=${parsedId}`); - return res.status(500).json({ error: 'An unexpected error occurred' }); - } -}; diff --git a/src/pages/api/v1/pull-requests/index.ts b/src/pages/api/v1/pull-requests/index.ts deleted file mode 100644 index afb6f774..00000000 --- a/src/pages/api/v1/pull-requests/index.ts +++ /dev/null @@ -1,277 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { NextApiRequest, NextApiResponse } from 'next/types'; -import { getLogger } from 'server/lib/logger'; -import PullRequestService from 'server/services/pullRequest'; - -/** - * @openapi - * /api/v1/pull-requests: - * get: - * summary: Get pull requests with filtering and pagination - * description: | - * Retrieves pull requests with optional filtering by user, repository, and status exclusion. - * Results are ordered by updatedAt descending and paginated with a default limit of 25. - * tags: - * - Pull Requests - * parameters: - * - in: query - * name: user - * required: false - * schema: - * type: string - * description: Filter by GitHub username (githubLogin) - * example: "johndoe" - * - in: query - * name: repo - * required: false - * schema: - * type: string - * description: Filter by repository name (fullName) - * example: "owner/repository" - * - in: query - * name: exclude - * required: false - * schema: - * oneOf: - * - type: array - * items: - * type: string - * - type: string - * description: | - * Status values to exclude from results. Can be passed as: - * - JSON array string: exclude=["closed","merged"] - * - Multiple query params: exclude=closed&exclude=merged - * - Single value: exclude=closed - * - in: query - * name: page - * required: false - * schema: - * type: integer - * minimum: 1 - * default: 1 - * description: Page number for pagination - * - in: query - * name: limit - * required: false - * schema: - * type: integer - * minimum: 1 - * default: 25 - * description: Number of items per page - * responses: - * 200: - * description: Successfully retrieved pull requests list - * content: - * application/json: - * schema: - * type: object - * required: - * - pull_requests - * - metadata - * properties: - * pull_requests: - * type: array - * items: - * type: object - * properties: - * deployOnUpdate: - * type: boolean - * branchName: - * type: string - * fullName: - * type: string - * latestCommit: - * type: string - * pullRequestNumber: - * type: integer - * repositoryId: - * type: integer - * status: - * type: string - * title: - * type: string - * updatedAt: - * type: string - * format: date-time - * createdAt: - * type: string - * format: date-time - * commentId: - * type: string - * githubLogin: - * type: string - * id: - * type: integer - * metadata: - * type: object - * required: - * - currentPage - * - totalPages - * - total - * - limit - * properties: - * currentPage: - * type: integer - * description: Current page number - * example: 1 - * totalPages: - * type: integer - * description: Total number of pages - * example: 5 - * total: - * type: integer - * description: Total number of pull requests after filtering - * example: 100 - * limit: - * type: integer - * description: Number of items per page - * example: 25 - * 400: - * description: Invalid request parameters - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: "Invalid parameters" - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: "POST is not allowed" - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: "An unexpected error occurred" - */ -// eslint-disable-next-line import/no-anonymous-default-export -export default async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== 'GET') { - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - try { - const pullRequestService = new PullRequestService(); - - // Extract query parameters - const { user, repo, exclude, page, limit } = req.query; - - // Build base query - let query = pullRequestService.db.models.PullRequest.query() - .select( - 'deployOnUpdate', - 'branchName', - 'fullName', - 'latestCommit', - 'pullRequestNumber', - 'repositoryId', - 'status', - 'title', - 'updatedAt', - 'createdAt', - 'commentId', - 'githubLogin', - 'id', - 'labels' - ) - .orderBy('updatedAt', 'desc'); - - // Apply user filter if provided - if (user && typeof user === 'string') { - query = query.where('githubLogin', user); - } - - // Apply repo filter if provided - if (repo && typeof repo === 'string') { - query = query.where('fullName', repo); - } - - // Parse exclude parameter - if (exclude) { - let excludeStatuses: string[] = []; - if (Array.isArray(exclude)) { - excludeStatuses = exclude.filter((status) => typeof status === 'string'); - } else if (typeof exclude === 'string') { - try { - const parsed = JSON.parse(exclude); - if (Array.isArray(parsed)) { - excludeStatuses = parsed.filter((status) => typeof status === 'string'); - } else { - excludeStatuses = [exclude]; - } - } catch { - // If not valid JSON, treat as single value - excludeStatuses = [exclude]; - } - } - - if (excludeStatuses.length > 0) { - query = query.whereNotIn('status', excludeStatuses); - } - } - - // Apply pagination (always paginate with default limit 25) - const pageNumber = Math.max(1, parseInt(page as string, 10) || 1); - const limitNumber = Math.max(1, parseInt(limit as string, 10) || 25); - const offset = (pageNumber - 1) * limitNumber; - - // Get total count using a cloned query - const totalCount = await query.resultSize(); - - // Apply pagination to the original query - const pullRequests = await query.offset(offset).limit(limitNumber); - - interface Response { - pull_requests: any[]; - metadata: { - currentPage: number; - totalPages: number; - total: number; - limit: number; - }; - } - - const response: Response = { - pull_requests: pullRequests, - metadata: { - currentPage: pageNumber, - totalPages: Math.ceil(totalCount / limitNumber), - total: totalCount, - limit: limitNumber, - }, - }; - - return res.status(200).json(response); - } catch (error) { - getLogger().error({ error }, 'API: pull requests fetch failed'); - return res.status(500).json({ error: 'An unexpected error occurred' }); - } -}; diff --git a/src/pages/api/v1/repos/index.ts b/src/pages/api/v1/repos/index.ts deleted file mode 100644 index 79de907f..00000000 --- a/src/pages/api/v1/repos/index.ts +++ /dev/null @@ -1,186 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { NextApiRequest, NextApiResponse } from 'next/types'; -import { getLogger } from 'server/lib/logger'; -import PullRequestService from 'server/services/pullRequest'; - -/** - * @openapi - * /api/v1/repos: - * get: - * summary: Get all distinct repository names from pull requests - * description: | - * Retrieves a list of distinct repository names (fullName) from the pull requests table. - * Returns all repositories by default, with optional pagination support. - * tags: - * - Repositories - * parameters: - * - in: query - * name: page - * required: false - * schema: - * type: integer - * minimum: 1 - * description: Page number for pagination (if not provided with limit, all results are returned) - * - in: query - * name: limit - * required: false - * schema: - * type: integer - * minimum: 1 - * description: Number of items per page (if not provided with page, all results are returned) - * responses: - * 200: - * description: Successfully retrieved repositories list - * content: - * application/json: - * schema: - * type: object - * required: - * - repos - * - metadata - * properties: - * repos: - * type: array - * items: - * type: string - * description: Array of distinct repository names - * example: ["owner/repo1", "owner/repo2", "owner/repo3"] - * metadata: - * type: object - * required: - * - currentPage - * - totalPages - * - total - * - limit - * properties: - * currentPage: - * type: integer - * description: Current page number - * example: 1 - * totalPages: - * type: integer - * description: Total number of pages - * example: 1 - * total: - * type: integer - * description: Total number of distinct repositories - * example: 3 - * limit: - * type: integer - * description: Number of items per page - * example: 20 - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: "POST is not allowed" - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: "An unexpected error occurred" - */ -// eslint-disable-next-line import/no-anonymous-default-export -export default async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== 'GET') { - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - try { - const pullRequestService = new PullRequestService(); - - // Extract query parameters - const { page, limit } = req.query; - - // Build base query for distinct fullName values - const baseQuery = pullRequestService.db.models.PullRequest.query() - .distinct('fullName') - .select('fullName') - .whereNotNull('fullName'); - - // Handle pagination - interface Response { - repos: string[]; - metadata: { - currentPage: number; - totalPages: number; - total: number; - limit: number; - }; - } - - let response: Response; - - if (page || limit) { - // If pagination params provided, apply pagination - const pageNumber = Math.max(1, parseInt(page as string, 10) || 1); - const limitNumber = Math.max(1, parseInt(limit as string, 10) || 20); - const offset = (pageNumber - 1) * limitNumber; - - const countResult = (await pullRequestService.db.models.PullRequest.query() - .countDistinct('fullName as count') - .whereNotNull('fullName') - .first()) as { count?: string | number } | undefined; - - const totalCount = parseInt(String(countResult?.count || '0'), 10); - - const reposResult = await baseQuery.offset(offset).limit(limitNumber); - - const repos = reposResult.map((row: { fullName: string }) => row.fullName); - - response = { - repos, - metadata: { - currentPage: pageNumber, - totalPages: Math.ceil(totalCount / limitNumber), - total: totalCount, - limit: limitNumber, - }, - }; - } else { - const reposResult = await baseQuery; - - const repos = reposResult.map((row: { fullName: string }) => row.fullName); - - response = { - repos, - metadata: { - currentPage: 1, - totalPages: 1, - total: repos.length, - limit: repos.length, - }, - }; - } - - return res.status(200).json(response); - } catch (error) { - getLogger().error({ error }, 'API: repos fetch failed'); - return res.status(500).json({ error: 'An unexpected error occurred' }); - } -}; diff --git a/src/pages/api/v1/schema/validate.ts b/src/pages/api/v1/schema/validate.ts deleted file mode 100644 index 27b4ed33..00000000 --- a/src/pages/api/v1/schema/validate.ts +++ /dev/null @@ -1,165 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * @openapi - * /api/v1/schema/validate: - * post: - * summary: Validate YAML configuration - * description: Validates a YAML config provided as content or fetched from a repo/branch. - * tags: - * - Schema - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * source: - * type: string - * enum: [content, path] - * content: - * type: string - * description: Base64-encoded YAML content (required if source=content) - * repo: - * type: string - * description: Repository name (required if source=path) - * branch: - * type: string - * description: Branch name (required if source=path) - * responses: - * 200: - * description: Validation result - * content: - * application/json: - * schema: - * type: object - * properties: - * valid: - * type: boolean - * error: - * type: array - * items: - * type: string - * nullable: true - * 400: - * description: Bad request or validation error - * content: - * application/json: - * schema: - * type: object - * properties: - * valid: - * type: boolean - * error: - * type: array - * items: - * type: string - * nullable: true - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - */ - -type ValidationResponse = { - valid: boolean; - error: string[] | null; -}; - -type ErrorResponse = { - error: string; -}; - -type Response = ValidationResponse | ErrorResponse; - -import { NextApiRequest, NextApiResponse } from 'next/types'; -import { getYamlFileContentFromBranch, ConfigFileNotFound } from 'server/lib/github'; -import { getLogger } from 'server/lib/logger'; -import { YamlConfigParser, ParsingError } from 'server/lib/yamlConfigParser'; -import { YamlConfigValidator, ValidationError } from 'server/lib/yamlConfigValidator'; - -const schemaValidateHandler = async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== 'POST') { - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - const { source } = req.body; - const allowedSources = ['content', 'path']; - - if (!source || typeof source !== 'string' || !allowedSources.includes(source)) { - return res.status(400).json({ valid: false, error: ['Invalid source in request body'] }); - } - - try { - if (source === 'content') { - return await validateContent(req, res); - } else if (source === 'path') { - return await validatePath(req, res); - } - } catch (error) { - if (error instanceof ParsingError || error instanceof ValidationError) { - const errors = error.message.split('\n'); - return res.status(400).json({ valid: false, error: errors }); - } - if (error instanceof ConfigFileNotFound) { - return res.status(404).json({ valid: false, error: ['Config file not found'] }); - } - getLogger().error({ error }, 'Schema: YAML validation failed'); - return res.status(500).json({ error: 'Internal server error' }); - } -}; - -export default schemaValidateHandler; - -const validateContent = async (req: NextApiRequest, res: NextApiResponse) => { - const { content } = req.body; - if (typeof content !== 'string') { - return res.status(400).json({ valid: false, error: ['Invalid content in request body'] }); - } - const decodedContent = Buffer.from(content, 'base64').toString('utf-8'); - const parser = new YamlConfigParser(); - const config = parser.parseYamlConfigFromString(decodedContent); - const isValid = new YamlConfigValidator().validate(config?.version, config); - return res.status(200).json({ valid: isValid, error: null }); -}; - -const validatePath = async (req: NextApiRequest, res: NextApiResponse) => { - const { repo, branch } = req.body; - if (![repo, branch].every((val) => typeof val === 'string' && val.trim() !== '')) { - return res.status(400).json({ valid: false, error: ['Invalid repo or branch in request body'] }); - } - const content = (await getYamlFileContentFromBranch(repo, branch)) as string; - const parser = new YamlConfigParser(); - const config = parser.parseYamlConfigFromString(content); - const isValid = new YamlConfigValidator().validate(config?.version, config); - return res.status(200).json({ valid: isValid, error: null }); -}; diff --git a/src/pages/api/v1/setup/callback.ts b/src/pages/api/v1/setup/callback.ts deleted file mode 100644 index 0be71685..00000000 --- a/src/pages/api/v1/setup/callback.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { NextApiRequest, NextApiResponse } from 'next'; -import { updateSecret, getCurrentNamespaceFromFile } from 'server/lib/kubernetes'; -import { getLogger } from 'server/lib/logger'; -import GlobalConfigService from 'server/services/globalConfig'; -import { SECRET_BOOTSTRAP_NAME } from 'shared/config'; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - try { - const { code, state } = req.query; - if (!code) return res.status(400).send({ error: 'Missing authorization code' }); - if (!state) return res.status(400).send({ error: 'Missing state parameter' }); - - getLogger().info(`Setup: callback received state=${state}`); - - const globalConfigService = new GlobalConfigService(); - const { state: storedState } = await globalConfigService.getConfig('app_setup'); - if (storedState !== state) { - getLogger().warn(`Setup: invalid state received=${state} expected=${storedState}`); - return res.status(400).send({ error: 'Invalid state parameter' }); - } - - // check if app is already installed - const app_setup = await globalConfigService.getConfig('app_setup'); - if (app_setup.installed) { - getLogger().warn('Setup: app already installed'); - return res.status(400).send({ error: 'App already installed' }); - } - - const response = await fetch(`https://api.github.com/app-manifests/${code}/conversions`, { - method: 'POST', - headers: { - Accept: 'application/vnd.github.v3+json', - }, - }); - - if (!response.ok) { - const errorData = await response.json(); - getLogger().error({ errorData }, 'Setup: manifest conversion failed'); - return res.status(response.status).json({ error: 'Failed to convert manifest code' }); - } - - const creds = await response.json(); - const { id, client_id, client_secret, webhook_secret, pem, html_url, slug } = creds; - - if (!id || !client_id || !client_secret || !webhook_secret || !pem || !html_url) { - return res.status(400).send({ error: 'Invalid response from GitHub' }); - } - - const namespace = getCurrentNamespaceFromFile(); - - await updateSecret( - SECRET_BOOTSTRAP_NAME, - { - GITHUB_APP_ID: id, - GITHUB_CLIENT_ID: client_id, - GITHUB_CLIENT_SECRET: client_secret, - GITHUB_WEBHOOK_SECRET: webhook_secret, - GITHUB_PRIVATE_KEY: pem.replace(/\n/g, '\\n'), - }, - namespace - ); - - getLogger().info(`Setup: secrets updated appId=${id}`); - - await globalConfigService.setConfig('app_setup', { - ...app_setup, - created: true, - url: html_url, - name: slug, - }); - - const installationUrl = `${html_url}/installations/new`; - getLogger().info(`Setup: redirecting to install url=${installationUrl}`); - res.redirect(installationUrl); - } catch (error) { - getLogger().error({ error }, 'Setup: callback failed'); - res.status(500).send({ error: 'An error occurred during GitHub app setup' }); - } -} diff --git a/src/pages/api/v1/setup/configure.ts b/src/pages/api/v1/setup/configure.ts deleted file mode 100644 index c9b93a52..00000000 --- a/src/pages/api/v1/setup/configure.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { NextApiRequest, NextApiResponse } from 'next'; -import { getCurrentNamespaceFromFile } from 'server/lib/kubernetes'; -import { shellPromise } from 'server/lib/shell'; -import { getLogger } from 'server/lib/logger'; -import GlobalConfigService from 'server/services/globalConfig'; -import { APP_HOST } from 'shared/config'; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const namespace = getCurrentNamespaceFromFile(); - const releaseName = process.env.HELM_RELEASE_NAME; - - if (!namespace || !releaseName) { - return res.status(500).json({ error: 'Restarting deployment failed' }); - } - - const globalConfigService = new GlobalConfigService(); - const app_setup = await globalConfigService.getConfig('app_setup'); - - if (app_setup?.restarted) { - return res.status(400).json({ error: 'Restart already requested' }); - } - - try { - const appDomain = APP_HOST.split('.').slice(1).join('.'); - const lifecycleDefaults = await globalConfigService.getConfig('lifecycleDefaults'); - const domainDefaults = await globalConfigService.getConfig('domainDefaults'); - - await globalConfigService.setConfig('lifecycleDefaults', { - ...lifecycleDefaults, - ecrDomain: `distribution.${appDomain}`, - defaultPublicUrl: `dev-0.${appDomain}`, - }); - - await globalConfigService.setConfig('domainDefaults', { - ...domainDefaults, - http: appDomain, - grpc: appDomain, - publicScheme: 'https', - }); - - await globalConfigService.getAllConfigs(true); - await shellPromise( - `kubectl rollout restart deployment -l app.kubernetes.io/instance=${releaseName},app.kubernetes.io/name=lifecycle -n ${namespace}` - ); - const updated_app_setup = { - ...app_setup, - restarted: true, - }; - await globalConfigService.setConfig('app_setup', updated_app_setup); - res.status(200).json({ message: 'Deployment restarted' }); - } catch (error) { - getLogger().error({ error }, 'Setup: deployment restart failed'); - return res.status(500).json({ error: 'Restarting deployment failed' }); - } -} diff --git a/src/pages/api/v1/setup/index.ts b/src/pages/api/v1/setup/index.ts deleted file mode 100644 index c35f41fb..00000000 --- a/src/pages/api/v1/setup/index.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { randomBytes } from 'crypto'; -import type { NextApiRequest, NextApiResponse } from 'next'; -import { getLogger } from 'server/lib/logger'; -import GlobalConfigService from 'server/services/globalConfig'; -import { APP_HOST, GITHUB_APP_AUTH_CALLBACK } from 'shared/config'; - -function isValidURL(url: string) { - try { - new URL(url); - return true; - } catch (error) { - return false; - } -} - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method !== 'GET') { - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - const gc = new GlobalConfigService(); - const appSetup = await gc.getConfig('app_setup'); - if (appSetup?.installed) return res.redirect('/setup'); - - const appName = typeof req.query.app_name === 'string' ? req.query.app_name.trim() : ''; - const appUrl = APP_HOST; - const githubAppAuthCallback = GITHUB_APP_AUTH_CALLBACK; - const org = typeof req.query.org === 'string' ? req.query.org.trim() : ''; - - if (appName.length > 34 || !/^[a-zA-Z0-9-]+$/.test(appName)) { - return res.status(400).json({ error: 'App name is not valid.' }); - } - - if (org && (org.length > 39 || !/^[a-zA-Z0-9-]+$/.test(org))) { - return res.status(400).json({ error: 'Organization name is not valid.' }); - } - - if (!appUrl || !isValidURL(appUrl)) { - return res.status(400).json({ error: 'Application public URL is not valid.' }); - } - - const state = randomBytes(16).toString('hex'); - getLogger().info(`Setup: state generated state=${state}`); - - await gc.setConfig('app_setup', { - state, - created: false, - installed: false, - org, - appUrl, - }); - - const manifest = { - name: appName, - url: appUrl, - hook_attributes: { url: `${appUrl}/api/webhooks/github`, active: true }, - setup_url: `${appUrl}/api/v1/setup/installed`, - redirect_url: `${appUrl}/api/v1/setup/callback`, - callback_urls: [`${appUrl}/api/v1/setup/callback`, `${githubAppAuthCallback}`], - public: false, - default_permissions: { - contents: 'write', - deployments: 'write', - issues: 'write', - members: 'read', - metadata: 'read', - pull_requests: 'write', - statuses: 'read', - // The update_file tool commits arbitrary paths; GitHub rejects writes to .github/workflows/* - // with contents:write alone, so the debug agent needs the dedicated workflows permission. - workflows: 'write', - emails: 'read', - }, - default_events: [ - 'issues', - 'issue_comment', - 'label', - 'membership', - 'organization', - 'repository', - 'public', - 'pull_request', - 'push', - 'team', - ], - }; - - const actionUrl = org - ? `https://github.com/organizations/${org}/settings/apps/new?state=${state}` - : `https://github.com/settings/apps/new?state=${state}`; - - const html = ` -
- -
- - `; - - res.setHeader('Content-Type', 'text/html; charset=utf-8'); - res.send(html); -} diff --git a/src/pages/api/v1/setup/installed.ts b/src/pages/api/v1/setup/installed.ts deleted file mode 100644 index dccde88f..00000000 --- a/src/pages/api/v1/setup/installed.ts +++ /dev/null @@ -1,64 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { NextApiRequest, NextApiResponse } from 'next'; -import { updateSecret, getCurrentNamespaceFromFile } from 'server/lib/kubernetes'; -import { getLogger } from 'server/lib/logger'; -import GlobalConfigService from 'server/services/globalConfig'; -import { SECRET_BOOTSTRAP_NAME } from 'shared/config'; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - const { installation_id, setup_action } = req.query; - - if (!installation_id) { - return res.status(400).json({ error: 'Missing installation_id' }); - } - if (setup_action && setup_action !== 'install') { - getLogger().warn(`Setup: invalid setup_action received=${setup_action} expected=install`); - return res.status(500).json({ error: 'Invalid setup_action' }); - } - - const globalConfigService = new GlobalConfigService(); - const app_setup = await globalConfigService.getConfig('app_setup'); - if (!app_setup) { - getLogger().warn('Setup: app_setup not found'); - return res.status(404).json({ error: 'No app_setup found.' }); - } - - if (app_setup.installed) { - getLogger().warn('Setup: app already installed'); - return res.status(400).json({ error: 'App already installed.' }); - } - - const updated_app_setup = { - ...app_setup, - installed: true, - }; - await globalConfigService.setConfig('app_setup', updated_app_setup); - getLogger().info(`Setup: installation recorded installationId=${installation_id}`); - const namespace = getCurrentNamespaceFromFile(); - await updateSecret( - SECRET_BOOTSTRAP_NAME, - { - GITHUB_APP_INSTALLATION_ID: installation_id as string, - }, - namespace - ); - - const app_setup_url_encoded = encodeURIComponent(JSON.stringify(updated_app_setup)); - getLogger().info('Setup: redirecting to completion page'); - res.redirect(`/setup/complete?app_setup=${app_setup_url_encoded}`); -} diff --git a/src/pages/api/v1/setup/status.ts b/src/pages/api/v1/setup/status.ts deleted file mode 100644 index 8b43c82f..00000000 --- a/src/pages/api/v1/setup/status.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * @openapi - * /api/v1/setup/status: - * get: - * summary: Get the status of the app setup - * description: Get the status of the app setup - * tags: - * - Setup - * responses: - * 200: - * description: The status of the app setup - */ -import { NextApiRequest, NextApiResponse } from 'next'; -import GlobalConfigService from 'server/services/globalConfig'; - -export default async function handler(req: NextApiRequest, res: NextApiResponse) { - if (req.method !== 'GET') { - return res.status(405).json({ error: 'Method not allowed' }); - } - - const globalConfigService = new GlobalConfigService(); - const appSetup = await globalConfigService.getConfig('app_setup'); - res.status(200).json({ - installed: appSetup?.installed || false, - created: appSetup?.created || false, - restarted: appSetup?.restarted || false, - url: appSetup?.url || '', - }); -} diff --git a/src/pages/api/v1/users/index.ts b/src/pages/api/v1/users/index.ts deleted file mode 100644 index 1a3f793a..00000000 --- a/src/pages/api/v1/users/index.ts +++ /dev/null @@ -1,186 +0,0 @@ -/** - * Copyright 2025 GoodRx, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { NextApiRequest, NextApiResponse } from 'next/types'; -import { getLogger } from 'server/lib/logger'; -import PullRequestService from 'server/services/pullRequest'; - -/** - * @openapi - * /api/v1/users: - * get: - * summary: Get all distinct GitHub users from pull requests - * description: | - * Retrieves a list of distinct GitHub usernames from the pull requests table. - * Returns all users by default, with optional pagination support. - * tags: - * - Users - * parameters: - * - in: query - * name: page - * required: false - * schema: - * type: integer - * minimum: 1 - * description: Page number for pagination (if not provided with limit, all results are returned) - * - in: query - * name: limit - * required: false - * schema: - * type: integer - * minimum: 1 - * description: Number of items per page (if not provided with page, all results are returned) - * responses: - * 200: - * description: Successfully retrieved users list - * content: - * application/json: - * schema: - * type: object - * required: - * - users - * - metadata - * properties: - * users: - * type: array - * items: - * type: string - * description: Array of distinct GitHub usernames - * example: ["user1", "user2", "user3"] - * metadata: - * type: object - * required: - * - currentPage - * - totalPages - * - total - * - limit - * properties: - * currentPage: - * type: integer - * description: Current page number - * example: 1 - * totalPages: - * type: integer - * description: Total number of pages - * example: 1 - * total: - * type: integer - * description: Total number of distinct users - * example: 3 - * limit: - * type: integer - * description: Number of items per page - * example: 20 - * 405: - * description: Method not allowed - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: "POST is not allowed" - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * type: object - * properties: - * error: - * type: string - * example: "An unexpected error occurred" - */ -// eslint-disable-next-line import/no-anonymous-default-export -export default async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== 'GET') { - return res.status(405).json({ error: `${req.method} is not allowed` }); - } - - try { - const pullRequestService = new PullRequestService(); - - // Extract query parameters - const { page, limit } = req.query; - - // Build base query for distinct githubLogin values - const baseQuery = pullRequestService.db.models.PullRequest.query() - .distinct('githubLogin') - .select('githubLogin') - .whereNotNull('githubLogin'); - - // Handle pagination - interface Response { - users: string[]; - metadata: { - currentPage: number; - totalPages: number; - total: number; - limit: number; - }; - } - - let response: Response; - - if (page || limit) { - // If pagination params provided, apply pagination - const pageNumber = Math.max(1, parseInt(page as string, 10) || 1); - const limitNumber = Math.max(1, parseInt(limit as string, 10) || 20); - const offset = (pageNumber - 1) * limitNumber; - - const countResult = (await pullRequestService.db.models.PullRequest.query() - .countDistinct('githubLogin as count') - .whereNotNull('githubLogin') - .first()) as { count?: string | number } | undefined; - - const totalCount = parseInt(String(countResult?.count || '0'), 10); - - const usersResult = await baseQuery.offset(offset).limit(limitNumber); - - const users = usersResult.map((row: { githubLogin: string }) => row.githubLogin); - - response = { - users, - metadata: { - currentPage: pageNumber, - totalPages: Math.ceil(totalCount / limitNumber), - total: totalCount, - limit: limitNumber, - }, - }; - } else { - const usersResult = await baseQuery; - - const users = usersResult.map((row: { githubLogin: string }) => row.githubLogin); - - response = { - users, - metadata: { - currentPage: 1, - totalPages: 1, - total: users.length, - limit: users.length, - }, - }; - } - - return res.status(200).json(response); - } catch (error) { - getLogger().error({ error }, 'API: users fetch failed'); - return res.status(500).json({ error: 'An unexpected error occurred' }); - } -}; diff --git a/src/pages/schema/validate.tsx b/src/pages/schema/validate.tsx index 2fcb70f1..3bec0e5b 100644 --- a/src/pages/schema/validate.tsx +++ b/src/pages/schema/validate.tsx @@ -14,125 +14,34 @@ * limitations under the License. */ -import React, { useState } from 'react'; +import Head from 'next/head'; +import React from 'react'; +import type { GetServerSideProps } from 'next'; +import { LIFECYCLE_UI_URL } from 'shared/config'; -const SchemaValidatorPage = () => { - const [yaml, setYaml] = useState(''); - const [result, setResult] = useState<{ valid: boolean; error: string[] | null } | null>(null); - const [loading, setLoading] = useState(false); - const [touched, setTouched] = useState(false); - - const handleChange = async (e: React.ChangeEvent) => { - const value = e.target.value; - setYaml(value); - setTouched(true); - if (!value) { - setResult(null); - return; - } - setLoading(true); - try { - const encodeBase64 = (str: string) => { - return Buffer.from(str, 'utf-8').toString('base64'); - }; - const encodedContent = encodeBase64(value); - const res = await fetch('/api/v1/schema/validate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content: encodedContent, source: 'content' }), - }); - const data = await res.json(); - setResult({ valid: data.valid, error: data.error }); - } catch (err) { - console.error(err); - setResult({ valid: false, error: ['Could not reach validation API.'] }); - } finally { - setLoading(false); - } - }; - - let borderColor = '#D1D5DB'; - if (touched && result) { - borderColor = result.valid ? '#22C55E' : '#EF4444'; // green-500 or red-500 - } +export const getServerSideProps: GetServerSideProps = async () => ({ + props: { uiUrl: LIFECYCLE_UI_URL || null }, +}); +export default function SchemaValidatorPage({ uiUrl }: { uiUrl: string | null }) { return ( -
-
-

Schema Validation

-