diff --git a/src/commands/aksCreateCluster/aksCreateCluster.ts b/src/commands/aksCreateCluster/aksCreateCluster.ts index de310791..8448ad85 100644 --- a/src/commands/aksCreateCluster/aksCreateCluster.ts +++ b/src/commands/aksCreateCluster/aksCreateCluster.ts @@ -1,11 +1,13 @@ import { IActionContext } from "@microsoft/vscode-azext-utils"; -import { getAksClusterSubscriptionNode } from "../utils/clusters"; -import { failed } from "../utils/errorable"; import * as vscode from "vscode"; import * as k8s from "vscode-kubernetes-tools-api"; -import { getExtension } from "../utils/host"; -import { CreateClusterDataProvider, CreateClusterPanel } from "../../panels/CreateClusterPanel"; import { getReadySessionProvider } from "../../auth/azureAuth"; +import { CreateClusterDataProvider, CreateClusterPanel } from "../../panels/CreateClusterPanel"; +import { getAksClusterSubscriptionNode } from "../utils/clusters"; +import { Errorable, failed } from "../utils/errorable"; +import { getExtension } from "../utils/host"; +import { getSubscription } from "../utils/subscriptions"; +import { SubscriptionTreeNode } from "../../tree/subscriptionTreeItem"; /** * A multi-step input using window.createQuickPick() and window.createInputBox(). @@ -13,6 +15,9 @@ import { getReadySessionProvider } from "../../auth/azureAuth"; * This first part uses the helper class `MultiStepInput` that wraps the API for the multi-step case. */ export default async function aksCreateCluster(_context: IActionContext, target: unknown): Promise { + let subscriptionNode: Errorable; + let subscriptionId: string | undefined; + let subscriptionName: string | undefined; const cloudExplorer = await k8s.extension.cloudExplorer.v1; const sessionProvider = await getReadySessionProvider(); @@ -21,10 +26,33 @@ export default async function aksCreateCluster(_context: IActionContext, target: return; } - const subscriptionNode = getAksClusterSubscriptionNode(target, cloudExplorer); - if (failed(subscriptionNode)) { - vscode.window.showErrorMessage(subscriptionNode.error); - return; + switch (typeof target) { + case "string": { + const subscriptionResult = await getSubscription(sessionProvider.result, target); + if (failed(subscriptionResult)) { + vscode.window.showErrorMessage(subscriptionResult.error); + return; + } + subscriptionId = subscriptionResult.result?.id; + subscriptionName = subscriptionResult.result?.displayName; + break; + } + + default: { + subscriptionNode = getAksClusterSubscriptionNode(target, cloudExplorer); + if (failed(subscriptionNode)) { + vscode.window.showErrorMessage(subscriptionNode.error); + return; + } + if (!subscriptionNode.result || !subscriptionNode.result.subscriptionId || !subscriptionNode.result.name) { + vscode.window.showErrorMessage("Subscription not found."); + return; + } + subscriptionId = subscriptionNode.result?.subscriptionId; + subscriptionName = subscriptionNode.result?.name; + break; + } + } const extension = getExtension(); @@ -35,11 +63,13 @@ export default async function aksCreateCluster(_context: IActionContext, target: const panel = new CreateClusterPanel(extension.result.extensionUri); - const dataProvider = new CreateClusterDataProvider( - sessionProvider.result, - subscriptionNode.result.subscriptionId, - subscriptionNode.result.name, - () => vscode.commands.executeCommand("aks.refreshSubscription", target), + if (!subscriptionId || !subscriptionName) { + vscode.window.showErrorMessage("Subscription ID or Name is undefined."); + return; + } + + const dataProvider = new CreateClusterDataProvider(sessionProvider.result, subscriptionId, subscriptionName, () => + vscode.commands.executeCommand("aks.refreshSubscription", target), ); panel.show(dataProvider); diff --git a/src/commands/aksCreateCluster/aksCreateClusterFromCopilot.ts b/src/commands/aksCreateCluster/aksCreateClusterFromCopilot.ts new file mode 100644 index 00000000..342f8324 --- /dev/null +++ b/src/commands/aksCreateCluster/aksCreateClusterFromCopilot.ts @@ -0,0 +1,6 @@ +import { IActionContext } from "@microsoft/vscode-azext-utils"; +import * as vscode from "vscode"; + +export async function aksCreateClusterFromCopilot(_context: IActionContext, subscriptionId: string): Promise { + vscode.commands.executeCommand("aks.createCluster", subscriptionId); +} diff --git a/src/commands/utils/featureRegistrations.ts b/src/commands/utils/featureRegistrations.ts new file mode 100644 index 00000000..dba30cdb --- /dev/null +++ b/src/commands/utils/featureRegistrations.ts @@ -0,0 +1,85 @@ +import { FeatureClient } from "@azure/arm-features"; +import { longRunning } from "./host"; + +const MAX_RETRIES = 5; +const RETRY_DELAY_MS = 60000; // 1 minute + +export enum FeatureRegistrationState { + Registered = "Registered", + Registering = "Registering", + Failed = "Failed", +} + +export type MultipleFeatureRegistration = { + resourceProviderNamespace: string; + featureName: string; +}; + +export type FeatureRegistrationResult = { + resourceProviderNamespace: string; + featureName: string; + registrationStatus: string; +}; + +export async function createFeatureRegistrationsWithRetry( + featureClient: FeatureClient, + resourceProviderNamespace: string, + featureName: string, +): Promise { + let retries = 0; + //register the feature + try { + await featureClient.features.register(resourceProviderNamespace, featureName); + } catch (error) { + throw new Error( + `Failed to initiate registration for feature ${featureName} in ${resourceProviderNamespace}: ${error}`, + ); + } + while (retries < MAX_RETRIES) { + // get the registration status + const featureRegistrationResult = await featureClient.features.get(resourceProviderNamespace, featureName); + const result = featureRegistrationResult?.properties?.state ?? FeatureRegistrationState.Failed; + switch (result) { + case FeatureRegistrationState.Registered: + console.log(`Feature ${featureName} registered successfully for ${resourceProviderNamespace}.`); + return; + + case FeatureRegistrationState.Registering: + retries++; + console.log(`Feature ${featureName} is still registering. Retry ${retries}/${MAX_RETRIES}.`); + await delay(RETRY_DELAY_MS); + break; + + default: + throw new Error( + `Failed to register the preview feature ${featureName} for ${resourceProviderNamespace}. Current state: ${result}, please try again.`, + ); + } + } +} + +async function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function createMultipleFeatureRegistrations( + featureClient: FeatureClient, + featureRegistrations: MultipleFeatureRegistration[], +): Promise { + await longRunning(`Registering the preview features.`, async () => { + try { + const featureRegistrationResults = featureRegistrations.map(async (featureRegistration) => { + return createFeatureRegistrationsWithRetry( + featureClient, + featureRegistration.resourceProviderNamespace, + featureRegistration.featureName, + ); + }); + await Promise.all(featureRegistrationResults); + console.log("All features registered successfully."); + } catch (error) { + console.error("Error registering features:", error); + throw error; + } + }); +} diff --git a/src/commands/utils/subscriptions.ts b/src/commands/utils/subscriptions.ts index a0b482ee..b79da899 100644 --- a/src/commands/utils/subscriptions.ts +++ b/src/commands/utils/subscriptions.ts @@ -1,8 +1,8 @@ -import { Subscription } from "@azure/arm-resources-subscriptions"; +import { Subscription, SubscriptionsGetResponse } from "@azure/arm-resources-subscriptions"; +import { ReadyAzureSessionProvider } from "../../auth/types"; import { getSubscriptionClient, listAll } from "./arm"; -import { Errorable, map as errmap } from "./errorable"; import { getFilteredSubscriptions } from "./config"; -import { ReadyAzureSessionProvider } from "../../auth/types"; +import { Errorable, map as errmap } from "./errorable"; export enum SelectionType { Filtered, @@ -41,3 +41,19 @@ function sortAndFilter(subscriptions: DefinedSubscription[], selectionType: Sele function isDefinedSubscription(sub: Subscription): sub is DefinedSubscription { return sub.subscriptionId !== undefined && sub.displayName !== undefined; } + +function isDefinedSubscriptionGetResponse(sub: SubscriptionsGetResponse): sub is DefinedSubscription { + return sub.subscriptionId !== undefined && sub.displayName !== undefined; +} + +export async function getSubscription( + sessionProvider: ReadyAzureSessionProvider, + subscriptionId: string, +): Promise> { + const client = getSubscriptionClient(sessionProvider); + const subResult: SubscriptionsGetResponse = await client.subscriptions.get(subscriptionId); + if (!isDefinedSubscriptionGetResponse(subResult)) { + return { succeeded: false, error: "Subscription is not found" }; + } + return { succeeded: true, result: subResult }; +} diff --git a/src/panels/CreateClusterPanel.ts b/src/panels/CreateClusterPanel.ts index 374342dc..91f2de7c 100644 --- a/src/panels/CreateClusterPanel.ts +++ b/src/panels/CreateClusterPanel.ts @@ -1,17 +1,22 @@ import { ContainerServiceClient, KubernetesVersion } from "@azure/arm-containerservice"; +import { FeatureClient } from "@azure/arm-features"; import { ResourceGroup as ARMResourceGroup, ResourceManagementClient } from "@azure/arm-resources"; import { RestError } from "@azure/storage-blob"; import { Uri, window } from "vscode"; import { getEnvironment } from "../auth/azureAuth"; import { ReadyAzureSessionProvider } from "../auth/types"; -import { getAksClient, getResourceManagementClient } from "../commands/utils/arm"; +import { getAksClient, getFeatureClient, getResourceManagementClient } from "../commands/utils/arm"; import { getPortalResourceUrl } from "../commands/utils/env"; import { failed, getErrorMessage } from "../commands/utils/errorable"; +import { + createMultipleFeatureRegistrations, + MultipleFeatureRegistration, +} from "../commands/utils/featureRegistrations"; import { getResourceGroups } from "../commands/utils/resourceGroups"; import { MessageHandler, MessageSink } from "../webview-contract/messaging"; import { InitialState, - Preset, + PresetType, ProgressEventType, ToVsCodeMsgDef, ToWebViewMsgDef, @@ -34,6 +39,7 @@ export class CreateClusterPanel extends BasePanel<"createCluster"> { export class CreateClusterDataProvider implements PanelDataProvider<"createCluster"> { private readonly resourceManagementClient: ResourceManagementClient; private readonly containerServiceClient: ContainerServiceClient; + private readonly featureClient: FeatureClient; public constructor( readonly sessionProvider: ReadyAzureSessionProvider, @@ -43,6 +49,7 @@ export class CreateClusterDataProvider implements PanelDataProvider<"createClust ) { this.resourceManagementClient = getResourceManagementClient(sessionProvider, this.subscriptionId); this.containerServiceClient = getAksClient(sessionProvider, this.subscriptionId); + this.featureClient = getFeatureClient(sessionProvider, this.subscriptionId); } getTitle(): string { @@ -129,7 +136,7 @@ export class CreateClusterDataProvider implements PanelDataProvider<"createClust groupName: string, location: string, name: string, - preset: Preset, + preset: PresetType, webview: MessageSink, ) { if (isNewResourceGroup) { @@ -150,6 +157,7 @@ export class CreateClusterDataProvider implements PanelDataProvider<"createClust webview, this.containerServiceClient, this.resourceManagementClient, + this.featureClient, ); this.refreshTree(); @@ -196,10 +204,11 @@ async function createCluster( groupName: string, location: string, name: string, - preset: Preset, + preset: PresetType, webview: MessageSink, containerServiceClient: ContainerServiceClient, resourceManagementClient: ResourceManagementClient, + featureClient: FeatureClient, ) { const operationDescription = `Creating cluster ${name}`; webview.postProgressUpdate({ @@ -252,12 +261,27 @@ async function createCluster( // Create a unique deployment name. const deploymentName = `${name}-${Math.random().toString(36).substring(5)}`; const deploymentSpec = new ClusterDeploymentBuilder() - .buildCommonParameters(clusterSpec) + .buildCommonParameters(clusterSpec, preset) .buildTemplate(preset) .getDeployment(); const environment = getEnvironment(); + // feature registration + try { + await doFeatureRegistration(preset, featureClient); + } catch (error) { + window.showErrorMessage(`Error Registering preview features for AKS cluster ${name}: ${error}`); + webview.postProgressUpdate({ + event: ProgressEventType.Failed, + operationDescription: "Error Registering preview features for AKS cluster", + errorMessage: getErrorMessage(error), + deploymentPortalUrl: null, + createdCluster: null, + }); + return; + } + try { const poller = await resourceManagementClient.deployments.beginCreateOrUpdate( groupName, @@ -323,6 +347,41 @@ async function createCluster( } } +async function doFeatureRegistration(preset: PresetType, featureClient: FeatureClient) { + if (preset !== PresetType.Automatic) { + return; + } + //Doc link - https://learn.microsoft.com/en-us/azure/aks/learn/quick-kubernetes-automatic-deploy?pivots=azure-cli#register-the-feature-flags + const features: MultipleFeatureRegistration[] = [ + { + resourceProviderNamespace: "Microsoft.ContainerService", + featureName: "EnableAPIServerVnetIntegrationPreview", + }, + { + resourceProviderNamespace: "Microsoft.ContainerService", + featureName: "NRGLockdownPreview", + }, + { + resourceProviderNamespace: "Microsoft.ContainerService", + featureName: "SafeguardsPreview", + }, + { + resourceProviderNamespace: "Microsoft.ContainerService", + featureName: "NodeAutoProvisioningPreview", + }, + { + resourceProviderNamespace: "Microsoft.ContainerService", + featureName: "DisableSSHPreview", + }, + { + resourceProviderNamespace: "Microsoft.ContainerService", + featureName: "AutomaticSKUPreview", + }, + ]; + + await createMultipleFeatureRegistrations(featureClient, features); +} + function getInvalidTemplateErrorMessage(ex: InvalidTemplateDeploymentRestError): string { const innerDetails = ex.details.error?.details || []; if (innerDetails.length > 0) { diff --git a/src/panels/templates/AutomaticCreateCluster.json b/src/panels/templates/AutomaticCreateCluster.json new file mode 100644 index 00000000..2968ec87 --- /dev/null +++ b/src/panels/templates/AutomaticCreateCluster.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "apiVersion": { + "type": "string" + }, + "resourceName": { + "type": "string", + "metadata": { + "description": "The name of the Managed Cluster resource." + } + }, + "location": { + "type": "string", + "metadata": { + "description": "The location of AKS resource." + } + }, + "clusterSku": { + "defaultValue": { + "name": "Automatic", + "tier": "Standard" + }, + "type": "object", + "metadata": { + "descirption": "The managed cluster SKU tier." + } + }, + "clusterIdentity": { + "defaultValue": { + "type": "SystemAssigned" + }, + "type": "object", + "metadata": { + "description": "The identity of the managed cluster, if configured." + } + } + }, + "resources": [ + { + "type": "Microsoft.ContainerService/managedClusters", + "apiVersion": "[parameters('apiVersion')]", + "sku": "[parameters('clusterSku')]", + "location": "[parameters('location')]", + "name": "[parameters('resourceName')]", + "properties": { + "agentPoolProfiles": [ + { + "name": "systempool", + "mode": "System", + "vmSize": "Standard_DS4_v2", + "count": 3, + "osType": "Linux" + } + ] + }, + "identity": "[parameters('clusterIdentity')]" + } + ] +} \ No newline at end of file diff --git a/src/panels/utilities/ClusterSpecCreationBuilder.ts b/src/panels/utilities/ClusterSpecCreationBuilder.ts index 7cedb0e9..7d655627 100644 --- a/src/panels/utilities/ClusterSpecCreationBuilder.ts +++ b/src/panels/utilities/ClusterSpecCreationBuilder.ts @@ -1,5 +1,6 @@ import { Deployment } from "@azure/arm-resources"; -import { Preset } from "../../webview-contract/webviewDefinitions/createCluster"; +import { PresetType } from "../../webview-contract/webviewDefinitions/createCluster"; +import automaticTemplate from "../templates/AutomaticCreateCluster.json"; import devTestTemplate from "../templates/DevTestCreateCluster.json"; export type ClusterSpec = { @@ -14,8 +15,10 @@ export type ClusterSpec = { type TemplateContent = Record; const deploymentApiVersion = "2023-08-01"; -const presetTemplates: Record = { - dev: devTestTemplate, +const deploymentApiVersionPreview = "2024-03-02-preview"; +const presetTemplates: Record = { + [PresetType.Automatic]: automaticTemplate, + [PresetType.Dev]: devTestTemplate, }; export class ClusterDeploymentBuilder { @@ -27,7 +30,41 @@ export class ClusterDeploymentBuilder { }, }; - public buildCommonParameters(clusterSpec: ClusterSpec): ClusterDeploymentBuilder { + public buildCommonParameters(clusterSpec: ClusterSpec, preset: PresetType): ClusterDeploymentBuilder { + return preset === PresetType.Automatic + ? this.buildParametersForAutomatic(clusterSpec) + : this.buildParametersForDev(clusterSpec); + } + + public buildParametersForAutomatic(clusterSpec: ClusterSpec): ClusterDeploymentBuilder { + this.deployment.properties.parameters = { + ...this.deployment.properties.parameters, + location: { + value: clusterSpec.location, + }, + resourceName: { + value: clusterSpec.name, + }, + apiVersion: { + value: deploymentApiVersionPreview, + }, + clusterIdentity: { + value: { + type: "SystemAssigned", + }, + }, + clusterSku: { + value: { + name: "Automatic", + tier: "Standard", + }, + }, + }; + + return this; + } + + public buildParametersForDev(clusterSpec: ClusterSpec): ClusterDeploymentBuilder { this.deployment.properties.parameters = { ...this.deployment.properties.parameters, location: { @@ -67,7 +104,7 @@ export class ClusterDeploymentBuilder { return this; } - public buildTemplate(preset: Preset) { + public buildTemplate(preset: PresetType) { this.deployment.properties.template = presetTemplates[preset]; return this; } diff --git a/src/webview-contract/webviewDefinitions/createCluster.ts b/src/webview-contract/webviewDefinitions/createCluster.ts index 73c471a7..c5db4985 100644 --- a/src/webview-contract/webviewDefinitions/createCluster.ts +++ b/src/webview-contract/webviewDefinitions/createCluster.ts @@ -26,11 +26,13 @@ export interface CreateClusterParams { resourceGroupName: string; location: string; name: string; - preset: Preset; + preset: PresetType; } -// NOTE: This is intented to be a union of Preset strings, but for now we only have one. -export type Preset = "dev"; +export enum PresetType { + Dev, + Automatic +} export type ToVsCodeMsgDef = { getLocationsRequest: void; diff --git a/webview-ui/src/CreateCluster/CreateCluster.module.css b/webview-ui/src/CreateCluster/CreateCluster.module.css index ba8994d2..20960721 100644 --- a/webview-ui/src/CreateCluster/CreateCluster.module.css +++ b/webview-ui/src/CreateCluster/CreateCluster.module.css @@ -16,6 +16,18 @@ height: 10.375rem; margin-bottom: 1rem; width: 13.5rem; + margin-right: 3rem; +} + +.presetContainerHighlighted { + border: 0.1875rem solid var(--Accent-Blue-05, #0097fb); + background: var(--vscode-editor-background); + border-radius: 0.5rem; + height: 10.375rem; + margin-bottom: 1rem; + width: 13.5rem; + margin-right: 3rem; + border-color: var(--vscode-button-background); } .presetTitle { diff --git a/webview-ui/src/CreateCluster/CreateCluster.tsx b/webview-ui/src/CreateCluster/CreateCluster.tsx index 0f1ed29b..0c2fa4d2 100644 --- a/webview-ui/src/CreateCluster/CreateCluster.tsx +++ b/webview-ui/src/CreateCluster/CreateCluster.tsx @@ -71,7 +71,7 @@ export function CreateCluster(initialState: InitialState) { return ( <> -

Create Cluster

+

Create AKS Cluster

{getBody()} diff --git a/webview-ui/src/CreateCluster/CreateClusterInput.tsx b/webview-ui/src/CreateCluster/CreateClusterInput.tsx index 30ed9198..f5d232e1 100644 --- a/webview-ui/src/CreateCluster/CreateClusterInput.tsx +++ b/webview-ui/src/CreateCluster/CreateClusterInput.tsx @@ -5,7 +5,7 @@ import { FormEvent, useState } from "react"; import { MessageSink } from "../../../src/webview-contract/messaging"; import { CreateClusterParams, - Preset, + PresetType, ResourceGroup, ToVsCodeMsgDef, } from "../../../src/webview-contract/webviewDefinitions/createCluster"; @@ -31,7 +31,7 @@ export function CreateClusterInput(props: CreateClusterInputProps) { const [name, setName] = useState>(unset()); const [isNewResourceGroupDialogShown, setIsNewResourceGroupDialogShown] = useState(false); const [newResourceGroupName, setNewResourceGroupName] = useState(null); - const [presetSelected, setPresetSelected] = useState("dev"); + const [presetSelected, setPresetSelected] = useState(PresetType.Automatic); const [selectedIndex, setSelectedIndex] = useState(0); const [location, setLocation] = useState>(unset()); @@ -54,7 +54,7 @@ export function CreateClusterInput(props: CreateClusterInputProps) { setSelectedIndex(1); // this is the index of the new resource group and the first option is "Select" } - function handlePresetSelection(presetSelected: Preset) { + function handlePresetSelection(presetSelected: PresetType) { setPresetSelected(presetSelected); } @@ -192,7 +192,7 @@ export function CreateClusterInput(props: CreateClusterInputProps) { )} void; + onPresetSelected: (presetSelected: PresetType) => void; } export function CreateClusterPresetInput(props: CreateClusterPresetInputProps) { - function handlePresetClick(presetSelected: Preset) { + const [selectedPreset, setSelectedPreset] = useState(PresetType.Automatic); + + useEffect(() => { + handlePresetClick(PresetType.Automatic); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + function handlePresetClick(presetSelected: PresetType) { console.log(presetSelected); props.onPresetSelected(presetSelected); + setSelectedPreset(presetSelected); } return ( @@ -19,21 +29,43 @@ export function CreateClusterPresetInput(props: CreateClusterPresetInputProps) {

Cluster preset configuration

- If you wish to create a more complex Azure Kubernetes Service (AKS) cluster, please  - click here -  to visit the Azure Portal. + For more customized Azure Kubernetes Service (AKS) cluster setup, visit the Azure Portal by  + clicking here.
-
handlePresetClick("dev")}> -
- -
Dev/Test
+
+
handlePresetClick(PresetType.Automatic)} + > +
+ +
Automatic
+
+
+ Fully Azure-managed with simplified setup for more streamlined app deployment. + +
-
- Best for developing new workloads or testing existing workloads. -
- - Learn more - + +
handlePresetClick(PresetType.Dev)} + > +
+ +
Dev/Test
+
+
+ Best for developing new workloads or testing existing workloads. +
diff --git a/webview-ui/src/icons/AutomaticIcon.tsx b/webview-ui/src/icons/AutomaticIcon.tsx new file mode 100644 index 00000000..ac4c00fb --- /dev/null +++ b/webview-ui/src/icons/AutomaticIcon.tsx @@ -0,0 +1,95 @@ +import { SvgProps } from "./svgProps"; + +export function AutomaticIcon(props: SvgProps) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}