mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 20:52:01 +03:00
fix(core): stop listing Azure resources at startup (#54870)
This commit is contained in:
@@ -1,11 +1,8 @@
|
||||
import { Clock, Effect, FiberHandle, Option, Schema, Semaphore, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"
|
||||
import { ChildProcess } from "effect/process"
|
||||
import path from "node:path"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Form } from "@opencode/schema/form"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { AppProcess } from "@opencode/util/process"
|
||||
import { App } from "../../app.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
@@ -14,7 +11,6 @@ import { Integration } from "../../integration.js"
|
||||
import { IntegrationConnection } from "../../integration/connection.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import { iife } from "../../util/iife.js"
|
||||
import { which } from "../../util/which.js"
|
||||
import type { PluginInternal } from "../internal.js"
|
||||
import { configuredSettings } from "./configured.js"
|
||||
@@ -52,18 +48,8 @@ const decodeManagementDeployment = Schema.decodeUnknownOption(
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const ResourceQuery = Schema.Struct({
|
||||
query: Schema.String,
|
||||
options: Schema.optional(Schema.Struct({ $top: Schema.Number })),
|
||||
})
|
||||
const ResourceQuery = Schema.Struct({ query: Schema.String })
|
||||
const Resources = Schema.Struct({ data: Schema.Array(Schema.Struct({ id: Schema.NonEmptyString })) })
|
||||
const ResourceListing = Schema.Struct({ data: Schema.Array(Schema.Unknown) })
|
||||
const decodeProfile = Schema.decodeUnknownOption(
|
||||
Schema.fromJsonString(Schema.Struct({ subscriptions: Schema.NonEmptyArray(Schema.Unknown) })),
|
||||
)
|
||||
const decodeResourceListing = Schema.decodeUnknownOption(
|
||||
Schema.Struct({ resourceName: Schema.NonEmptyString, resourceGroup: Schema.String, location: Schema.String }),
|
||||
)
|
||||
|
||||
type Deployment = { readonly name: string; readonly model: string }
|
||||
|
||||
@@ -78,7 +64,6 @@ export function make(
|
||||
effect: Effect.fn(function* (ctx) {
|
||||
const configured = yield* configuredSettings(Provider.ID.azure)
|
||||
const processes = yield* AppProcess.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const bus = yield* Bus.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const providers = yield* Provider.Service
|
||||
@@ -133,8 +118,6 @@ export function make(
|
||||
|
||||
const available = Boolean(which("az"))
|
||||
const configuredResource = Boolean(resolveResourceName(configured) || typeof configured?.baseURL === "string")
|
||||
// Undefined until the Azure CLI answers; a failed lookup leaves the form to manual entry.
|
||||
const listing: { resources?: readonly Form.Option[] } = {}
|
||||
|
||||
yield* ctx.integration.transform((editor) => {
|
||||
// The retired azure-cognitive-services provider's env var still connects Azure.
|
||||
@@ -177,16 +160,7 @@ export function make(
|
||||
placeholder: "e.g. my-models",
|
||||
required: true,
|
||||
pattern: resourcePattern.source,
|
||||
// Resources are listed once at startup, so one created later is typed in. Without a list the field
|
||||
// is a plain text input.
|
||||
...iife(() => {
|
||||
if (!listing.resources) return { description: "Requests use your `az login` session." }
|
||||
if (listing.resources.length === 0)
|
||||
return {
|
||||
description: "No Azure OpenAI or AI Services resources found for your `az login` account.",
|
||||
}
|
||||
return { custom: true, options: listing.resources }
|
||||
}),
|
||||
description: "Enter the name of your Azure OpenAI or AI Services resource.",
|
||||
},
|
||||
]),
|
||||
},
|
||||
@@ -371,42 +345,6 @@ export function make(
|
||||
|
||||
Object.assign(loaded, yield* load())
|
||||
|
||||
// Lists the resources the Azure CLI account can reach in the background, so the connect form can offer them
|
||||
// without startup waiting on Azure. The CLI's profile shows a signed-in account without running the CLI.
|
||||
// https://learn.microsoft.com/cli/azure/azure-cli-configuration#cli-configuration-file
|
||||
const listResources = Effect.fn("AzurePlugin.listResources")(function* () {
|
||||
const profile = yield* fs.readFileStringSafe(
|
||||
path.join(process.env.AZURE_CONFIG_DIR ?? path.join(Global.Path.home, ".azure"), "azureProfile.json"),
|
||||
)
|
||||
// The Azure CLI writes the profile with a byte order mark.
|
||||
if (Option.isNone(decodeProfile(profile?.replace(/^\uFEFF/, "")))) return
|
||||
const response = yield* HttpClientRequest.post(
|
||||
`${endpoints.management}/providers/Microsoft.ResourceGraph/resources?api-version=2022-10-01`,
|
||||
).pipe(
|
||||
HttpClientRequest.schemaBodyJson(ResourceQuery)({ query: resourceListQuery, options: { $top: 1000 } }),
|
||||
Effect.flatMap(management),
|
||||
Effect.flatMap(HttpClientResponse.schemaBodyJson(ResourceListing)),
|
||||
Effect.timeout("10 seconds"),
|
||||
)
|
||||
listing.resources = response.data.flatMap((raw): Form.Option[] => {
|
||||
const item = Option.getOrUndefined(decodeResourceListing(raw))
|
||||
if (!item || !resourcePattern.test(item.resourceName)) return []
|
||||
return [
|
||||
{
|
||||
value: item.resourceName,
|
||||
label: item.resourceName,
|
||||
description: `${item.resourceGroup} · ${item.location}`,
|
||||
},
|
||||
]
|
||||
})
|
||||
yield* ctx.integration.reload()
|
||||
})
|
||||
if (available && !configuredResource)
|
||||
yield* listResources().pipe(
|
||||
Effect.catch((cause) => Effect.logDebug("failed to list Azure resources", { cause })),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
yield* ctx.provider.transform((evt) => {
|
||||
for (const item of evt.list()) {
|
||||
if (
|
||||
@@ -553,17 +491,6 @@ function credentialResource(credential: Credential.Value | undefined) {
|
||||
return typeof resource === "string" && resource.trim() !== "" ? resource : undefined
|
||||
}
|
||||
|
||||
// Entra ID authentication requires a custom subdomain, which is the resource name every endpoint uses.
|
||||
// https://learn.microsoft.com/azure/ai-services/cognitive-services-custom-subdomains
|
||||
const resourceListQuery = [
|
||||
"resources",
|
||||
"| where type =~ 'microsoft.cognitiveservices/accounts' and kind in~ ('AIServices', 'OpenAI')",
|
||||
"| extend resourceName = tostring(properties.customSubDomainName)",
|
||||
"| where isnotempty(resourceName)",
|
||||
"| project resourceName, resourceGroup, location",
|
||||
"| order by resourceName asc",
|
||||
].join(" ")
|
||||
|
||||
function resourceQuery(resource: string) {
|
||||
return [
|
||||
"resources",
|
||||
|
||||
@@ -64,13 +64,13 @@ type AzureRequest = {
|
||||
type Route = (request: AzureRequest) => Response | Promise<Response>
|
||||
|
||||
// Answers like Azure: the resource's own deployment list, Resource Graph, and the management API.
|
||||
const fakeAzure = (routes: { resource?: Route; management?: Route; resources?: () => unknown[] }) =>
|
||||
const fakeAzure = (routes: { resource?: Route; management?: Route }) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
const requests: AzureRequest[] = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: async (raw) => {
|
||||
fetch: (raw) => {
|
||||
const url = new URL(raw.url)
|
||||
const request = {
|
||||
method: raw.method,
|
||||
@@ -81,12 +81,8 @@ const fakeAzure = (routes: { resource?: Route; management?: Route; resources?: (
|
||||
}
|
||||
requests.push(request)
|
||||
if (request.path.startsWith("/openai/deployments")) return routes.resource?.(request) ?? notFound()
|
||||
if (request.path.startsWith("/providers/Microsoft.ResourceGraph/")) {
|
||||
// The connect form lists every resource; discovery looks up one resource's ID.
|
||||
if ((await raw.text()).includes("project resourceName"))
|
||||
return routes.resources ? Response.json({ data: routes.resources() }) : unauthorized()
|
||||
if (request.path.startsWith("/providers/Microsoft.ResourceGraph/"))
|
||||
return Response.json({ data: [{ id: resourceID }] })
|
||||
}
|
||||
return routes.management?.(request) ?? notFound()
|
||||
},
|
||||
})
|
||||
@@ -125,7 +121,8 @@ const fakeAzureCli = Effect.fn(function* (respond: (args: readonly string[]) =>
|
||||
const executable = `${directory}/${windows ? "az.cmd" : "az"}`
|
||||
yield* Effect.promise(() => Bun.write(executable, windows ? "@exit /b 0\r\n" : "#!/bin/sh\nexit 0\n"))
|
||||
yield* Effect.promise(() => chmod(executable, 0o755))
|
||||
// The CLI writes its profile with a byte order mark.
|
||||
// A signed-in profile, so tests show that startup leaves Azure alone even when the CLI could reach it. The CLI
|
||||
// writes it with a byte order mark.
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(`${directory}/azure/azureProfile.json`, `\uFEFF${JSON.stringify({ subscriptions: [{ id: "sub" }] })}`),
|
||||
)
|
||||
@@ -245,31 +242,21 @@ describe("AzurePlugin connecting", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("offers the resources the Azure CLI can reach and accepts a typed resource name", () =>
|
||||
it.live("asks for the resource name as text without calling Azure", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setEnv(noResourceEnv)
|
||||
const cli = yield* fakeAzureCli()
|
||||
const azure = yield* fakeAzure({
|
||||
resources: () => [
|
||||
{ resourceName: "alpha", resourceGroup: "models", location: "eastus" },
|
||||
{ resourceName: "not a hostname", resourceGroup: "models", location: "eastus" },
|
||||
{ id: 42 },
|
||||
],
|
||||
})
|
||||
const azure = yield* fakeAzure({})
|
||||
yield* addPlugin(azure.endpoints).pipe(cli.provide)
|
||||
const integrations = yield* Integration.Service
|
||||
const field = Effect.gen(function* () {
|
||||
const method = required(yield* integrations.get(azureID)).methods.find((method) => method.type === "external")
|
||||
const field = method?.type === "external" ? method.form?.[0] : undefined
|
||||
return field?.type === "string" ? field : undefined
|
||||
})
|
||||
const method = required(yield* integrations.get(azureID)).methods.find((method) => method.type === "external")
|
||||
const field = method?.type === "external" ? method.form?.[0] : undefined
|
||||
expect(field).toMatchObject({ type: "string", key: "resourceName" })
|
||||
expect(field?.type === "string" ? field.options : undefined).toBeUndefined()
|
||||
|
||||
const listed = required(yield* eventually(field, (field) => (field?.options?.length ?? 0) > 0))
|
||||
expect(listed).toMatchObject({
|
||||
key: "resourceName",
|
||||
custom: true,
|
||||
options: [{ value: "alpha", label: "alpha", description: "models · eastus" }],
|
||||
})
|
||||
yield* settle
|
||||
expect(cli.commands).toEqual([])
|
||||
expect(azure.requests).toEqual([])
|
||||
|
||||
const external = (resourceName: string) =>
|
||||
integrations.connection.external({
|
||||
@@ -290,24 +277,6 @@ describe("AzurePlugin connecting", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("asks for the resource name as text when the Azure CLI lists no resources", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setEnv(noResourceEnv)
|
||||
const cli = yield* fakeAzureCli()
|
||||
const azure = yield* fakeAzure({ resources: () => [] })
|
||||
yield* addPlugin(azure.endpoints).pipe(cli.provide)
|
||||
const integrations = yield* Integration.Service
|
||||
const field = Effect.gen(function* () {
|
||||
const method = required(yield* integrations.get(azureID)).methods.find((method) => method.type === "external")
|
||||
const field = method?.type === "external" ? method.form?.[0] : undefined
|
||||
return field?.type === "string" ? field : undefined
|
||||
})
|
||||
|
||||
const listed = required(yield* eventually(field, (field) => field?.description?.startsWith("No ") === true))
|
||||
expect(listed.options).toBeUndefined()
|
||||
expect(listed.custom).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("AzurePlugin startup", () => {
|
||||
|
||||
Reference in New Issue
Block a user