diff --git a/README.md b/README.md index 1f03bc0..b86f202 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ polylane integration connect --type # see `polylane integration co polylane integration connect --type posthog # choose a project; repeat to add another polylane cloud connect --provider # see `polylane cloud connect --help` polylane cloud connect --provider gcp --project-id --project-number # prints a gcloud setup script, then verifies +polylane cloud connect --provider azure # opens an Azure portal deployment, then waits # 4. Work polylane issue list --active # what the system is flagging diff --git a/skill/SKILL.md b/skill/SKILL.md index 0db8ae2..765fe6f 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -156,6 +156,8 @@ polylane service list # cloud infra discovered from `polylane cloud connect --provider gcp --project-id --project-number ` prepares a Google Cloud connection and prints its setup script on stdout. A project administrator runs the script in Google Cloud Shell or a terminal signed in with `gcloud` (it applies an Infrastructure Manager deployment and takes a few minutes). An interactive terminal then asks whether the script finished and verifies; otherwise the command exits `7` and prints the command that finishes the connection: `polylane --workspace cloud connect --provider gcp --request gcpconn_... --project-number `. Non-interactive and JSON runs make one verification attempt and exit `7` with the same command while Google access is still propagating; retry it a minute later. Requests expire after seven days. Pass `--no-investigate-alerts` to skip investigating Cloud Monitoring alerts. +`polylane cloud connect --provider azure` starts an Azure connection request and prints the Azure portal deployment URL on stdout. An interactive terminal also opens the URL in the browser. In the portal, an Owner of the subscription selects the subscription, then selects Review + create and Create. The deployment gives Polylane read access through Azure Lighthouse. The command then checks the request every 5 seconds for up to 15 minutes and prints the connected account. Ctrl+C during the wait cancels the request. Pass `--no-alert-rules` to stop Polylane from creating Azure Monitor alert rules. Pass `--no-wait` to print the URL and the request ID and exit `7`. JSON runs do not wait either: they return `requestId`, `deployUrl` and `next` and exit `7`. To check a request later, run `polylane --workspace cloud connect --provider azure --request azureconn_...`. A cancelled or expired request exits `1` with the command that starts a new one. + Plans cap how many cloud accounts a workspace can connect; the numbers come from the API, never from the CLI. `cloud connect` checks first: with room it connects; at the limit it names the plan and the count (`Your Free plan includes 2 cloud accounts; 2 connected.`), offers the cheapest plan that raises it, and on yes opens Stripe Checkout and waits, then carries on with the connect once the plan lands. A no, a Stripe cancel, or no answer in time exits `4` with `Upgrade any time: polylane subscription upgrade` and leaves the connected accounts as they are. Non-interactive runs at the limit exit `4` with the same line as an error. The API answers a connect over the limit with `402` (exit `4`) either way. ```bash diff --git a/src/commands/cloud/azure-connect.ts b/src/commands/cloud/azure-connect.ts new file mode 100644 index 0000000..956ae7c --- /dev/null +++ b/src/commands/cloud/azure-connect.ts @@ -0,0 +1,273 @@ +import type { Config } from '../../config/schema'; +import { PolylaneAPI } from '../../generated/client'; +import { formatOutput } from '../../output/formatter'; +import { Spinner } from '../../output/progress'; +import { CLIError } from '../../errors/base'; +import { ApiError } from '../../errors/api'; +import { ExitCode } from '../../errors/codes'; +import { openBrowser } from '../../utils/browser'; +import { isInteractive } from '../../utils/env'; +import { BACK, note, promptConfirmOrBack } from '../../utils/prompt'; +import { getArgBoolean, getArgString, runSteps, SKIPPED, scopedSigint } from '../helpers'; + +export type AzureConnectionRequest = Awaited>; +export type AzureOutcome = 'connected' | 'timeout' | 'pending'; + +const AZURE_REQUEST_ID = /^azureconn_[0-9a-z]{24}$/; + +export const AZURE_POLL_INTERVAL_MS = 5_000; +export const AZURE_POLL_TIMEOUT_MS = 15 * 60_000; + +export function parseAzureRequestId(value: string): string { + const id = value.trim(); + if (!AZURE_REQUEST_ID.test(id)) { + throw new CLIError( + `Invalid Azure connection request ID: "${value}"`, + ExitCode.USAGE, + 'Pass the azureconn_... ID printed when the connection started.' + ); + } + return id; +} + +// The request belongs to one workspace, so the command names it: a request +// started with --workspace must not resume against the configured default. +export function azureResumeCommand(workspaceId: string, requestId: string): string { + return `polylane --workspace ${workspaceId} cloud connect --provider azure --request ${requestId}`; +} + +function startAgainCommand(workspaceId: string): string { + return `polylane --workspace ${workspaceId} cloud connect --provider azure`; +} + +export function isAzureRequestConnected(request: AzureConnectionRequest): boolean { + return request.status === 'active' && request.completedAt !== null; +} + +function endedError(workspaceId: string, request: AzureConnectionRequest): CLIError { + return new CLIError( + `Azure connection request ${request.requestId} is ${request.status}`, + ExitCode.GENERAL, + `Start a new connection:\n${startAgainCommand(workspaceId)}` + ); +} + +// A dropped connection, a slow read or a server error does not end the request, so the +// wait reads again on the next interval instead of giving up. +export function isTransientAzurePollError(err: unknown): boolean { + if (err instanceof ApiError) return err.status >= 500 || err.status === 429; + if (err instanceof CLIError) return err.exitCode === ExitCode.NETWORK || err.exitCode === ExitCode.TIMEOUT; + return err instanceof TypeError; +} + +export type AzurePollResult = { kind: 'connected'; request: AzureConnectionRequest } | { kind: 'timeout' }; + +// Each read also asks the API to finish the connection once Azure has +// delegated the subscription, so the poll drives activation forward. +export async function pollAzureRequest( + api: Pick, + workspaceId: string, + requestId: string, + deps: { + intervalMs?: number; + timeoutMs?: number; + sleep?: (ms: number) => Promise; + now?: () => number; + } = {} +): Promise { + const intervalMs = deps.intervalMs ?? AZURE_POLL_INTERVAL_MS; + const timeoutMs = deps.timeoutMs ?? AZURE_POLL_TIMEOUT_MS; + const sleep = deps.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms))); + const now = deps.now ?? Date.now; + const deadline = now() + timeoutMs; + for (;;) { + let request: AzureConnectionRequest | null = null; + try { + request = await api.cloudAccountsAzureRequestsGet(workspaceId, requestId); + } catch (err) { + if (!isTransientAzurePollError(err)) throw err; + } + if (request && isAzureRequestConnected(request)) return { kind: 'connected', request }; + if (request && (request.status === 'cancelled' || request.status === 'expired')) throw endedError(workspaceId, request); + if (now() >= deadline) return { kind: 'timeout' }; + await sleep(intervalMs); + } +} + +async function printConnected( + config: Config, + api: PolylaneAPI, + workspaceId: string, + request: AzureConnectionRequest +): Promise { + const cloudAccountId = request.cloudAccountId ?? ''; + if (config.output === 'json') { + formatOutput(config, cloudAccountId ? await api.cloudAccountsGet(workspaceId, cloudAccountId) : request); + return 'connected'; + } + const subscription = request.subscriptionId ? `Azure subscription ${request.subscriptionId}` : 'Azure subscription'; + process.stderr.write(`✓ Connected: ${subscription}${cloudAccountId ? ` (${cloudAccountId})` : ''}\n`); + if (!config.quiet && cloudAccountId) { + process.stderr.write(`The first inventory sync can take a few minutes. Follow it with \`polylane cloud show ${cloudAccountId}\`.\n`); + } + return 'connected'; +} + +export async function cancelAzureRequest( + api: Pick, + workspaceId: string, + requestId: string +): Promise { + process.stderr.write('Cancelling the Azure connection request…\n'); + try { + const request = await api.cloudAccountsAzureRequestsCancel(workspaceId, requestId); + // The API cancels only a pending request: one that Azure finished between + // two reads comes back active, so the message follows the returned status. + if (request.status === 'active') { + process.stderr.write( + `The Azure deployment finished before the cancel, so the request was not cancelled. To see the connection, run:\n${azureResumeCommand(workspaceId, requestId)}\n` + ); + return false; + } + process.stderr.write(`Cancelled. To connect Azure later, run:\n${startAgainCommand(workspaceId)}\n`); + return true; + } catch { + process.stderr.write( + `The request could not be cancelled. It expires on its own. To check it, run:\n${azureResumeCommand(workspaceId, requestId)}\n` + ); + return false; + } +} + +async function cancelOnInterrupt(api: PolylaneAPI, workspaceId: string, requestId: string): Promise { + await cancelAzureRequest(api, workspaceId, requestId); + process.exit(130); +} + +// An interactive wait shows a spinner, and Ctrl+C cancels the request so a +// deployment created later cannot connect a subscription nobody waits for. +// A non-interactive wait polls quietly. +async function waitForConnection( + config: Config, + api: PolylaneAPI, + workspaceId: string, + requestId: string +): Promise { + const interactive = isInteractive(config.nonInteractive); + const spinner = new Spinner('Waiting for the Azure deployment to finish… (Ctrl+C to cancel)'); + const restoreSigint = scopedSigint(() => { + spinner.stop(); + void cancelOnInterrupt(api, workspaceId, requestId); + }); + if (interactive && !config.quiet) spinner.start(); + try { + const result = await pollAzureRequest(api, workspaceId, requestId); + spinner.stop(); + if (result.kind === 'connected') return await printConnected(config, api, workspaceId, result.request); + process.stderr.write( + 'The Azure deployment has not connected yet. If it is still running, check again with:\n' + + `${azureResumeCommand(workspaceId, requestId)}\n` + ); + return 'timeout'; + } catch (err) { + spinner.fail(); + throw err; + } finally { + restoreSigint(); + } +} + +async function resume( + config: Config, + api: PolylaneAPI, + args: Record, + workspaceId: string, + requestId: string +): Promise { + const request = await api.cloudAccountsAzureRequestsGet(workspaceId, requestId); + if (isAzureRequestConnected(request)) return printConnected(config, api, workspaceId, request); + if (request.status === 'cancelled' || request.status === 'expired') throw endedError(workspaceId, request); + if (getArgBoolean(args, 'noWait') === true || config.output === 'json' || config.dryRun) { + if (config.output === 'json') { + formatOutput(config, { requestId, status: request.status, next: azureResumeCommand(workspaceId, requestId) }); + } else { + process.stderr.write(`The Azure connection is not complete yet. Check again with:\n${azureResumeCommand(workspaceId, requestId)}\n`); + } + return 'pending'; + } + return waitForConnection(config, api, workspaceId, requestId); +} + +export async function connectAzure( + config: Config, + api: PolylaneAPI, + args: Record, + workspaceId: string, + noBrowser: boolean +): Promise { + const requestFlag = getArgString(args, 'request'); + if (requestFlag !== undefined) { + return resume(config, api, args, workspaceId, parseAzureRequestId(requestFlag)); + } + + let createMonitoringAlarms = getArgBoolean(args, 'noAlertRules') !== true; + const ok = await runSteps([ + async () => { + if (getArgBoolean(args, 'noAlertRules') === true || !isInteractive(config.nonInteractive)) return SKIPPED; + const answer = await promptConfirmOrBack( + { nonInteractive: config.nonInteractive }, + 'Allow Polylane to create Azure Monitor alert rules?', + true + ); + if (answer === BACK) return BACK; + createMonitoringAlarms = answer; + return; + }, + ]); + if (!ok) return BACK; + + const result = await api.cloudAccountsConnect({ workspaceId, provider: 'azure', createMonitoringAlarms }); + if (config.dryRun) return 'pending'; + if (result.provider !== 'azure') { + throw new CLIError('Unexpected response from the Azure connect endpoint', ExitCode.GENERAL); + } + const { request, deployUrl } = result; + const resumeCommand = azureResumeCommand(workspaceId, request.requestId); + const noWait = getArgBoolean(args, 'noWait') === true; + + if (config.output === 'json') { + formatOutput(config, { + requestId: request.requestId, + status: request.status, + createMonitoringAlarms: request.createMonitoringAlarms, + expiresAt: request.expiresAt, + deployUrl, + next: resumeCommand, + }); + return 'pending'; + } + + const shouldOpen = !noBrowser && isInteractive(config.nonInteractive); + if (!config.quiet) { + note( + [ + 'A deployment in the Azure portal gives Polylane read access to one subscription through Azure Lighthouse.', + shouldOpen ? 'The Azure portal opens in your browser. Then:' : 'Open the URL below in a browser. Then:', + '1. Sign in as an Owner of the subscription.', + '2. Select the subscription to connect.', + '3. Select Review + create, then Create.', + `The request stays open until ${request.expiresAt}.`, + ].join('\n'), + 'Connect Azure' + ); + if (shouldOpen) process.stderr.write("If the browser does not open, use this URL:\n"); + } + process.stdout.write(deployUrl + '\n'); + if (shouldOpen) openBrowser(deployUrl); + + if (noWait) { + process.stderr.write(`\nRequest ${request.requestId}. After the deployment finishes, check the connection with:\n${resumeCommand}\n`); + return 'pending'; + } + return waitForConnection(config, api, workspaceId, request.requestId); +} diff --git a/src/commands/cloud/connect.ts b/src/commands/cloud/connect.ts index 846111e..e957722 100644 --- a/src/commands/cloud/connect.ts +++ b/src/commands/cloud/connect.ts @@ -20,6 +20,7 @@ import { } from '../helpers'; import { buildCloudflareTokenUrl } from './cloudflare-token-url'; import { connectGcp } from './gcp-connect'; +import { connectAzure } from './azure-connect'; import { ensureCloudAccountCapacity } from '../../billing/cloud-capacity'; import type { CloudAccount } from '../../generated/types'; import { isApiError } from '../../errors/api'; @@ -42,6 +43,7 @@ type ConnectBody = Parameters[0]; type Provider = | 'aws' | 'gcp' + | 'azure' | 'cloudflare' | 'vercel' | 'fly' @@ -60,6 +62,7 @@ type Provider = export const PROVIDER_OPTIONS: Array<{ value: Provider; label: string; hint: string }> = [ { value: 'aws', label: 'AWS', hint: 'deploys a read-only CloudFormation stack (browser)' }, { value: 'gcp', label: 'Google Cloud', hint: 'runs an Infrastructure Manager setup script (gcloud)' }, + { value: 'azure', label: 'Azure', hint: 'deploys an Azure Lighthouse template in the Azure portal (browser)' }, { value: 'cloudflare', label: 'Cloudflare', hint: 'read-only API token' }, { value: 'vercel', label: 'Vercel', hint: 'install the Vercel integration (browser)' }, { value: 'fly', label: 'Fly.io', hint: 'API token' }, @@ -685,6 +688,9 @@ async function connectProvider( if (provider === 'gcp') { return connectGcp(config, api, args, workspaceId); } + if (provider === 'azure') { + return connectAzure(config, api, args, workspaceId, noBrowser); + } if (provider === 'neon') { const orgIdFlag = getArgString(args, 'orgId'); if (orgIdFlag !== undefined && orgIdFlag.trim().length === 0) { @@ -1083,6 +1089,11 @@ async function connectProvider( return 'connected'; } +export function needsCloudAccountCapacity(args: Record): boolean { + if (getArgBoolean(args, 'reconnect') === true) return false; + return !(getArgString(args, 'provider') === 'azure' && getArgString(args, 'request') !== undefined); +} + export const cloudConnectCommand: Command = { name: 'cloud connect', description: `Connect a cloud account (${PROVIDER_OPTIONS.map((o) => o.label).join(', ')})`, @@ -1102,7 +1113,10 @@ export const cloudConnectCommand: Command = { { flag: '--project-id ', description: 'Google Cloud project ID (e.g. my-production-project)', type: 'string' }, { flag: '--project-number ', description: 'Google Cloud project number (numeric, beside the project ID in project settings)', type: 'string' }, { flag: '--no-investigate-alerts', description: 'Google Cloud: do not investigate Cloud Monitoring alerts in the project', type: 'boolean' }, - { flag: '--request ', description: 'Google Cloud: verify and connect a prepared setup (gcpconn_...) once its script has run', type: 'string' }, + { flag: '--request ', description: 'Google Cloud: verify and connect a prepared setup (gcpconn_...) once its script has run; Azure: check a started connection (azureconn_...)', type: 'string' }, + // Azure + { flag: '--no-alert-rules', description: 'Azure: do not allow Polylane to create Azure Monitor alert rules', type: 'boolean' }, + { flag: '--no-wait', description: 'Azure: print the deployment URL and the request ID, then exit without waiting', type: 'boolean' }, // Cloudflare / Fly / Railway / PlanetScale / Convex / Turso / Neon { flag: '--token ', description: 'Cloudflare API token, Fly.io token, Railway token, PlanetScale service token, Convex team access token, Turso platform API token, or Neon API key', type: 'string' }, { flag: '--org-id ', description: 'Neon: organization to connect (org-...), when the key reaches more than one', type: 'string' }, @@ -1121,7 +1135,7 @@ export const cloudConnectCommand: Command = { // ClickHouse { flag: '--key-id ', description: 'ClickHouse Cloud API key ID', type: 'string' }, { flag: '--key-secret ', description: 'ClickHouse Cloud API key secret', type: 'string' }, - { flag: '--no-browser', description: 'AWS / Vercel / PlanetScale / Supabase: print the URL instead of opening it; Cloudflare: show manual token-creation steps instead of opening the browser', type: 'boolean' }, + { flag: '--no-browser', description: 'AWS / Azure / Vercel / PlanetScale / Supabase: print the URL instead of opening it; Cloudflare: show manual token-creation steps instead of opening the browser', type: 'boolean' }, { flag: '--reconnect', description: 'AWS / Vercel / PlanetScale / Supabase / Kubernetes: run the connect flow even when the provider is already connected', type: 'boolean' }, ], examples: [ @@ -1133,6 +1147,9 @@ export const cloudConnectCommand: Command = { 'polylane cloud connect --provider aws --account 123456789012 --region all', 'polylane cloud connect --provider gcp --project-id my-production-project --project-number 123456789012', 'polylane cloud connect --provider gcp --request gcpconn_... --project-number 123456789012', + 'polylane cloud connect --provider azure', + 'polylane cloud connect --provider azure --no-alert-rules --no-wait', + 'polylane cloud connect --provider azure --request azureconn_...', 'polylane cloud connect --provider render --api-key ', 'polylane cloud connect --provider railway --token ', 'polylane cloud connect --provider supabase', @@ -1153,8 +1170,9 @@ export const cloudConnectCommand: Command = { // One more account has to fit the plan. At the limit the gate offers the // upgrade and, once it lands, the connect continues; a decline ends the // command with QUOTA so a driving script knows nothing failed. A - // reconnect replaces an account and needs no room. - if (getArgBoolean(args, 'reconnect') !== true) { + // reconnect replaces an account and needs no room, and checking a started + // Azure request adds none: the server checks room when it activates it. + if (needsCloudAccountCapacity(args)) { const gate = await ensureCloudAccountCapacity(config, workspaceId, { noBrowser }); if (gate === 'declined') { process.exitCode = ExitCode.QUOTA; diff --git a/src/commands/integration/connect.ts b/src/commands/integration/connect.ts index 037fe9d..9317679 100644 --- a/src/commands/integration/connect.ts +++ b/src/commands/integration/connect.ts @@ -63,7 +63,6 @@ type ConnectableType = | 'devin' | 'cursor' | 'factory' - | 'conductor' | 'capy' | 'amp' | 'linear' @@ -114,7 +113,6 @@ const TYPE_OPTIONS: Array<{ value: ConnectableType; label: string; hint: string; { value: 'devin', label: 'Devin', hint: 'API key · coding agent', category: 'code-agent' }, { value: 'cursor', label: 'Cursor', hint: 'API key · coding agent', category: 'code-agent' }, { value: 'factory', label: 'Factory', hint: 'API key · coding agent', category: 'code-agent' }, - { value: 'conductor', label: 'Conductor', hint: 'API key · coding agent', category: 'code-agent' }, { value: 'capy', label: 'Capy', hint: 'API key · coding agent', category: 'code-agent' }, { value: 'amp', label: 'Amp', hint: 'access token · coding agent', category: 'code-agent' }, { value: 'linear', label: 'Linear', hint: 'personal API key · issue tracking', category: 'issue-tracking' }, @@ -393,14 +391,6 @@ const CODE_AGENTS = { link: 'https://app.factory.ai/settings/api-keys', linkLabel: 'Create API key', }, - conductor: { - keyLabel: 'API key', - name: 'Conductor', - instructions: - 'Create an API key in Conductor under Users > API keys. The key starts with sk_. Handed-off autofixes run as agents in Conductor Cloud workspaces on your account.', - link: 'https://app.conductor.build/users/api-keys', - linkLabel: 'Create API key', - }, capy: { keyLabel: 'API key', name: 'Capy', @@ -1600,7 +1590,7 @@ export const integrationConnectCommand: Command = { { flag: '--api-key ', description: - 'API key (Datadog / Honeycomb / OpenStatus / Logfire project key / New Relic User key / LangSmith / Braintrust service token / incident.io / Amplitude / CircleCI personal API token / Devin / Cursor / Factory / Conductor / Capy / Amp access token / Linear)', + 'API key (Datadog / Honeycomb / OpenStatus / Logfire project key / New Relic User key / LangSmith / Braintrust service token / incident.io / Amplitude / CircleCI personal API token / Devin / Cursor / Factory / Capy / Amp access token / Linear)', type: 'string', }, { flag: '--secret-key ', description: 'Secret key (Amplitude only)', type: 'string' }, diff --git a/test/cloud-connect-azure.test.ts b/test/cloud-connect-azure.test.ts new file mode 100644 index 0000000..abe6396 --- /dev/null +++ b/test/cloud-connect-azure.test.ts @@ -0,0 +1,254 @@ +import { describe, it, before, after, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { + azureResumeCommand, + cancelAzureRequest, + connectAzure, + isTransientAzurePollError, + parseAzureRequestId, + pollAzureRequest, + type AzureConnectionRequest, +} from '../src/commands/cloud/azure-connect'; +import { PROVIDER_OPTIONS, needsCloudAccountCapacity } from '../src/commands/cloud/connect'; +import { CLIError } from '../src/errors/base'; +import { ApiError } from '../src/errors/api'; +import { ExitCode } from '../src/errors/codes'; +import type { Config } from '../src/config/schema'; +import type { PolylaneAPI } from '../src/generated/client'; + +const config = { nonInteractive: true, quiet: true, output: 'text' } as Config; +const REQUEST_ID = 'azureconn_abcdefghijklmnopqrstuvwx'; +const DEPLOY_URL = 'https://portal.azure.com/#create/Microsoft.Template/uri/https%3A%2F%2Fapi.example.test'; + +function request(overrides: Partial = {}): AzureConnectionRequest { + return { + requestId: REQUEST_ID, + workspaceId: 'ws_1', + status: 'pending', + registrationDefinitionName: 'Polylane', + createMonitoringAlarms: true, + subscriptionId: null, + tenantId: null, + cloudAccountId: null, + createdBy: 'usr_1', + createdAt: '2026-11-09T00:00:00Z', + expiresAt: '2026-11-16T00:00:00Z', + activatedAt: null, + completedAt: null, + ...overrides, + }; +} + +const connected = request({ + status: 'active', + subscriptionId: '00000000-0000-0000-0000-000000000001', + cloudAccountId: 'acc_1', + activatedAt: '2026-11-09T00:05:00Z', + completedAt: '2026-11-09T00:05:01Z', +}); + +function sequenceApi(responses: AzureConnectionRequest[]): { api: PolylaneAPI; reads: () => number } { + let reads = 0; + const api = { + cloudAccountsAzureRequestsGet: async () => responses[Math.min(reads++, responses.length - 1)]!, + } as unknown as PolylaneAPI; + return { api, reads: () => reads }; +} + +describe('Azure connect', () => { + const originalStdout = process.stdout.write; + const originalStderr = process.stderr.write; + let stdout = ''; + let stderr = ''; + + before(() => { + process.stdout.write = ((chunk: string | Uint8Array): boolean => { + stdout += String(chunk); + return true; + }) as typeof process.stdout.write; + process.stderr.write = ((chunk: string | Uint8Array): boolean => { + stderr += String(chunk); + return true; + }) as typeof process.stderr.write; + }); + + beforeEach(() => { + stdout = ''; + stderr = ''; + }); + + after(() => { + process.stdout.write = originalStdout; + process.stderr.write = originalStderr; + }); + + it('is offered in the provider picker', () => { + assert.ok(PROVIDER_OPTIONS.some((o) => o.value === 'azure' && o.label === 'Azure')); + }); + + it('accepts only azureconn_ request IDs', () => { + assert.equal(parseAzureRequestId(` ${REQUEST_ID} `), REQUEST_ID); + assert.throws( + () => parseAzureRequestId('gcpconn_abcdefghijklmnopqrstuvwx'), + (err: unknown) => err instanceof CLIError && err.exitCode === ExitCode.USAGE + ); + }); + + it('polls until the request is active and completed', async () => { + const pendingActive = request({ status: 'active', subscriptionId: connected.subscriptionId }); + const { api, reads } = sequenceApi([request(), pendingActive, connected]); + const sleeps: number[] = []; + const result = await pollAzureRequest(api, 'ws_1', REQUEST_ID, { + sleep: async (ms) => { + sleeps.push(ms); + }, + }); + assert.deepEqual(result, { kind: 'connected', request: connected }); + assert.equal(reads(), 3); + assert.deepEqual(sleeps, [5_000, 5_000]); + }); + + it('times out when the deployment never connects', async () => { + let clock = 0; + const { api } = sequenceApi([request()]); + const result = await pollAzureRequest(api, 'ws_1', REQUEST_ID, { + now: () => clock, + sleep: async (ms) => { + clock += ms; + }, + }); + assert.deepEqual(result, { kind: 'timeout' }); + assert.equal(clock, 15 * 60_000); + }); + + it('stops with the start command when the request is cancelled or expired', async () => { + for (const status of ['cancelled', 'expired'] as const) { + const { api } = sequenceApi([request({ status })]); + await assert.rejects( + () => pollAzureRequest(api, 'ws_1', REQUEST_ID, { sleep: async () => {} }), + (err: unknown) => + err instanceof CLIError && + err.message.includes(status) && + (err.hint ?? '').includes('polylane --workspace ws_1 cloud connect --provider azure') + ); + } + }); + + it('starts the request with alert rules on by default and waits for the connection', async () => { + const bodies: Array> = []; + const api = { + cloudAccountsConnect: async (body: Record) => { + bodies.push(body); + return { provider: 'azure', request: request(), templateUrl: 'https://api.example.test/t', deployUrl: DEPLOY_URL }; + }, + cloudAccountsAzureRequestsGet: async () => connected, + } as unknown as PolylaneAPI; + + const outcome = await connectAzure(config, api, {}, 'ws_1', false); + + assert.equal(outcome, 'connected'); + assert.deepEqual(bodies, [{ workspaceId: 'ws_1', provider: 'azure', createMonitoringAlarms: true }]); + assert.equal(stdout, DEPLOY_URL + '\n'); + assert.ok(stderr.includes('✓ Connected: Azure subscription 00000000-0000-0000-0000-000000000001 (acc_1)')); + }); + + it('prints the URL and the check command with --no-wait, and turns alert rules off', async () => { + let createMonitoringAlarms: unknown; + const api = { + cloudAccountsConnect: async (body: Record) => { + createMonitoringAlarms = body.createMonitoringAlarms; + return { + provider: 'azure', + request: request({ createMonitoringAlarms: false }), + templateUrl: 'https://api.example.test/t', + deployUrl: DEPLOY_URL, + }; + }, + } as unknown as PolylaneAPI; + + const outcome = await connectAzure(config, api, { noAlertRules: true, noWait: true }, 'ws_1', true); + + assert.equal(outcome, 'pending'); + assert.equal(createMonitoringAlarms, false); + assert.equal(stdout, DEPLOY_URL + '\n'); + assert.ok(stderr.includes(REQUEST_ID)); + assert.ok(stderr.includes(azureResumeCommand('ws_1', REQUEST_ID))); + }); + + it('returns the request as JSON without waiting', async () => { + const api = { + cloudAccountsConnect: async () => ({ + provider: 'azure', + request: request(), + templateUrl: 'https://api.example.test/t', + deployUrl: DEPLOY_URL, + }), + } as unknown as PolylaneAPI; + + const outcome = await connectAzure({ ...config, output: 'json' } as Config, api, {}, 'ws_1', false); + + assert.equal(outcome, 'pending'); + const printed = JSON.parse(stdout) as Record; + assert.equal(printed.requestId, REQUEST_ID); + assert.equal(printed.deployUrl, DEPLOY_URL); + assert.equal(printed.next, azureResumeCommand('ws_1', REQUEST_ID)); + }); + + it('resumes a connected request without starting a new one', async () => { + const api = { cloudAccountsAzureRequestsGet: async () => connected } as unknown as PolylaneAPI; + const outcome = await connectAzure(config, api, { request: REQUEST_ID }, 'ws_1', false); + assert.equal(outcome, 'connected'); + assert.equal(stdout, ''); + }); + + it('cancels the request and names the start command', async () => { + const cancelled: string[] = []; + const api = { + cloudAccountsAzureRequestsCancel: async (_ws: string, id: string) => { + cancelled.push(id); + return request({ status: 'cancelled' }); + }, + } as unknown as PolylaneAPI; + assert.equal(await cancelAzureRequest(api, 'ws_1', REQUEST_ID), true); + assert.deepEqual(cancelled, [REQUEST_ID]); + assert.ok(stderr.includes('polylane --workspace ws_1 cloud connect --provider azure')); + }); + it('checks a started Azure request without the plan capacity gate', () => { + assert.equal(needsCloudAccountCapacity({ provider: 'azure', request: REQUEST_ID }), false); + assert.equal(needsCloudAccountCapacity({ provider: 'azure' }), true); + assert.equal(needsCloudAccountCapacity({ provider: 'gcp', request: 'gcpconn_1' }), true); + assert.equal(needsCloudAccountCapacity({ provider: 'aws', reconnect: true }), false); + }); + + it('keeps waiting through a network error or a server error', async () => { + let reads = 0; + const failures: unknown[] = [new TypeError('fetch failed'), new ApiError(503, 'Service unavailable'), new CLIError('Network error', ExitCode.NETWORK), new CLIError('Request timed out', ExitCode.TIMEOUT)]; + const api = { + cloudAccountsAzureRequestsGet: async () => { + const failure = failures[reads++]; + if (failure) throw failure; + return connected; + }, + } as unknown as PolylaneAPI; + const result = await pollAzureRequest(api, 'ws_1', REQUEST_ID, { sleep: async () => {} }); + assert.equal(result.kind, 'connected'); + assert.equal(reads, 5); + }); + + it('stops on an error that a retry cannot fix', async () => { + const api = { + cloudAccountsAzureRequestsGet: async () => { + throw new ApiError(403, 'Forbidden', ExitCode.AUTH); + }, + } as unknown as PolylaneAPI; + await assert.rejects(() => pollAzureRequest(api, 'ws_1', REQUEST_ID, { sleep: async () => {} }), /Forbidden/); + assert.equal(isTransientAzurePollError(new ApiError(404, 'Not found')), false); + }); + + it('reports a request that connected before the cancel instead of cancelling it', async () => { + const api = { cloudAccountsAzureRequestsCancel: async () => connected } as unknown as PolylaneAPI; + assert.equal(await cancelAzureRequest(api, 'ws_1', REQUEST_ID), false); + assert.ok(!stderr.includes('Cancelled.')); + assert.ok(stderr.includes(`--request ${REQUEST_ID}`)); + }); +}); diff --git a/test/integration-connect-category.test.ts b/test/integration-connect-category.test.ts index a85eb7d..531434b 100644 --- a/test/integration-connect-category.test.ts +++ b/test/integration-connect-category.test.ts @@ -10,7 +10,7 @@ import { isCLIError } from '../src/errors/base'; describe('typeOptionsForCategory', () => { it('returns every option when no category is given', () => { const all = typeOptionsForCategory(undefined); - assert.equal(all.length, 28); + assert.equal(all.length, 27); }); it('narrows to exactly the observability integrations', () => { @@ -37,7 +37,7 @@ describe('typeOptionsForCategory', () => { it('narrows to exactly the code agents', () => { const types = typeOptionsForCategory('code-agent').map((o) => o.value); - assert.deepEqual(types.sort(), ['amp', 'capy', 'conductor', 'cursor', 'devin', 'factory']); + assert.deepEqual(types.sort(), ['amp', 'capy', 'cursor', 'devin', 'factory']); }); it('narrows to exactly the incident management and CI/CD integrations', () => { @@ -95,9 +95,9 @@ describe('typeOptionsForCategory', () => { describe('resolveTypeOptions', () => { it('lets --type win over the filter', () => { - assert.equal(resolveTypeOptions('observability', true).length, 28); + assert.equal(resolveTypeOptions('observability', true).length, 27); assert.equal(resolveTypeOptions('observability', false).length, 11); - assert.equal(resolveTypeOptions(undefined, false).length, 28); + assert.equal(resolveTypeOptions(undefined, false).length, 27); }); it('rejects an unknown category even when --type is present', () => {