Skip to content

feat(cloud): connect Azure subscriptions - #140

Open
boristane wants to merge 4 commits into
mainfrom
feat/azure-connect
Open

boristane wants to merge 4 commits into
mainfrom
feat/azure-connect

Conversation

@boristane

Copy link
Copy Markdown
Contributor

TL;DR

polylane cloud connect --provider azure connects an Azure subscription. It starts a connection request, opens the Azure portal deployment, waits until the subscription connects, and prints the account.

Why

The Polylane API can connect Azure (provider: "azure" on cloud_accounts.connect, plus cloud_accounts.azure.requests.get and .cancel). The CLI and the installer had no way to start it.

Where to look

  • connectAzure: the flow, the alert-rules prompt, --no-wait and JSON output, and --request to check a started request.
  • pollAzureRequest: reads the request every 5 s for up to 15 min. It stops when the status is active and completedAt is set, and it stops with an error on cancelled or expired. Ctrl+C cancels the request through DELETE.
  • connect.ts: provider option, dispatch, flags (--no-alert-rules, --no-wait) and examples.
  • Tests: offline, with an injected sleep and clock.
  • Separate commit 5739ee1: removes Conductor. The API removed that integration, so typecheck failed on a fresh codegen for every branch, main included.

Feedback wanted

Ctrl+C cancels the request, as the brief asked. If the person already created the deployment, the cancel also releases the registration. Tell me if Ctrl+C should only stop the wait.

Risk

Low. The change adds a new provider branch and does not change other providers. The Conductor removal drops an option the API already refuses.

Verified

  • npm run typecheck, npm run lint, npm test (632 pass), npm run build.
  • polylane cloud connect --help shows the Azure provider, flags and examples.
  • Not verified: a live Azure deployment against the API. That needs an Azure subscription and a person in the portal.

🤖 Generated with Claude Code

boristane and others added 2 commits November 9, 2026 09:40
The Polylane API removed the Conductor integration. The generated client
no longer accepts "conductor" as an integration type, so typecheck failed
on a fresh codegen. This change removes the Conductor option, its key
instructions and its test fixtures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`polylane cloud connect --provider azure` starts an Azure connection
request, prints the Azure portal deployment URL and opens it in the
browser. The person selects the subscription and creates the
deployment. The command then reads the request every 5 seconds for up
to 15 minutes and prints the connected account.

Ctrl+C during the wait cancels the request. `--no-alert-rules` stops
Polylane from creating Azure Monitor alert rules. `--no-wait` and JSON
output print the request ID and the command that checks it later
(`--request azureconn_...`), then exit 7.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@polylane

polylane Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

Warning

Polylane could not verify the production impact of this pull request.

No cloud resource in the workspace executes coreplanelabs/cli. It is an npm-distributed client CLI, and the repository has no deploy target and no attached issue. The delta at d8f62f9 only makes one transient Azure poll read (timeout) retry on the unchanged 5 s interval, bounded by the 15-minute deadline.

View the full analysis →

Also considered · 2 refuted
  • Refuted · Skipping the plan gate on an Azure request check lets a client exceed the plan limit · The changed code is a client CLI that no deployed resource executes, and this path only reads an existing request.
  • Refuted · The transient poll retry could amplify load on the Polylane API · The retry is one user machine polling its own request, bounded to ~180 reads over 15 minutes.
Analysed against 8 cloud accounts and 1 repository
  • Cloud accounts: coreplane-prod, baseberry-uat, coreplane-infra, coreplane, coreplane-gtm, 251714435813, Polylane, nominal-493716
  • Repository: coreplanelabs/cli

View in Polylane Pause assessments

Polylane could not find the cloud resources this repository manages, so this review looked at the entire cloud account. Connect this repository to its resources and the next review will focus on exactly what this code deploys to.

Connect resources

Polylane analysed d8f62f9 for production impact. You can ask follow-ups by mentioning @polylane in a comment.

Previous verdicts (2)
Head Verdict Analysis
f51a602 Production impact not verified analysis
a04a386 Production impact not verified analysis

