mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-16 19:03:26 +00:00
Add Azure Connector Namespaces canvas extension 🤖🤖🤖 (#2250)
* Add MCP Connectors (connector-namespaces) canvas extension A Copilot CLI canvas extension for browsing and adding MCP connectors from an Azure Connector Namespace into a Copilot session. Sign-in is dependency-free (OAuth 2.0 auth-code + PKCE via the Azure CLI public client, loopback redirect); network access is restricted to the public Azure Resource Manager endpoint. MIT licensed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update Connector Namespaces canvas extension Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Harden Connector Namespaces canvas extension Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Sanitize connector icon brand colors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Connector Namespace playground actions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Replace sandbox skill with native tool Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Clarify connector disconnect action Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Color disconnect action red Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Add connector extension plugin manifest Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Address connector canvas review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Remove legacy connector canvas manifest Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Address remaining connector review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Harden connector review fixes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Harden connector convergence Address the latest connector review batch across config persistence, executable trust, reauthentication, JSON-RPC transport, smoke safety, and accessibility. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Use native HTTP connector configs Persist Connector Namespace MCP servers as direct HTTPS entries with API-key headers, remove the stdio unwrap proxy, and exercise the native Streamable HTTP path in smoke coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Secure persisted connector state Create the Connector Namespace artifacts directory and saved gateway config with private permissions, and align the reduced-motion regression notes with the static fallback behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf --------- Co-authored-by: Alex Yang <yangalex@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,444 @@
|
||||
// ARM API client — fetches real connector data with Azure CLI credentials.
|
||||
|
||||
import { exec, execFile } from "node:child_process";
|
||||
import { constants as fsConstants, promises as fs } from "node:fs";
|
||||
import { homedir, platform } from "node:os";
|
||||
import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const API_VERSION = "2026-05-01-preview";
|
||||
const RG_API_VERSION = "2021-04-01";
|
||||
const MSI_API_VERSION = "2023-01-31";
|
||||
const SUBS_API_VERSION = "2020-01-01";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const execAsync = promisify(exec);
|
||||
const ARM_RESOURCE = "https://management.azure.com/";
|
||||
const EXPIRY_SKEW_MS = 5 * 60 * 1000;
|
||||
const LEGACY_AUTH_CACHE = join(
|
||||
process.env.COPILOT_HOME || join(homedir(), ".copilot"),
|
||||
"extensions",
|
||||
"connector-namespaces",
|
||||
"artifacts",
|
||||
"auth-cache.json",
|
||||
);
|
||||
|
||||
let s_auth = null; // { token, expiresAt }
|
||||
let s_authInFlight = null;
|
||||
let s_legacyAuthCacheRemoved = false;
|
||||
|
||||
export function parseAzureCliToken(stdout) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(stdout);
|
||||
} catch {
|
||||
throw new Error("Azure CLI returned invalid token JSON.");
|
||||
}
|
||||
const token = data?.accessToken;
|
||||
const epochSeconds = Number(data?.expires_on);
|
||||
const expiresAt = Number.isFinite(epochSeconds) && epochSeconds > 0
|
||||
? epochSeconds * 1000
|
||||
: Date.parse(data?.expiresOn);
|
||||
if (typeof token !== "string" || token.length === 0 || !Number.isFinite(expiresAt)) {
|
||||
throw new Error("Azure CLI returned an incomplete ARM token.");
|
||||
}
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
async function removeLegacyAuthCache() {
|
||||
if (s_legacyAuthCacheRemoved) return;
|
||||
try {
|
||||
await fs.unlink(LEGACY_AUTH_CACHE);
|
||||
} catch (error) {
|
||||
if (error?.code !== "ENOENT") {
|
||||
throw new Error(`Could not remove the legacy connector credential cache at ${LEGACY_AUTH_CACHE}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
s_legacyAuthCacheRemoved = true;
|
||||
}
|
||||
|
||||
function windowsSystemExecutable(name) {
|
||||
const systemRoot = process.env.SystemRoot;
|
||||
if (systemRoot && isAbsolute(systemRoot)) return join(systemRoot, "System32", name);
|
||||
throw new Error(`Could not resolve the Windows system executable ${name}.`);
|
||||
}
|
||||
|
||||
async function trustedExecutablePath(path, expectedName, workspaceRoot = process.cwd()) {
|
||||
if (!isAbsolute(path) || /["\r\n]/.test(path)) return null;
|
||||
let candidate;
|
||||
let workspace;
|
||||
try {
|
||||
[candidate, workspace] = await Promise.all([
|
||||
fs.realpath(path),
|
||||
fs.realpath(workspaceRoot),
|
||||
]);
|
||||
if (!(await fs.stat(candidate)).isFile()) return null;
|
||||
if (platform() !== "win32") await fs.access(candidate, fsConstants.X_OK);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const insensitive = platform() === "win32";
|
||||
const normalize = (value) => insensitive ? value.toLowerCase() : value;
|
||||
const normalizedCandidate = normalize(resolve(candidate));
|
||||
const normalizedWorkspace = normalize(resolve(workspace));
|
||||
const workspacePrefix = normalizedWorkspace.endsWith(sep)
|
||||
? normalizedWorkspace
|
||||
: `${normalizedWorkspace}${sep}`;
|
||||
if (
|
||||
normalize(basename(candidate)) !== normalize(expectedName) ||
|
||||
normalizedCandidate === normalizedWorkspace ||
|
||||
normalizedCandidate.startsWith(workspacePrefix)
|
||||
) return null;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
async function resolveWindowsAzureCli() {
|
||||
const trustedCwd = homedir();
|
||||
const { stdout } = await execFileAsync(
|
||||
windowsSystemExecutable("where.exe"),
|
||||
["az.cmd"],
|
||||
{ cwd: trustedCwd, encoding: "utf8", windowsHide: true, timeout: 10_000, maxBuffer: 64 * 1024 },
|
||||
);
|
||||
for (const path of stdout.split(/\r?\n/).map((line) => line.trim())) {
|
||||
if (/[%]/.test(path)) continue;
|
||||
const candidate = await trustedExecutablePath(path, "az.cmd");
|
||||
if (candidate) return candidate;
|
||||
}
|
||||
throw new Error("Azure CLI was not found outside the current workspace.");
|
||||
}
|
||||
|
||||
export async function resolvePosixAzureCli(pathValue = process.env.PATH || "", workspaceRoot = process.cwd()) {
|
||||
for (const directory of pathValue.split(delimiter)) {
|
||||
const candidate = await trustedExecutablePath(resolve(directory || workspaceRoot, "az"), "az", workspaceRoot);
|
||||
if (candidate) return candidate;
|
||||
}
|
||||
throw new Error("Azure CLI was not found outside the current workspace.");
|
||||
}
|
||||
|
||||
export async function resolveSystemExecutable(name, workspaceRoot = process.cwd()) {
|
||||
const candidates = platform() === "win32"
|
||||
? [windowsSystemExecutable(name)]
|
||||
: [join("/usr/bin", name), join("/bin", name), join("/usr/local/bin", name)];
|
||||
for (const path of candidates) {
|
||||
const candidate = await trustedExecutablePath(path, name, workspaceRoot);
|
||||
if (candidate) return candidate;
|
||||
}
|
||||
throw new Error(`Could not resolve the trusted system executable ${name}.`);
|
||||
}
|
||||
|
||||
async function acquireToken() {
|
||||
await removeLegacyAuthCache();
|
||||
try {
|
||||
const windows = platform() === "win32";
|
||||
const azureCli = windows ? await resolveWindowsAzureCli() : await resolvePosixAzureCli();
|
||||
const options = { cwd: homedir(), encoding: "utf8", windowsHide: true, timeout: 60_000, maxBuffer: 1024 * 1024 };
|
||||
const { stdout } = windows
|
||||
? await execAsync(
|
||||
`"${azureCli}" account get-access-token --resource https://management.azure.com/ --output json --only-show-errors`,
|
||||
{ ...options, shell: windowsSystemExecutable("cmd.exe") },
|
||||
)
|
||||
: await execFileAsync(
|
||||
azureCli,
|
||||
["account", "get-access-token", "--resource", ARM_RESOURCE, "--output", "json", "--only-show-errors"],
|
||||
options,
|
||||
);
|
||||
s_auth = parseAzureCliToken(stdout);
|
||||
return s_auth.token;
|
||||
} catch (error) {
|
||||
const detail = String(error?.stderr || error?.message || "").trim();
|
||||
throw new Error(
|
||||
`Azure CLI authentication failed. Install Azure CLI and run "az login" before opening the canvas.${detail ? ` ${detail}` : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getToken() {
|
||||
if (s_auth && s_auth.expiresAt - EXPIRY_SKEW_MS > Date.now()) return s_auth.token;
|
||||
if (s_authInFlight) return s_authInFlight;
|
||||
s_authInFlight = acquireToken().finally(() => {
|
||||
s_authInFlight = null;
|
||||
});
|
||||
return s_authInFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all enabled Azure subscriptions the user has access to.
|
||||
*/
|
||||
// The set of enabled subscriptions is stable for a session, so cache it — the
|
||||
// first /setup pays the ARM round-trip once and every "Change namespace"
|
||||
// afterwards serves from memory.
|
||||
let s_subsCache = null; // { subs, expiresAt }
|
||||
const SUBS_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
export async function listSubscriptions() {
|
||||
const now = Date.now();
|
||||
if (s_subsCache && s_subsCache.expiresAt > now) return s_subsCache.subs;
|
||||
const token = await getToken();
|
||||
const url = `https://management.azure.com/subscriptions?api-version=${SUBS_API_VERSION}`;
|
||||
const raw = await paginateAll(url, token);
|
||||
const subs = raw
|
||||
.filter((s) => s.state === "Enabled")
|
||||
.map((s) => ({ id: s.subscriptionId, name: s.displayName, tenantId: s.tenantId, state: s.state }));
|
||||
s_subsCache = { subs, expiresAt: now + SUBS_TTL_MS };
|
||||
return subs;
|
||||
}
|
||||
|
||||
// ARM resource identifiers are a restricted charset (letters, digits and a few
|
||||
// punctuation chars). Validating each path segment against this allowlist before
|
||||
// it enters a URL rejects anything containing "/", "?", "#", "@" or ":" — the
|
||||
// characters that could otherwise alter the request path or redirect the host —
|
||||
// and acts as a taint barrier so config/file-derived names cannot reach fetch
|
||||
// unvalidated.
|
||||
const ARM_SEGMENT = /^[A-Za-z0-9._()-]{1,256}$/;
|
||||
|
||||
export function armSegment(value) {
|
||||
const s = String(value);
|
||||
if (s === "." || s === ".." || !ARM_SEGMENT.test(s)) {
|
||||
throw new Error(`Invalid ARM resource identifier: ${s}`);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function buildBaseUrl(subscriptionId, resourceGroup, gatewayName) {
|
||||
return `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/resourceGroups/${armSegment(resourceGroup)}/providers/Microsoft.Web/connectorGateways/${armSegment(gatewayName)}`;
|
||||
}
|
||||
|
||||
// Hard host allowlist: every request this client makes targets ARM and only
|
||||
// ARM. The trailing slash matters — it blocks suffix/userinfo bypasses such as
|
||||
// "https://management.azure.com.evil.com/" and "https://management.azure.com@evil.com/",
|
||||
// neither of which starts with this exact prefix. This guards the paginated
|
||||
// nextLink (a server-supplied value) which does not pass through armSegment.
|
||||
const ARM_BASE = "https://management.azure.com/";
|
||||
|
||||
// Returns the URL only if it targets ARM, otherwise throws. Used by callers
|
||||
// (e.g. install.mjs) that build ARM URLs before handing them here.
|
||||
export function assertArmHost(rawUrl) {
|
||||
const url = String(rawUrl);
|
||||
if (!url.startsWith(ARM_BASE)) {
|
||||
throw new Error(`Refusing to call non-ARM URL: ${url}`);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
async function armFetch(url, token) {
|
||||
// Guard the exact value handed to fetch so a tainted path segment or a
|
||||
// server-supplied nextLink can never redirect the call off ARM.
|
||||
if (!url.startsWith(ARM_BASE)) {
|
||||
throw new Error(`Refusing to call non-ARM URL: ${url}`);
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`ARM ${res.status}: ${body.slice(0, 300)}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ARM normally returns distinct nextLink URLs that terminate, but a buggy or
|
||||
// hostile endpoint could return a repeating/self-referential nextLink. Guard
|
||||
// against an unbounded loop with a seen-set and a hard page cap.
|
||||
const MAX_PAGES = 1000;
|
||||
|
||||
async function paginateAll(url, token) {
|
||||
const results = [];
|
||||
const seen = new Set();
|
||||
let nextUrl = url;
|
||||
let pages = 0;
|
||||
while (nextUrl) {
|
||||
if (seen.has(nextUrl) || pages >= MAX_PAGES) break;
|
||||
seen.add(nextUrl);
|
||||
pages++;
|
||||
const data = await armFetch(nextUrl, token);
|
||||
if (data.value) results.push(...data.value);
|
||||
nextUrl = data.nextLink || null;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* List connector gateways in a subscription.
|
||||
* Uses $top=10 and stops after the first page for speed.
|
||||
* Pass fetchAll=true to paginate through everything.
|
||||
*/
|
||||
export async function listConnectorGateways(subscriptionId, { fetchAll = false } = {}) {
|
||||
const token = await getToken();
|
||||
const url = `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/providers/Microsoft.Web/connectorGateways?api-version=${API_VERSION}&$top=10`;
|
||||
if (fetchAll) return { items: await paginateAll(url, token), hasMore: false };
|
||||
// First page only — much faster
|
||||
const data = await armFetch(url, token);
|
||||
const items = data.value || [];
|
||||
return { items, hasMore: !!data.nextLink };
|
||||
}
|
||||
|
||||
/**
|
||||
* List managed APIs (traditional connectors)
|
||||
*/
|
||||
export async function listManagedApis(subscriptionId, resourceGroup, gatewayName) {
|
||||
const token = await getToken();
|
||||
const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}/managedApis?api-version=${API_VERSION}`;
|
||||
return paginateAll(url, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* List managed hosted MCP servers
|
||||
*/
|
||||
export async function listManagedHostedMcpServers(subscriptionId, resourceGroup, gatewayName) {
|
||||
const token = await getToken();
|
||||
const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}/managedHostedMcpServers?api-version=${API_VERSION}`;
|
||||
return paginateAll(url, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* List managed MCP operations
|
||||
*/
|
||||
export async function listManagedMcpOperations(subscriptionId, resourceGroup, gatewayName) {
|
||||
const token = await getToken();
|
||||
const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}/managedMcpOperations?api-version=${API_VERSION}`;
|
||||
return paginateAll(url, token);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create connector namespace (provisioning flow)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
// Write helper (PUT/PATCH/DELETE) that mirrors armFetch's host guard but keeps
|
||||
// the parsed error body so callers can surface ARM's message verbatim.
|
||||
async function armWrite(method, url, body, extraHeaders = {}) {
|
||||
if (!url.startsWith(ARM_BASE)) {
|
||||
throw new Error(`Refusing to call non-ARM URL: ${url}`);
|
||||
}
|
||||
const token = await getToken();
|
||||
const headers = { Authorization: `Bearer ${token}` };
|
||||
Object.assign(headers, extraHeaders);
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json";
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await res.text();
|
||||
let parsed;
|
||||
try { parsed = text ? JSON.parse(text) : undefined; } catch { parsed = text; }
|
||||
if (!res.ok) {
|
||||
const msg = parsed?.error?.message ?? parsed?.message ?? text ?? `HTTP ${res.status}`;
|
||||
const err = new Error(`ARM ${method} ${res.status}: ${String(msg).slice(0, 300)}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* List resource groups in a subscription (sorted by name).
|
||||
*/
|
||||
export async function listResourceGroups(subscriptionId) {
|
||||
const token = await getToken();
|
||||
const url = `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/resourcegroups?api-version=${RG_API_VERSION}`;
|
||||
const items = await paginateAll(url, token);
|
||||
return items
|
||||
.map((rg) => ({ name: rg.name, location: rg.location }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a resource group without updating an existing group on a name race.
|
||||
*/
|
||||
export async function createResourceGroup(subscriptionId, name, location) {
|
||||
const url = `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/resourcegroups/${armSegment(name)}?api-version=${RG_API_VERSION}`;
|
||||
return armWrite("PUT", url, { location }, { "If-None-Match": "*" });
|
||||
}
|
||||
|
||||
/**
|
||||
* List user-assigned managed identities across a subscription (sorted by name).
|
||||
*/
|
||||
export async function listUserAssignedIdentities(subscriptionId) {
|
||||
const token = await getToken();
|
||||
const url = `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/providers/Microsoft.ManagedIdentity/userAssignedIdentities?api-version=${MSI_API_VERSION}`;
|
||||
const items = await paginateAll(url, token);
|
||||
return items
|
||||
.map((id) => {
|
||||
const parts = String(id.id).split("/");
|
||||
const rgIdx = parts.findIndex((p) => p.toLowerCase() === "resourcegroups");
|
||||
return {
|
||||
id: id.id,
|
||||
name: id.name,
|
||||
resourceGroup: rgIdx >= 0 ? parts[rgIdx + 1] || "" : "",
|
||||
location: id.location || "",
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a connector namespace name is free in the given resource group.
|
||||
* Returns true when available (ARM 404), false when taken (200). Uses fetch
|
||||
* directly so the 404 isn't thrown the way armFetch would.
|
||||
*/
|
||||
export async function checkConnectorGatewayNameAvailable(subscriptionId, resourceGroup, gatewayName) {
|
||||
const token = await getToken();
|
||||
const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}?api-version=${API_VERSION}`;
|
||||
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (res.status === 404) return true;
|
||||
if (res.ok) return false;
|
||||
const body = await res.text();
|
||||
throw new Error(`ARM ${res.status}: ${body.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
// ARM `identity` block — mirrors the portal's buildIdentityPayload so the PUT
|
||||
// body is always explicit ({ type: "None" } when nothing is configured).
|
||||
export function buildGatewayIdentity(enableSystem, userAssignedIds = []) {
|
||||
const hasUser = userAssignedIds.length > 0;
|
||||
const type = enableSystem && hasUser
|
||||
? "SystemAssigned,UserAssigned"
|
||||
: enableSystem
|
||||
? "SystemAssigned"
|
||||
: hasUser
|
||||
? "UserAssigned"
|
||||
: "None";
|
||||
const identity = { type };
|
||||
if (hasUser) {
|
||||
identity.userAssignedIdentities = Object.fromEntries(userAssignedIds.map((id) => [id, {}]));
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
|
||||
export async function waitForProvisioning(initialResult, gatewayName, fetchLatest, {
|
||||
maxPolls = 60,
|
||||
delay = () => sleep(3000),
|
||||
} = {}) {
|
||||
let result = initialResult;
|
||||
let state;
|
||||
for (let poll = 0; poll <= maxPolls; poll++) {
|
||||
state = result?.properties?.provisioningState;
|
||||
if (state === "Succeeded") return result;
|
||||
if (state === "Failed" || state === "Canceled") {
|
||||
throw new Error(`Provisioning ${state} for "${gatewayName}".`);
|
||||
}
|
||||
if (poll === maxPolls) break;
|
||||
await delay();
|
||||
result = await fetchLatest();
|
||||
}
|
||||
throw new Error(`Provisioning timed out for "${gatewayName}" (last state: ${state ?? "unknown"}).`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a connector namespace and poll until the
|
||||
* provisioningState reaches a terminal value. Throws on Failed/Canceled.
|
||||
* Returns the final resource object.
|
||||
*/
|
||||
export async function createConnectorGateway(subscriptionId, resourceGroup, gatewayName, { location, identity }) {
|
||||
const token = await getToken();
|
||||
const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}?api-version=${API_VERSION}`;
|
||||
const body = { location, properties: {}, identity };
|
||||
const result = await armWrite("PUT", url, body, { "If-None-Match": "*" });
|
||||
// ~3 min ceiling (60 * 3s). A 202 may have no body, so every state other
|
||||
// than explicit Succeeded enters the polling path.
|
||||
return waitForProvisioning(result, gatewayName, () => armFetch(url, token));
|
||||
}
|
||||
Reference in New Issue
Block a user