Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ polylane integration connect --type <type> # see `polylane integration co
polylane integration connect --type posthog # choose a project; repeat to add another
polylane cloud connect --provider <provider> # see `polylane cloud connect --help`
polylane cloud connect --provider gcp --project-id <id> --project-number <n> # 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
Expand Down
2 changes: 2 additions & 0 deletions skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ polylane service list # cloud infra discovered from

`polylane cloud connect --provider gcp --project-id <id> --project-number <n>` 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 <id> cloud connect --provider gcp --request gcpconn_... --project-number <n>`. 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 <id> 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
Expand Down
273 changes: 273 additions & 0 deletions src/commands/cloud/azure-connect.ts
Original file line number Diff line number Diff line change
@@ -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<ReturnType<PolylaneAPI['cloudAccountsAzureRequestsGet']>>;
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<PolylaneAPI, 'cloudAccountsAzureRequestsGet'>,
workspaceId: string,
requestId: string,
deps: {
intervalMs?: number;
timeoutMs?: number;
sleep?: (ms: number) => Promise<void>;
now?: () => number;
} = {}
): Promise<AzurePollResult> {
const intervalMs = deps.intervalMs ?? AZURE_POLL_INTERVAL_MS;
const timeoutMs = deps.timeoutMs ?? AZURE_POLL_TIMEOUT_MS;
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((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<AzureOutcome> {
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<PolylaneAPI, 'cloudAccountsAzureRequestsCancel'>,
workspaceId: string,
requestId: string
): Promise<boolean> {
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`);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit. The DELETE cancels only a pending request. For any other status it returns the request unchanged (AzureConnectionsOperations.cancel). If the deployment finished between two polls, the request is active, but this line writes "Cancelled.". Recommendation: read the returned status. If it is active with completedAt, report the connected account (or tell the user to run the check command) instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f51a602. cancelAzureRequest now reads the status that the DELETE returns. If the request is active, the CLI says that the deployment finished before the cancel and prints the check command. It no longer prints "Cancelled." in that case. Test: "reports a request that connected before the cancel instead of cancelling it".

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<never> {
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<AzureOutcome> {
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<string, unknown>,
workspaceId: string,
requestId: string
): Promise<AzureOutcome> {
const request = await api.cloudAccountsAzureRequestsGet(workspaceId, requestId);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important. This read is the check that every Azure hint tells the user to run. But cloudConnectCommand.execute runs ensureCloudAccountCapacity first (connect.ts, the if (getArgBoolean(args, 'reconnect') !== true) block), and --request does not skip it. The Event Grid validation handshake activates the request on the server (azureConnectionsRouter events route, activate in waitUntil), so the account can exist before this command runs. When that account fills the plan (for example, the second account on a 2-account plan), cloudAccountCapacity(plan).atLimit is true:

  • In JSON or non-interactive mode, the gate throws CLIError(limitLine(plan), ExitCode.QUOTA, ...) and the command exits 4.
  • In interactive mode, it offers an upgrade.

In both cases the request already connected. An agent that follows the next field of the JSON output sees this failure each time the connection was the last slot.

Recommendation: skip the gate when --provider azure and --request are both set. The server already enforces the limit when it activates the request (the capacity callback in registerAzureConnection), so the gate adds nothing for this path. Add a test that runs --request with a plan at its limit and an active request, and expects exit 0 and the connected account.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in f51a602. cloud connect now calls needsCloudAccountCapacity(args). It skips the plan gate for --reconnect and for --provider azure --request <id>, because the server checks capacity when it activates the request. GCP's --request still goes through the gate. Test: "checks a started Azure request without the plan capacity gate".

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<string, unknown>,
workspaceId: string,
noBrowser: boolean
): Promise<typeof BACK | AzureOutcome> {
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);
}
Loading
Loading