@boristane boristane left a comment

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.

One Important finding blocks this PR: the check command that the CLI prints (--request azureconn_...) fails with exit 4 when the new Azure account used the last plan slot. The rest of the flow agrees with the server contract.

Checked against the server (nominal-azure-identity at cdfc445016, routers/azure-connections/index.ts, connects/azure.ts, durable-workspaces/operations/azure-connections.ts):

  • POST /v1/cloud_accounts with provider: "azure" returns request, templateUrl and deployUrl. The CLI reads request.requestId, request.expiresAt and deployUrl, and it narrows on result.provider. Correct.
  • The request ID is azureconn + 24 characters (packages/util/random/index.ts, len: 24, lowercase base36). ^azureconn_[0-9a-z]{24}$ agrees.
  • The GET drives activation (activate when pending with a subscription, or active without completedAt) and swallows the 404/409 pending states. Thus status === 'active' && completedAt !== null is the correct ready test, and cancelled/expired are the correct terminal states.
  • The DELETE cancels only a pending request. On an active request it returns the request unchanged, so Ctrl+C after the account connects does not disconnect it.
  • The dry run: requestJson returns {}, so resume takes the pending branch and connectAzure returns before it reads the result. No crash.
  • The Conductor removal: no conductor stays in src/ or in the regenerated client, and typecheck passes on a fresh codegen. This agrees with the PR body.

Fresh local evidence at a04a386 (Node v22, not CI): npm ci, npm run codegen (live spec), npm run typecheck, npm run lint all exit 0. npm test: 632 pass, 0 fail. The diff touches skill/SKILL.md, so I also ran npm run build in a clean env -i: ./dist/polylane.mjs --version prints polylane 0.2.53, and cloud connect --help shows the Azure provider, --no-alert-rules, --no-wait and the three examples. I did not run Node 20 or 24 locally. The diff uses no new Node API.

Findings, by severity:

  1. Important (azure-connect.ts resume, inline): cloud connect runs ensureCloudAccountCapacity before the provider dispatch, and --request does not skip it. An Azure request can become active on the server without the CLI (the Event Grid handshake activates it). When this account fills the plan, the gate counts it and the check of that same request fails with QUOTA. The class: every path that tells the user to run azureResumeCommand. These are the next field of JSON output (JSON runs always exit 7 and hand over this command), the --no-wait hint, the timeout hint, the failed-cancel hint, and the SKILL.md text. All of them reach the gate. The GCP --request path is not in the class: its resume creates the account, so the gate is correct there.
  2. Nit (pollAzureRequest, inline): one failed GET (network error, 5xx) during the 15-minute wait stops the wait with a generic error. The request stays pending and the error does not name the check command.
  3. Nit (cancelAzureRequest, inline): the CLI writes "Cancelled." without reading the returned status. If the account connected between two polls, the DELETE returns active and the message is wrong.
  4. Nit: in README.md, line 126, the # comment does not align with the column of the lines around it.

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".

Comment thread src/commands/cloud/azure-connect.ts Outdated
const now = deps.now ?? Date.now;
const deadline = now() + timeoutMs;
for (;;) {
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.

Nit. One transient failure here (a network error, or a 5xx from the API) stops a 15-minute wait. The error goes to handleError, and it does not name azureResumeCommand. The request stays pending. Recommendation: retry a network or 5xx error on the next interval until the deadline, or catch it in waitForConnection and print the check command, as the timeout path does.

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. pollAzureRequest now reads again on the next interval after a transient error, until the deadline. A transient error is a network error (TypeError from fetch or a CLIError with ExitCode.NETWORK), an ApiError with status 5xx, or a 429. Any other error, such as 403 or 404, still stops the wait. When the deadline passes, the wait prints the check command, as before. Tests: "keeps waiting through a network error or a server error" and "stops on an error that a retry cannot fix".

process.stderr.write('Cancelling the Azure connection request…\n');
try {
await api.cloudAccountsAzureRequestsCancel(workspaceId, requestId);
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".

…nd keep waiting through transient errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@boristane

Copy link
Copy Markdown
Contributor Author

Review 5835936022 is addressed at f51a602. The Important finding about the capacity gate and the request check, and both inline nits, are fixed with tests. The README comment column is aligned again. Typecheck, lint and 636/636 tests pass on Node 22.

@boristane boristane left a comment

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.

LGTM: the four findings of the previous review (5835936022 on a04a386) are fixed, and the delta adds no blocking issue.

Verification of each finding in a04a386..f51a602:

  • Important, capacity gate: needsCloudAccountCapacity returns false for --provider azure --request <id> and for --reconnect. It returns true for a new Azure connect and for every other provider. The server checks capacity when it activates the request (markActive in azure-connections.ts, and assertWithinLimit in connects/azure.ts). Fixed.
  • Nit, transient poll failure: pollAzureRequest now reads again after a 5xx, a 429, a NETWORK CLIError or a TypeError. A 4xx stops the wait. Fixed, with one remaining gap (inline Nit).
  • Nit, Ctrl+C message: cancelAzureRequest now reads the returned status. The server cancel returns the stored record without change when the request is not pending, so an active result prints the resume command and not "Cancelled.". Fixed.
  • Nit, README column: aligned. Fixed.

Local checks at f51a602 on Node v22.23.4 (fresh, not cached): npm ci, npm run typecheck (codegen against the live spec), npm run lint and npm test all pass: 636 tests, 0 failures.

Nit (inline): a request timeout still ends the 15-minute wait.

Comment thread src/commands/cloud/azure-connect.ts Outdated
// 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;

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: src/client/http.ts:110 converts a fetch timeout (TimeoutError) into CLIError('Request timed out', ExitCode.TIMEOUT), and an abort into ExitCode.TIMEOUT too. This check accepts only ExitCode.NETWORK, so one slow read still ends the 15-minute wait. That is the same failure as the previous Nit. Suggestion: also accept ExitCode.TIMEOUT here. Also, http.ts converts every fetch TypeError into a NETWORK CLIError, so the TypeError branch does not occur through the generated client. It is harmless.

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 d8f62f9. isTransientAzurePollError now also accepts ExitCode.TIMEOUT, so one slow read does not end the wait. The transient-error test now includes a timeout. Typecheck, lint and 636/636 tests pass.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Auto-approved: boristane reviewed this PR and posted an LGTM verdict (see its review). A repo admin enabled this via the auto-approve workflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@boristane boristane left a comment

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.

LGTM: the delta since f51a602 fixes the earlier nit. A fetch timeout (ExitCode.TIMEOUT) is now a transient poll error, and the test proves it.

Delta reviewed: git diff f51a602..d8f62f9 (2 files, 4 lines).

  • isTransientAzurePollError now accepts ExitCode.TIMEOUT as well as ExitCode.NETWORK. src/client/http.ts throws ExitCode.TIMEOUT for TimeoutError ("Request timed out") and for AbortError ("Request aborted"). The only signal on that fetch is AbortSignal.timeout, so an AbortError there also comes from the timeout and not from the user. A retry is correct for both.
  • The retry cannot loop forever: pollAzureRequest checks its deadline after each read, failed or not, and returns { kind: 'timeout' }.
  • The only caller of isTransientAzurePollError is pollAzureRequest (azure-connect.ts:88). No other behavior changes.
  • The test adds new CLIError('Request timed out', ExitCode.TIMEOUT) to the failure sequence and expects 5 reads. This matches the new error.

Local checks at d8f62f9 on Node 22.23.4, in a clean worktree: npm ci, npm run codegen (through typecheck), npm run typecheck, npm run lint and npm test (636/636 pass) all pass. The build/smoke leg does not apply because the delta does not touch build.ts, codegen/, skill/SKILL.md or packaging.

No findings.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Auto-approved: boristane reviewed this PR and posted an LGTM verdict (see its review). A repo admin enabled this via the auto-approve workflow.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant