Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create Fleet: Full Cycle Completed #1179

Merged
merged 3 commits into from
Jan 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading