Skip to content

Commit

Permalink
Create Fleet: Full Cycle Completed (#1179)
Browse files Browse the repository at this point in the history
  • Loading branch information
JunyuQian authored Jan 21, 2025
1 parent 18d1ce8 commit 744b980
Show file tree
Hide file tree
Showing 6 changed files with 406 additions and 38 deletions.
37 changes: 12 additions & 25 deletions src/commands/aksFleet/aksFleetManager.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { IActionContext } from "@microsoft/vscode-azext-utils";
import * as vscode from "vscode";
import * as k8s from "vscode-kubernetes-tools-api";
import { getCredential, getReadySessionProvider } from "../../auth/azureAuth";
import { getReadySessionProvider } from "../../auth/azureAuth";
import { getAksClusterSubscriptionNode } from "../utils/clusters";
import { failed } from "../utils/errorable";
import { getResourceGroups } from "../utils/resourceGroups";
import { createFleet } from "../../panels/CreateFleetPanel";
import { ContainerServiceFleetClient } from "@azure/arm-containerservicefleet";
import { CreateFleetDataProvider, CreateFleetPanel } from "../../panels/CreateFleetPanel";
import { getExtension } from "../utils/host";

export default async function aksCreateFleet(_context: IActionContext, target: unknown): Promise<void> {
const cloudExplorer = await k8s.extension.cloudExplorer.v1;
Expand Down Expand Up @@ -38,26 +38,13 @@ export default async function aksCreateFleet(_context: IActionContext, target: u
return;
}

// Temporary code for incremental check-in.
// TODO: Replace hardcoded values with dynamic parameters or configuration settings.

// Initialize the ContainerServiceFleetClient with session credentials and subscription ID.
// Hardcoded 'subscriptionId' should be parameterized in future updates.
const client = new ContainerServiceFleetClient(
getCredential(sessionProvider.result), // Retrieve credentials from session provider.
subscriptionId, // TODO: Ensure subscriptionId is dynamically passed or configured.
);

// Create a fleet using hardcoded parameters.
// TODO: Replace hardcoded 'Fleet-Resource-Name', 'Fleet-Name', and 'Australia East' with configurable inputs.
createFleet(
client,
"Fleet-Resource-Name", // Fleet resource group name (hardcoded).
"Fleet-Name", // Fleet name (hardcoded).
{ location: "Australia East" }, // Location (hardcoded).
);

// NOTE: This temporary implementation assumes static context for testing purposes.
// Ensure these hardcoded values are replaced with appropriate dynamic configurations
// before finalizing this code for production level work which will be user focused.
const extension = getExtension();
if (failed(extension)) {
vscode.window.showErrorMessage(extension.error);
return;
}

const panel = new CreateFleetPanel(extension.result.extensionUri);
const dataProvider = new CreateFleetDataProvider(sessionProvider.result, subscriptionId, subscriptionName);
panel.show(dataProvider);
}
48 changes: 42 additions & 6 deletions src/panels/CreateFleetPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { ReadyAzureSessionProvider } from "../auth/types";
import { getAksFleetClient, getResourceManagementClient } from "../commands/utils/arm";
import { getResourceGroups } from "../commands/utils/resourceGroups";
import { failed } from "../commands/utils/errorable";
import { getEnvironment } from "../auth/azureAuth";
import { getDeploymentPortalUrl, getPortalResourceUrl } from "../commands/utils/env";

export class CreateFleetPanel extends BasePanel<"createFleet"> {
constructor(extensionUri: Uri) {
Expand Down Expand Up @@ -62,7 +64,7 @@ export class CreateFleetDataProvider implements PanelDataProvider<"createFleet">
getLocationsRequest: () => this.handleGetLocationsRequest(webview),
getResourceGroupsRequest: () => this.handleGetResourceGroupsRequest(webview),
createFleetRequest: (args) =>
this.handleCreateFleetRequest(args.resourceGroupName, args.location, args.name),
this.handleCreateFleetRequest(args.resourceGroupName, args.location, args.name, webview),
};
}

Expand Down Expand Up @@ -109,25 +111,59 @@ export class CreateFleetDataProvider implements PanelDataProvider<"createFleet">
webview.postGetResourceGroupsResponse({ groups: usableGroups });
}

private async handleCreateFleetRequest(resourceGroupName: string, location: string, name: string) {
private async handleCreateFleetRequest(
resourceGroupName: string,
location: string,
name: string,
webview: MessageSink<ToWebViewMsgDef>,
) {
const resource = {
location: location,
};

await createFleet(this.fleetClient, resourceGroupName, name, resource);
await createFleet(this.fleetClient, resourceGroupName, name, resource, webview);
}
}

export async function createFleet(
async function createFleet(
client: ContainerServiceFleetClient,
resourceGroupName: string,
name: string,
resource: Fleet,
webview: MessageSink<ToWebViewMsgDef>,
) {
const operationDescription = `Creating fleet ${name}`;
webview.postProgressUpdate({
event: ProgressEventType.InProgress,
operationDescription,
errorMessage: null,
deploymentPortalUrl: null,
createdFleet: null,
});

const environment = getEnvironment();
try {
const result = await client.fleets.beginCreateOrUpdateAndWait(resourceGroupName, name, resource);
return { succeeded: true, result: result.name! };
if (!result.id) {
throw new Error("Fleet creation did not return an ID");
}
const deploymentPortalUrl = getDeploymentPortalUrl(environment, result.id);
webview.postProgressUpdate({
event: ProgressEventType.Success,
operationDescription,
errorMessage: null,
deploymentPortalUrl,
createdFleet: {
portalUrl: getPortalResourceUrl(environment, result.id),
},
});
} catch (error) {
return { succeeded: false, error: (error as Error).message };
webview.postProgressUpdate({
event: ProgressEventType.Failed,
operationDescription,
errorMessage: (error as Error).message,
deploymentPortalUrl: null,
createdFleet: null,
});
}
}
76 changes: 76 additions & 0 deletions webview-ui/src/CreateFleet/CreateFleet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useEffect } from "react";
import { InitialState } from "../../../src/webview-contract/webviewDefinitions/createFleet";
import { CreateFleetInput } from "./CreateFleetInput";
import { useStateManagement } from "../utilities/state";
import { Stage, stateUpdater, vscode } from "./helpers/state";
import { VSCodeLink, VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react";

export function CreateFleet(initialState: InitialState) {
const { state, eventHandlers } = useStateManagement(stateUpdater, initialState, vscode);

useEffect(() => {
if (state.stage === Stage.Uninitialized) {
vscode.postGetLocationsRequest();
vscode.postGetResourceGroupsRequest();
eventHandlers.onSetInitializing(); // Set stage to Stage.Loading
}
});

useEffect(() => {
if (state.stage === Stage.Loading && state.locations !== null && state.resourceGroups !== null) {
eventHandlers.onSetInitialized(); // Set stage to Stage.CollectingInput
}
}, [state.stage, state.locations, state.resourceGroups, eventHandlers]);

function getBody() {
// Returns JSX based on the current stage
switch (state.stage) {
case Stage.Uninitialized:
case Stage.Loading:
return <p>Loading...</p>;
case Stage.CollectingInput:
return (
<CreateFleetInput
locations={state.locations!}
resourceGroups={state.resourceGroups!}
eventHandlers={eventHandlers}
vscode={vscode}
/>
);
case Stage.Creating:
return (
<>
<h3>
Creating Fleet {state.createParams!.name} in {state.createParams!.location}
</h3>
<VSCodeProgressRing />
</>
);
case Stage.Failed:
return (
<>
<h3>Error Creating Fleet</h3>
<p>{state.message}</p>
</>
);
case Stage.Succeeded:
return (
<>
<h3>Fleet {state.createParams!.name} was created successfully</h3>
<p>
Click <VSCodeLink href={state.createdFleet?.portalUrl}>here</VSCodeLink> to view your fleet
in the Azure Portal.
</p>
</>
);
}
}

return (
<>
<h1>Create AKS Fleet Manager</h1>
<label>Subscription: {state.subscriptionName}</label>
{getBody()}
</>
);
}
183 changes: 183 additions & 0 deletions webview-ui/src/CreateFleet/CreateFleetInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import { FormEvent, useState } from "react";
import {
CreateFleetParams,
ResourceGroup,
ToVsCodeMsgDef,
} from "../../../src/webview-contract/webviewDefinitions/createFleet";
import { VSCodeButton, VSCodeDropdown, VSCodeOption, VSCodeTextField } from "@vscode/webview-ui-toolkit/react";
import { hasMessage, invalid, isValid, isValueSet, missing, unset, valid, Validatable } from "../utilities/validation";
import { MessageSink } from "../../../src/webview-contract/messaging";
import { EventDef } from "./helpers/state";
import { EventHandlers } from "../utilities/state";
import { isNothing, just, Maybe, nothing } from "../utilities/maybe";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTimesCircle } from "@fortawesome/free-solid-svg-icons";
// To ensure consistent formats and styles across features, it uses the same CSS file as CreateCluster.tsx
// TODO: considering restructuring the CSS file to be more modular and reusable
import styles from "../CreateCluster/CreateCluster.module.css";

type ChangeEvent = Event | FormEvent<HTMLElement>;

interface CreateFleetInputProps {
locations: string[];
resourceGroups: ResourceGroup[];
eventHandlers: EventHandlers<EventDef>;
vscode: MessageSink<ToVsCodeMsgDef>;
}

export function CreateFleetInput(props: CreateFleetInputProps) {
const [existingResourceGroup, setExistingResourceGroup] = useState<Validatable<ResourceGroup | null>>(unset());
const [fleetName, setFleetName] = useState<Validatable<string>>(unset());
const [selectedResourceGroupIndex, setselectedResourceGroupIndex] = useState<number>(0);
const [location, setLocation] = useState<Validatable<string>>(unset());

const allResourcesGroups = props.resourceGroups; // All available resource groups fetched from the portal

function handleValidationAndIndex(e: ChangeEvent) {
handleExistingResourceGroupChange(e);
const ele = e.currentTarget as HTMLSelectElement;
setselectedResourceGroupIndex(ele.selectedIndex);
}

function handleExistingResourceGroupChange(e: ChangeEvent) {
const elem = e.currentTarget as HTMLSelectElement;
const resourceGroup = elem.selectedIndex <= 0 ? null : allResourcesGroups[elem.selectedIndex - 1];
const validatable = resourceGroup ? valid(resourceGroup) : invalid(null, "Resource Group is required.");
setExistingResourceGroup(validatable);
}

function handleFleetNameChange(e: ChangeEvent) {
const name = (e.currentTarget as HTMLInputElement).value;
const validated = getValidatedName(name);
setFleetName(validated);
}

function getValidatedName(name: string): Validatable<string> {
// Fleet name validation rules from the Azure REST API specs
// https://github.com/Azure/azure-rest-api-specs/blob/24d856b33d49b5ac6227a51c610b7d8b0f289458/specification/containerservice/resource-manager/Microsoft.ContainerService/fleet/stable/2024-04-01/fleets.json#L193C10-L202C12
if (!name) return invalid(name, "Fleet name must be at least 1 character long.");
if (name.length > 63) return invalid(name, "Fleet name must be at most 63 characters long.");
if (!/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(name)) {
return invalid(
name,
"The only allowed characters are lowercase alphanumeric characters and '-'. The first and last character must be an alphanumeric character.",
);
}

return valid(name);
}

function handleLocationChange(e: ChangeEvent) {
const elem = e.currentTarget as HTMLSelectElement;
const location = elem.selectedIndex <= 0 ? null : props.locations[elem.selectedIndex - 1];
const validated = location ? valid(location) : missing<string>("Location is required.");
setLocation(validated);
}

function validate(): Maybe<CreateFleetParams> {
if (!isValid(location)) return nothing();
let resourceGroupName: string;
if (isValid(existingResourceGroup) && existingResourceGroup.value !== null) {
resourceGroupName = existingResourceGroup.value.name;
} else {
return nothing();
}
if (!isValid(fleetName)) return nothing();

const parameters: CreateFleetParams = {
resourceGroupName,
location: location.value,
name: fleetName.value,
};

return just(parameters);
}

function handleSubmit(event: FormEvent) {
event.preventDefault();
const parameters = validate();
if (isNothing(parameters)) return;
props.vscode.postCreateFleetRequest(parameters.value);
props.eventHandlers.onSetCreating({ parameters: parameters.value }); // Set to Stage.Creating
}

return (
<form className={styles.createForm} onSubmit={handleSubmit}>
<label className={styles.label}>Fleet Name*</label>
<VSCodeTextField
id="name-input"
value={isValueSet(fleetName) ? fleetName.value : ""}
className={`${styles.longControl} ${styles.validatable}`}
onBlur={handleFleetNameChange}
onChange={handleFleetNameChange}
/>
{hasMessage(fleetName) && (
<span className={styles.validationMessage}>
<FontAwesomeIcon className={styles.errorIndicator} icon={faTimesCircle} />
{fleetName.message}
</span>
)}
<br />

<label htmlFor="resourceGroup" className={styles.label}>
Resource Group*
</label>
<VSCodeDropdown
id="existing-resource-group-dropdown"
className={styles.longControl}
onBlur={handleValidationAndIndex}
onChange={handleValidationAndIndex}
selectedIndex={selectedResourceGroupIndex}
aria-label="Select a resource group"
>
<VSCodeOption selected value="">
Select
</VSCodeOption>
{allResourcesGroups.length > 0 ? (
allResourcesGroups.map((group) => (
<VSCodeOption key={group.name} value={group.name}>
{""} {group.name}
</VSCodeOption>
))
) : (
<VSCodeOption disabled>No resource groups available</VSCodeOption>
)}
</VSCodeDropdown>
{hasMessage(existingResourceGroup) && (
<span className={styles.validationMessage}>
<FontAwesomeIcon className={styles.errorIndicator} icon={faTimesCircle} />
{existingResourceGroup.message}
</span>
)}
<br />

<label htmlFor="location-dropdown" className={styles.label}>
Region*
</label>
<VSCodeDropdown
id="location-dropdown"
className={styles.longControl}
onBlur={handleLocationChange}
onChange={handleLocationChange}
>
<VSCodeOption value="">Select</VSCodeOption>
{props.locations.map((location) => (
<VSCodeOption key={location} value={location}>
{location}
</VSCodeOption>
))}
</VSCodeDropdown>
{hasMessage(location) && (
<span className={styles.validationMessage}>
<FontAwesomeIcon className={styles.errorIndicator} icon={faTimesCircle} />
{location.message}
</span>
)}
<br />

<div className={styles.buttonContainer}>
<VSCodeButton type="submit">Create</VSCodeButton>
</div>
</form>
);
}
Loading

0 comments on commit 744b980

Please sign in to comment.