diff --git a/desktop/src/app/environment/desktop-environment.ts b/desktop/src/app/environment/desktop-environment.ts index 9b0bb3bad..c2f9fccba 100644 --- a/desktop/src/app/environment/desktop-environment.ts +++ b/desktop/src/app/environment/desktop-environment.ts @@ -14,7 +14,7 @@ import { DefaultFormLayoutProvider } from "@azure/bonito-ui/lib/components/form" import { BrowserDependencyName, BrowserEnvironmentConfig, DefaultBrowserEnvironment } from "@azure/bonito-ui/lib/environment"; import BatchExplorerHttpClient from "@batch-flask/core/batch-explorer-http-client"; import { BatchBrowserDependencyFactories, BatchFormControlResolver } from "@batch/ui-react"; -import { LiveNodeService, LivePoolService } from "@batch/ui-service"; +import { LiveNodeService, LivePoolService, LiveSkuService } from "@batch/ui-service"; import { BatchDependencyName } from "@batch/ui-service/lib/environment"; import { DesktopLocalizer } from "app/localizer/desktop-localizer"; import { AppTranslationsLoaderService, AuthService, BatchExplorerService } from "app/services"; @@ -46,6 +46,8 @@ export function initDesktopEnvironment( new LivePoolService(), [BatchDependencyName.NodeService]: () => new LiveNodeService(), + [BatchDependencyName.SkuService]: () => + new LiveSkuService(), [DependencyName.Notifier]: () => new AlertNotifier(), // TODO: update with real notification implementation [DependencyName.ResourceGroupService]: () => diff --git a/packages/bonito-core/i18n/resources.resjson b/packages/bonito-core/i18n/resources.resjson index 9d21d9202..7a6ce9add 100644 --- a/packages/bonito-core/i18n/resources.resjson +++ b/packages/bonito-core/i18n/resources.resjson @@ -6,6 +6,7 @@ "bonito.core.close": "Close", "bonito.core.form.validation.booleanValueError": "Value must be a boolean", "bonito.core.form.validation.invalidEnumValue": "Invalid value", + "bonito.core.form.validation.minLengthError": "Value must be at least {length} characters long", "bonito.core.form.validation.numberValueError": "Value must be a number", "bonito.core.form.validation.stringListValueError": "All values must be strings", "bonito.core.form.validation.stringValueError": "Value must be a string", diff --git a/packages/bonito-core/src/__tests__/http-localizer.spec.ts b/packages/bonito-core/src/__tests__/http-localizer.spec.ts index 9cc10737e..a424cac65 100644 --- a/packages/bonito-core/src/__tests__/http-localizer.spec.ts +++ b/packages/bonito-core/src/__tests__/http-localizer.spec.ts @@ -4,9 +4,25 @@ describe("HttpLocalizer", () => { let httpLocalizer: HttpLocalizer; let fetchMock: jest.Mock; - const testTranslations = { hello: "world" }; + const testTranslations = { + hello: "world", + parameterized: "Hello, {name}", + }; const frenchTranslations = { bonjour: "monde" }; + const setUpTranslations = ( + translations: { [key: string]: string } = testTranslations, + locale = "en-US" + ) => { + fetchMock.mockImplementationOnce(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve(translations), + }) + ); + jest.spyOn(httpLocalizer, "getLocale").mockReturnValue(locale); + }; + beforeEach(() => { httpLocalizer = new HttpLocalizer(); fetchMock = jest.fn(); @@ -14,29 +30,16 @@ describe("HttpLocalizer", () => { }); test("Load the correct translation file based on the locale", async () => { - fetchMock.mockImplementationOnce(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(testTranslations), - }) - ); + setUpTranslations(); - jest.spyOn(httpLocalizer, "getLocale").mockReturnValue("en-US"); await httpLocalizer.loadTranslations("/base/url"); expect(fetchMock).toHaveBeenCalledWith("/base/url/resources.en.json"); expect(httpLocalizer.translate("hello")).toEqual("world"); }); test("Load the correct translation file for French locale", async () => { - fetchMock.mockImplementationOnce(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(frenchTranslations), - }) - ); + setUpTranslations(frenchTranslations, "fr-FR"); - // Simulate a French locale - jest.spyOn(httpLocalizer, "getLocale").mockReturnValue("fr-FR"); await httpLocalizer.loadTranslations("/resources/i18n"); expect(fetchMock).toHaveBeenCalledWith( "/resources/i18n/resources.fr.json" @@ -45,15 +48,9 @@ describe("HttpLocalizer", () => { }); test("Default to English if locale not found", async () => { - fetchMock.mockImplementationOnce(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(testTranslations), - }) - ); - // Simulate an invalid locale - jest.spyOn(httpLocalizer, "getLocale").mockReturnValue("abc"); + setUpTranslations(testTranslations, "abc"); + await httpLocalizer.loadTranslations("/resources/i18n"); expect(fetchMock).toHaveBeenCalledWith( "/resources/i18n/resources.en.json" @@ -68,15 +65,18 @@ describe("HttpLocalizer", () => { }); test("Return original message if no translation found", async () => { - fetchMock.mockImplementationOnce(() => - Promise.resolve({ - ok: true, - json: () => Promise.resolve(testTranslations), - }) - ); + setUpTranslations(); - jest.spyOn(httpLocalizer, "getLocale").mockReturnValue("en-US"); await httpLocalizer.loadTranslations("/resources/i18n"); expect(httpLocalizer.translate("notFound")).toEqual("notFound"); }); + + test("Supports parameterized translations", async () => { + setUpTranslations(); + + await httpLocalizer.loadTranslations("/base/url"); + expect( + httpLocalizer.translate("parameterized", { name: "world" }) + ).toEqual("Hello, world"); + }); }); diff --git a/packages/bonito-core/src/__tests__/localization.spec.ts b/packages/bonito-core/src/__tests__/localization.spec.ts index aa9d59c9f..e99b44864 100644 --- a/packages/bonito-core/src/__tests__/localization.spec.ts +++ b/packages/bonito-core/src/__tests__/localization.spec.ts @@ -1,5 +1,5 @@ import { initMockEnvironment } from "../environment"; -import { translate } from "../localization/localization-util"; +import { replaceTokens, translate } from "../localization/localization-util"; import { LocalizedStrings } from "../localization/localized-strings"; describe("Localization utilities", () => { @@ -13,9 +13,65 @@ describe("Localization utilities", () => { ).toEqual("Value must be a boolean"); }); - test("Throw error if string is unknown", () => { + test("Throws error if string is unknown", () => { expect(() => translate("Invalid" as unknown as keyof LocalizedStrings) ).toThrowError("Unable to translate string Invalid"); }); + + test("Can translate parameterized strings", () => { + expect( + translate("bonito.core.form.validation.minLengthError", { + length: 5, + }) + ).toEqual("Value must be at least 5 characters long"); + }); + + describe("Token replacer", () => { + test("Can replace a single token", () => { + expect(replaceTokens("Hello {name}", { name: "World" })).toEqual( + "Hello World" + ); + }); + test("Ignores unknown tokens", () => { + expect(replaceTokens("Hello {name}", {})).toEqual("Hello {name}"); + }); + test("Can replace multiple tokens", () => { + expect( + replaceTokens("{emotion} is the {organ} {role}", { + emotion: "Fear", + organ: "mind", + role: "killer", + }) + ).toEqual("Fear is the mind killer"); + }); + test("Ignores unparametrized strings", () => { + expect(replaceTokens("Hello World", { name: "World" })).toEqual( + "Hello World" + ); + }); + test("Handles non-string tokens", () => { + expect(replaceTokens("Who is my #{num}?", { num: 5 })).toEqual( + "Who is my #5?" + ); + expect( + replaceTokens("Too good to be {state}", { state: true }) + ).toEqual("Too good to be true"); + expect( + replaceTokens("Bear {truth} witness", { truth: false }) + ).toEqual("Bear false witness"); + }); + test("Handles multiple instances of the same token", () => { + expect( + replaceTokens("{name} is the name of my {name}", { + name: "cat", + }) + ).toEqual("cat is the name of my cat"); + }); + test("Handles whitespace around tokens", () => { + expect(replaceTokens("Hello { name }", { name: "World" })).toEqual( + "Hello World" + ); + }); + }); }); diff --git a/packages/bonito-core/src/form/form.i18n.yml b/packages/bonito-core/src/form/form.i18n.yml index 2f6af13ee..9ba03d268 100644 --- a/packages/bonito-core/src/form/form.i18n.yml +++ b/packages/bonito-core/src/form/form.i18n.yml @@ -5,3 +5,4 @@ form: numberValueError: Value must be a number stringListValueError: All values must be strings stringValueError: Value must be a string + minLengthError: Value must be at least {length} characters long diff --git a/packages/bonito-core/src/localization/fake-localizer.ts b/packages/bonito-core/src/localization/fake-localizer.ts index c1482e148..1e696242d 100644 --- a/packages/bonito-core/src/localization/fake-localizer.ts +++ b/packages/bonito-core/src/localization/fake-localizer.ts @@ -1,4 +1,5 @@ -import { Localizer } from "./localizer"; +import { replaceTokens } from "./localization-util"; +import { Localizer, LocalizerTokenMap } from "./localizer"; export class FakeLocalizer implements Localizer { private locale: string; @@ -7,7 +8,7 @@ export class FakeLocalizer implements Localizer { this.locale = "en"; } - translate(message: string): string { + translate(message: string, tokens?: LocalizerTokenMap): string { // eslint-disable-next-line @typescript-eslint/no-explicit-any const value = (globalThis as unknown as any).__TEST_RESOURCE_STRINGS[ message @@ -15,6 +16,9 @@ export class FakeLocalizer implements Localizer { if (value == null) { throw new Error("Unable to translate string " + message); } + if (tokens) { + return replaceTokens(value, tokens); + } return value; } diff --git a/packages/bonito-core/src/localization/http-localizer.ts b/packages/bonito-core/src/localization/http-localizer.ts index 2ca0a4d69..8c4bedc86 100644 --- a/packages/bonito-core/src/localization/http-localizer.ts +++ b/packages/bonito-core/src/localization/http-localizer.ts @@ -1,4 +1,5 @@ -import { Localizer } from "./localizer"; +import { replaceTokens } from "./localization-util"; +import { Localizer, LocalizerTokenMap } from "./localizer"; interface Translations { [key: string]: string; @@ -85,12 +86,32 @@ export class HttpLocalizer implements Localizer { return await response.json(); } - translate(message: string): string { + /** + * Translates a message using loaded translations and optional tokens. + * + * @param {string} message - The message to be translated. + * @param {LocalizerTokenMap} [tokens] - An optional map of tokens to replace in the translation. + * @returns {string} The translated message, or the original message if no translation was found. + * @throws {Error} If the translations have not been loaded. + * + * @example + * ```yaml + * foo: My name is {name} + * ``` + * + * ```typescript + * translate("foo", { name: "Lucy Barton" }) // "My name is Lucy Barton" + * ``` + */ + translate(message: string, tokens?: LocalizerTokenMap): string { if (!this.translations) { throw new Error("Translation strings are not loaded " + message); } const translation = this.translations[message]; if (translation != null) { + if (tokens) { + return replaceTokens(translation, tokens); + } return translation; } else { return message; diff --git a/packages/bonito-core/src/localization/localization-util.ts b/packages/bonito-core/src/localization/localization-util.ts index 52a586559..668171d22 100644 --- a/packages/bonito-core/src/localization/localization-util.ts +++ b/packages/bonito-core/src/localization/localization-util.ts @@ -1,15 +1,23 @@ import { getEnvironment } from "../environment"; import { LocalizedStrings } from "./localized-strings"; -import { Localizer } from "./localizer"; +import { Localizer, LocalizerTokenMap } from "./localizer"; export function translate( - message: Extract + message: Extract, + tokens?: LocalizerTokenMap ): string { return getLocalizer().translate( - message as unknown as keyof LocalizedStrings + message as unknown as keyof LocalizedStrings, + tokens ); } export function getLocalizer(): Localizer { return getEnvironment().getLocalizer(); } + +export function replaceTokens(text: string, tokens: LocalizerTokenMap): string { + return text.replace(/{\s*([\w.]+)\s*}/g, (match, token) => + token in tokens ? String(tokens[token]) : match + ); +} diff --git a/packages/bonito-core/src/localization/localizer.ts b/packages/bonito-core/src/localization/localizer.ts index bdb98027f..46d2d83ea 100644 --- a/packages/bonito-core/src/localization/localizer.ts +++ b/packages/bonito-core/src/localization/localizer.ts @@ -1,4 +1,6 @@ +export type LocalizerTokenMap = Record; + export interface Localizer { - translate(message: string): string; + translate(message: string, tokens?: LocalizerTokenMap): string; getLocale(): string; } diff --git a/packages/service/i18n/resources.resjson b/packages/service/i18n/resources.resjson index 9e26dfeeb..a8d0b00bb 100644 --- a/packages/service/i18n/resources.resjson +++ b/packages/service/i18n/resources.resjson @@ -1 +1,4 @@ -{} \ No newline at end of file +{ + "lib.service.sku.eolWarning": "This SKU is scheduled for retirement on {eolDate}", + "lib.service.sku.notFound": "SKU '{skuName}' not found" +} \ No newline at end of file diff --git a/packages/service/src/constants.ts b/packages/service/src/constants.ts index 873a15960..a61beb0e2 100644 --- a/packages/service/src/constants.ts +++ b/packages/service/src/constants.ts @@ -1,4 +1,4 @@ export const BatchApiVersion = { - arm: `2023-11-01`, + arm: `2024-02-01`, data: `2023-05-01.17.0`, }; diff --git a/packages/service/src/environment/batch-dependencies.ts b/packages/service/src/environment/batch-dependencies.ts index 94d334127..c37773109 100644 --- a/packages/service/src/environment/batch-dependencies.ts +++ b/packages/service/src/environment/batch-dependencies.ts @@ -1,13 +1,16 @@ import { DependencyFactories } from "@azure/bonito-core/lib/environment"; import { NodeService } from "../node"; import { PoolService } from "../pool"; +import { SkuService } from "../sku"; export enum BatchDependencyName { PoolService = "poolService", NodeService = "nodeService", + SkuService = "skuService", } export interface BatchDependencyFactories extends DependencyFactories { [BatchDependencyName.PoolService]: () => PoolService; [BatchDependencyName.NodeService]: () => NodeService; + [BatchDependencyName.SkuService]: () => SkuService; } diff --git a/packages/service/src/environment/environment-util.ts b/packages/service/src/environment/environment-util.ts index a15508fc0..6c060ed2c 100644 --- a/packages/service/src/environment/environment-util.ts +++ b/packages/service/src/environment/environment-util.ts @@ -7,6 +7,7 @@ import { mockEnvironmentConfig, } from "@azure/bonito-core/lib/environment"; import { FakePoolService } from "../pool"; +import { FakeSkuService } from "../sku"; import { BatchDependencyFactories, BatchDependencyName, @@ -14,6 +15,7 @@ import { export const mockBatchDepFactories: Partial = { [BatchDependencyName.PoolService]: () => new FakePoolService(), + [BatchDependencyName.SkuService]: () => new FakeSkuService(), }; /** diff --git a/packages/service/src/index.ts b/packages/service/src/index.ts index 9147f198a..32373c610 100644 --- a/packages/service/src/index.ts +++ b/packages/service/src/index.ts @@ -4,3 +4,4 @@ export * from "./certificate"; export * from "./pool"; export * from "./node"; export * from "./constants"; +export * from "./sku"; diff --git a/packages/service/src/internal/arm-batch-rest/generated/batchManagementClient.ts b/packages/service/src/internal/arm-batch-rest/generated/batchManagementClient.ts index 66d50ae7a..7a083223f 100644 --- a/packages/service/src/internal/arm-batch-rest/generated/batchManagementClient.ts +++ b/packages/service/src/internal/arm-batch-rest/generated/batchManagementClient.ts @@ -14,7 +14,7 @@ export default function createClient( options: ClientOptions = {} ): BatchManagementClient { const baseUrl = options.baseUrl ?? `https://management.azure.com`; - options.apiVersion = options.apiVersion ?? "2023-11-01"; + options.apiVersion = options.apiVersion ?? "2024-02-01"; options = { ...options, credentials: { diff --git a/packages/service/src/internal/arm-batch-rest/generated/models.ts b/packages/service/src/internal/arm-batch-rest/generated/models.ts index 118ede1a9..f8c6b7012 100644 --- a/packages/service/src/internal/arm-batch-rest/generated/models.ts +++ b/packages/service/src/internal/arm-batch-rest/generated/models.ts @@ -415,6 +415,8 @@ export interface PoolProperties { targetNodeCommunicationMode?: "Default" | "Classic" | "Simplified"; /** Determines how a pool communicates with the Batch service. */ currentNodeCommunicationMode?: "Default" | "Classic" | "Simplified"; + /** Describes an upgrade policy - automatic, manual, or rolling. */ + upgradePolicy?: UpgradePolicy; /** The user-defined tags to be associated with the Azure Batch Pool. When specified, these tags are propagated to the backing Azure resources associated with the pool. This property can only be specified when the Batch account was created with the poolAllocationMode property set to 'UserSubscription'. */ resourceTags?: Record; } @@ -960,6 +962,46 @@ export interface AzureFileShareConfiguration { mountOptions?: string; } +/** Describes an upgrade policy - automatic, manual, or rolling. */ +export interface UpgradePolicy { + /** Specifies the mode of an upgrade to virtual machines in the scale set.

Possible values are:

**Manual** - You control the application of updates to virtual machines in the scale set. You do this by using the manualUpgrade action.

**Automatic** - All virtual machines in the scale set are automatically updated at the same time.

**Rolling** - Scale set performs updates in batches with an optional pause time in between. */ + mode: "automatic" | "manual" | "rolling"; + /** The configuration parameters used for performing automatic OS upgrade. */ + automaticOSUpgradePolicy?: AutomaticOSUpgradePolicy; + /** This property is only supported on Pools with the virtualMachineConfiguration property. */ + rollingUpgradePolicy?: RollingUpgradePolicy; +} + +/** The configuration parameters used for performing automatic OS upgrade. */ +export interface AutomaticOSUpgradePolicy { + /** Whether OS image rollback feature should be disabled. */ + disableAutomaticRollback?: boolean; + /** Indicates whether OS upgrades should automatically be applied to scale set instances in a rolling fashion when a newer version of the OS image becomes available.

If this is set to true for Windows based pools, [WindowsConfiguration.enableAutomaticUpdates](https://learn.microsoft.com/en-us/rest/api/batchmanagement/pool/create?tabs=HTTP#windowsconfiguration) cannot be set to true. */ + enableAutomaticOSUpgrade?: boolean; + /** Indicates whether rolling upgrade policy should be used during Auto OS Upgrade. Auto OS Upgrade will fallback to the default policy if no policy is defined on the VMSS. */ + useRollingUpgradePolicy?: boolean; + /** Defer OS upgrades on the TVMs if they are running tasks. */ + osRollingUpgradeDeferral?: boolean; +} + +/** The configuration parameters used while performing a rolling upgrade. */ +export interface RollingUpgradePolicy { + /** Allow VMSS to ignore AZ boundaries when constructing upgrade batches. Take into consideration the Update Domain and maxBatchInstancePercent to determine the batch size. If this field is not set, Azure Azure Batch will not set its default value. The value of enableCrossZoneUpgrade on the created VirtualMachineScaleSet will be decided by the default configurations on VirtualMachineScaleSet. This field is able to be set to true or false only when using NodePlacementConfiguration as Zonal. */ + enableCrossZoneUpgrade?: boolean; + /** The maximum percent of total virtual machine instances that will be upgraded simultaneously by the rolling upgrade in one batch. As this is a maximum, unhealthy instances in previous or future batches can cause the percentage of instances in a batch to decrease to ensure higher reliability. The value of this field should be between 5 and 100, inclusive. If both maxBatchInstancePercent and maxUnhealthyInstancePercent are assigned with value, the value of maxBatchInstancePercent should not be more than maxUnhealthyInstancePercent. */ + maxBatchInstancePercent?: number; + /** The maximum percentage of the total virtual machine instances in the scale set that can be simultaneously unhealthy, either as a result of being upgraded, or by being found in an unhealthy state by the virtual machine health checks before the rolling upgrade aborts. This constraint will be checked prior to starting any batch. The value of this field should be between 5 and 100, inclusive. If both maxBatchInstancePercent and maxUnhealthyInstancePercent are assigned with value, the value of maxBatchInstancePercent should not be more than maxUnhealthyInstancePercent. */ + maxUnhealthyInstancePercent?: number; + /** The maximum percentage of upgraded virtual machine instances that can be found to be in an unhealthy state. This check will happen after each batch is upgraded. If this percentage is ever exceeded, the rolling update aborts. The value of this field should be between 0 and 100, inclusive. */ + maxUnhealthyUpgradedInstancePercent?: number; + /** The wait time between completing the update for all virtual machines in one batch and starting the next batch. The time duration should be specified in ISO 8601 format. */ + pauseTimeBetweenBatches?: string; + /** Upgrade all unhealthy instances in a scale set before any healthy instances. */ + prioritizeUnhealthyInstances?: boolean; + /** Rollback failed instances to previous model if the Rolling Upgrade policy is violated. */ + rollbackFailedInstancesOnPolicyBreach?: boolean; +} + /** The identity of the Batch pool, if configured. If the pool identity is updated during update an existing pool, only the new vms which are created after the pool shrinks to 0 will have the updated identities */ export interface BatchPoolIdentity { /** The type of identity used for the Batch Pool. */ diff --git a/packages/service/src/internal/arm-batch-rest/generated/outputModels.ts b/packages/service/src/internal/arm-batch-rest/generated/outputModels.ts index 6d77c64cf..31608c045 100644 --- a/packages/service/src/internal/arm-batch-rest/generated/outputModels.ts +++ b/packages/service/src/internal/arm-batch-rest/generated/outputModels.ts @@ -334,6 +334,8 @@ export interface SupportedSkuOutput { familyName?: string; /** A collection of capabilities which this SKU supports. */ capabilities?: Array; + /** The time when Azure Batch service will retire this SKU. */ + batchSupportEndOfLife?: string; } /** A SKU capability, such as the number of cores. */ @@ -584,6 +586,8 @@ export interface PoolPropertiesOutput { targetNodeCommunicationMode?: "Default" | "Classic" | "Simplified"; /** Determines how a pool communicates with the Batch service. */ currentNodeCommunicationMode?: "Default" | "Classic" | "Simplified"; + /** Describes an upgrade policy - automatic, manual, or rolling. */ + upgradePolicy?: UpgradePolicyOutput; /** The user-defined tags to be associated with the Azure Batch Pool. When specified, these tags are propagated to the backing Azure resources associated with the pool. This property can only be specified when the Batch account was created with the poolAllocationMode property set to 'UserSubscription'. */ resourceTags?: Record; } @@ -1129,6 +1133,46 @@ export interface AzureFileShareConfigurationOutput { mountOptions?: string; } +/** Describes an upgrade policy - automatic, manual, or rolling. */ +export interface UpgradePolicyOutput { + /** Specifies the mode of an upgrade to virtual machines in the scale set.

Possible values are:

**Manual** - You control the application of updates to virtual machines in the scale set. You do this by using the manualUpgrade action.

**Automatic** - All virtual machines in the scale set are automatically updated at the same time.

**Rolling** - Scale set performs updates in batches with an optional pause time in between. */ + mode: "automatic" | "manual" | "rolling"; + /** The configuration parameters used for performing automatic OS upgrade. */ + automaticOSUpgradePolicy?: AutomaticOSUpgradePolicyOutput; + /** This property is only supported on Pools with the virtualMachineConfiguration property. */ + rollingUpgradePolicy?: RollingUpgradePolicyOutput; +} + +/** The configuration parameters used for performing automatic OS upgrade. */ +export interface AutomaticOSUpgradePolicyOutput { + /** Whether OS image rollback feature should be disabled. */ + disableAutomaticRollback?: boolean; + /** Indicates whether OS upgrades should automatically be applied to scale set instances in a rolling fashion when a newer version of the OS image becomes available.

If this is set to true for Windows based pools, [WindowsConfiguration.enableAutomaticUpdates](https://learn.microsoft.com/en-us/rest/api/batchmanagement/pool/create?tabs=HTTP#windowsconfiguration) cannot be set to true. */ + enableAutomaticOSUpgrade?: boolean; + /** Indicates whether rolling upgrade policy should be used during Auto OS Upgrade. Auto OS Upgrade will fallback to the default policy if no policy is defined on the VMSS. */ + useRollingUpgradePolicy?: boolean; + /** Defer OS upgrades on the TVMs if they are running tasks. */ + osRollingUpgradeDeferral?: boolean; +} + +/** The configuration parameters used while performing a rolling upgrade. */ +export interface RollingUpgradePolicyOutput { + /** Allow VMSS to ignore AZ boundaries when constructing upgrade batches. Take into consideration the Update Domain and maxBatchInstancePercent to determine the batch size. If this field is not set, Azure Azure Batch will not set its default value. The value of enableCrossZoneUpgrade on the created VirtualMachineScaleSet will be decided by the default configurations on VirtualMachineScaleSet. This field is able to be set to true or false only when using NodePlacementConfiguration as Zonal. */ + enableCrossZoneUpgrade?: boolean; + /** The maximum percent of total virtual machine instances that will be upgraded simultaneously by the rolling upgrade in one batch. As this is a maximum, unhealthy instances in previous or future batches can cause the percentage of instances in a batch to decrease to ensure higher reliability. The value of this field should be between 5 and 100, inclusive. If both maxBatchInstancePercent and maxUnhealthyInstancePercent are assigned with value, the value of maxBatchInstancePercent should not be more than maxUnhealthyInstancePercent. */ + maxBatchInstancePercent?: number; + /** The maximum percentage of the total virtual machine instances in the scale set that can be simultaneously unhealthy, either as a result of being upgraded, or by being found in an unhealthy state by the virtual machine health checks before the rolling upgrade aborts. This constraint will be checked prior to starting any batch. The value of this field should be between 5 and 100, inclusive. If both maxBatchInstancePercent and maxUnhealthyInstancePercent are assigned with value, the value of maxBatchInstancePercent should not be more than maxUnhealthyInstancePercent. */ + maxUnhealthyInstancePercent?: number; + /** The maximum percentage of upgraded virtual machine instances that can be found to be in an unhealthy state. This check will happen after each batch is upgraded. If this percentage is ever exceeded, the rolling update aborts. The value of this field should be between 0 and 100, inclusive. */ + maxUnhealthyUpgradedInstancePercent?: number; + /** The wait time between completing the update for all virtual machines in one batch and starting the next batch. The time duration should be specified in ISO 8601 format. */ + pauseTimeBetweenBatches?: string; + /** Upgrade all unhealthy instances in a scale set before any healthy instances. */ + prioritizeUnhealthyInstances?: boolean; + /** Rollback failed instances to previous model if the Rolling Upgrade policy is violated. */ + rollbackFailedInstancesOnPolicyBreach?: boolean; +} + /** The identity of the Batch pool, if configured. If the pool identity is updated during update an existing pool, only the new vms which are created after the pool shrinks to 0 will have the updated identities */ export interface BatchPoolIdentityOutput { /** The type of identity used for the Batch Pool. */ diff --git a/packages/service/src/sku/__tests__/fake-sku-service.spec.ts b/packages/service/src/sku/__tests__/fake-sku-service.spec.ts new file mode 100644 index 000000000..2f5e4e404 --- /dev/null +++ b/packages/service/src/sku/__tests__/fake-sku-service.spec.ts @@ -0,0 +1,31 @@ +import { initMockBatchEnvironment } from "../../environment"; +import { + BasicBatchFakeSet, + BatchFakeSet, + FakeLocations, +} from "../../test-util/fakes"; +import { FakeSkuService } from "../fake-sku-service"; +import { SupportedSkuType } from "../sku-models"; + +describe("FakeSkuService", () => { + let fakeSet: BatchFakeSet; + let service: FakeSkuService; + + beforeEach(() => { + initMockBatchEnvironment(); + fakeSet = new BasicBatchFakeSet(); + service = new FakeSkuService(); + service.setFakes(fakeSet); + }); + + test("List by type", async () => { + const skus = await service.list({ + subscriptionId: "00000000-0000-0000-0000-000000000000", + type: SupportedSkuType.CloudService, + locationName: FakeLocations.Arrakis.name, + }); + expect(skus.length).toEqual(2); + expect(skus[1].name).toEqual("A7"); + expect(skus[1].batchSupportEndOfLife).toEqual("2024-08-31T00:00:00Z"); + }); +}); diff --git a/packages/service/src/sku/__tests__/live-sku-service.spec.ts b/packages/service/src/sku/__tests__/live-sku-service.spec.ts new file mode 100644 index 000000000..cb2bf2fcb --- /dev/null +++ b/packages/service/src/sku/__tests__/live-sku-service.spec.ts @@ -0,0 +1,58 @@ +import { MockHttpClient, MockHttpResponse } from "@azure/bonito-core/lib/http"; +import { + BasicBatchFakeSet, + BatchFakeSet, + FakeLocations, +} from "../../test-util/fakes"; +import { LiveSkuService } from "../live-sku-service"; +import { initMockBatchEnvironment } from "../../environment"; +import { getMockEnvironment } from "@azure/bonito-core/lib/environment"; +import { getArmUrl } from "@azure/bonito-core"; +import { BatchApiVersion } from "../../constants"; +import { SupportedSkuType } from "../sku-models"; + +describe("LiveSkuService", () => { + const arrakisLoc = + "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Batch/locations/arrakis"; + let service: LiveSkuService; + let fakeSet: BatchFakeSet; + + let httpClient: MockHttpClient; + + beforeEach(() => { + initMockBatchEnvironment(); + httpClient = getMockEnvironment().getHttpClient(); + service = new LiveSkuService(); + fakeSet = new BasicBatchFakeSet(); + }); + + test("List virtual machine SKUs", async () => { + httpClient.addExpected( + new MockHttpResponse( + `${getArmUrl()}${arrakisLoc}/virtualMachineSkus?api-version=${ + BatchApiVersion.arm + }`, + { + status: 200, + body: JSON.stringify({ + value: fakeSet.listSupportedSkus( + SupportedSkuType.VirtualMachine, + FakeLocations.Arrakis.name + ), + }), + } + ) + ); + + const skus = await service.list({ + subscriptionId: "00000000-0000-0000-0000-000000000000", + locationName: FakeLocations.Arrakis.name, + }); + expect(skus.length).toEqual(4); + expect(skus[2]).toEqual({ + name: "Standard_NC8as_T4_v3", + familyName: "Standard NCASv3_T4 Family", + batchSupportEndOfLife: "2024-08-31T00:00:00Z", + }); + }); +}); diff --git a/packages/service/src/sku/__tests__/sku-util.spec.ts b/packages/service/src/sku/__tests__/sku-util.spec.ts new file mode 100644 index 000000000..a696ccb1c --- /dev/null +++ b/packages/service/src/sku/__tests__/sku-util.spec.ts @@ -0,0 +1,47 @@ +import { Localizer, getLocalizer } from "@azure/bonito-core/lib/localization"; +import { initMockBatchEnvironment } from "../../environment"; +import { + BasicBatchFakeSet, + BatchFakeSet, + FakeLocations, +} from "../../test-util/fakes"; +import { FakeSkuService } from "../fake-sku-service"; +import { SupportedSkuType } from "../sku-models"; +import { endOfLifeStatus } from "../sku-util"; + +describe("SKU Utilities", () => { + let fakeSet: BatchFakeSet; + let service: FakeSkuService; + let localizer: Localizer; + + const options = { + subscriptionId: "00000000-0000-0000-0000-000000000000", + type: SupportedSkuType.VirtualMachine, + locationName: FakeLocations.Arrakis.name, + }; + + beforeEach(() => { + initMockBatchEnvironment(); + fakeSet = new BasicBatchFakeSet(); + service = new FakeSkuService(); + service.setFakes(fakeSet); + localizer = getLocalizer(); + jest.spyOn(localizer, "getLocale").mockReturnValue("en-US"); + }); + + test("returns an EOL date for a given SKU", async () => { + const eolSku = "Standard_NC8as_T4_v3"; + const nonEolSku = "Standard_NC64as_T4_v3"; + const nonExistingSku = "non-SKU"; + + const eol = (skuName: string) => + endOfLifeStatus({ ...options, skuName }); + + expect(await eol(eolSku)).toEqual({ + eolDate: new Date(2024, 7, 31), + warning: "This SKU is scheduled for retirement on August 31, 2024", + }); + expect(await eol(nonEolSku)).toBeNull(); + expect(eol(nonExistingSku)).rejects.toThrow(`SKU 'non-SKU' not found`); + }); +}); diff --git a/packages/service/src/sku/fake-sku-service.ts b/packages/service/src/sku/fake-sku-service.ts new file mode 100644 index 000000000..7bbdaa05e --- /dev/null +++ b/packages/service/src/sku/fake-sku-service.ts @@ -0,0 +1,17 @@ +import { BasicBatchFakeSet, BatchFakeSet } from "../test-util/fakes"; +import { SupportedSku, SupportedSkuType } from "./sku-models"; +import { ListSkusOptions, SkuService } from "./sku-service"; + +export class FakeSkuService implements SkuService { + fakeSet: BatchFakeSet = new BasicBatchFakeSet(); + + setFakes(fakeSet: BatchFakeSet): void { + this.fakeSet = fakeSet; + } + + async list(options: ListSkusOptions): Promise { + const { type = SupportedSkuType.VirtualMachine, locationName } = + options; + return this.fakeSet.listSupportedSkus(type, locationName); + } +} diff --git a/packages/service/src/sku/index.ts b/packages/service/src/sku/index.ts new file mode 100644 index 000000000..1a69983ae --- /dev/null +++ b/packages/service/src/sku/index.ts @@ -0,0 +1,5 @@ +export * from "./fake-sku-service"; +export * from "./live-sku-service"; +export * from "./sku-service"; +export * from "./sku-models"; +export * from "./sku-util"; diff --git a/packages/service/src/sku/live-sku-service.ts b/packages/service/src/sku/live-sku-service.ts new file mode 100644 index 000000000..c2339077a --- /dev/null +++ b/packages/service/src/sku/live-sku-service.ts @@ -0,0 +1,39 @@ +import { AbstractHttpService, getArmUrl } from "@azure/bonito-core"; +import { ListSkusOptions, SkuService } from "./sku-service"; +import { SupportedSku, SupportedSkuType } from "./sku-models"; +import { createARMBatchClient, isUnexpected } from "../internal/arm-batch-rest"; +import { createArmUnexpectedStatusCodeError } from "../utils"; + +const VIRTUAL_MACHINE_SKU_PATH = + "/subscriptions/{subscriptionId}/providers/Microsoft.Batch/locations/{locationName}/virtualMachineSkus"; +const CLOUD_SERVICE_SKU_PATH = + "/subscriptions/{subscriptionId}/providers/Microsoft.Batch/locations/{locationName}/cloudServiceSkus"; + +export class LiveSkuService extends AbstractHttpService implements SkuService { + async list(options: ListSkusOptions): Promise { + const { + subscriptionId, + locationName, + type = SupportedSkuType.VirtualMachine, + } = options; + + const armBatchClient = createARMBatchClient({ + baseUrl: getArmUrl(), + }); + + let res; + if (type === SupportedSkuType.CloudService) { + res = await armBatchClient + .path(CLOUD_SERVICE_SKU_PATH, subscriptionId, locationName) + .get(); + } else { + res = await armBatchClient + .path(VIRTUAL_MACHINE_SKU_PATH, subscriptionId, locationName) + .get(); + } + if (isUnexpected(res)) { + throw createArmUnexpectedStatusCodeError(res); + } + return res.body.value ?? []; + } +} diff --git a/packages/service/src/sku/sku-models.ts b/packages/service/src/sku/sku-models.ts new file mode 100644 index 000000000..4d2ef4cc5 --- /dev/null +++ b/packages/service/src/sku/sku-models.ts @@ -0,0 +1,6 @@ +export { SupportedSkuOutput as SupportedSku } from "../internal/arm-batch-rest"; + +export const enum SupportedSkuType { + CloudService = "CloudService", + VirtualMachine = "VirtualMachine", +} diff --git a/packages/service/src/sku/sku-service.ts b/packages/service/src/sku/sku-service.ts new file mode 100644 index 000000000..3b04cb1b0 --- /dev/null +++ b/packages/service/src/sku/sku-service.ts @@ -0,0 +1,11 @@ +import { SupportedSku, SupportedSkuType } from "./sku-models"; + +export interface ListSkusOptions { + subscriptionId: string; + type?: SupportedSkuType; + locationName: string; +} + +export interface SkuService { + list(options: ListSkusOptions): Promise; +} diff --git a/packages/service/src/sku/sku-util.ts b/packages/service/src/sku/sku-util.ts new file mode 100644 index 000000000..00b4e3a2a --- /dev/null +++ b/packages/service/src/sku/sku-util.ts @@ -0,0 +1,65 @@ +import { getLocalizer, translate } from "@azure/bonito-core/lib/localization"; +import { BatchDependencyName } from "../environment"; +import { ListSkusOptions, SkuService } from "./sku-service"; +import { inject } from "@azure/bonito-core/lib/environment"; + +export interface SkuEndOfLifeStatus { + eolDate: Date; + warning: string; +} + +export interface EndOfLifeStatusQueryOptions extends ListSkusOptions { + skuName: string; +} + +/** + * Get the end of life status for a SKU + * + * @param options - The options for the query + * @returns The end of life status for the SKU + * @throws If the SKU is not found + */ +export async function endOfLifeStatus( + options: EndOfLifeStatusQueryOptions +): Promise { + const { skuName } = options; + + const skuService: SkuService = inject(BatchDependencyName.SkuService); + const skus = await skuService.list(options); + const sku = skus.find((s) => s.name === skuName); + if (!sku) { + throw new Error(translate("lib.service.sku.notFound", { skuName })); + } + if (sku.batchSupportEndOfLife) { + const eolDate = toDateObject(sku.batchSupportEndOfLife); + return { + eolDate, + warning: translate("lib.service.sku.eolWarning", { + skuName, + eolDate: eolDate.toLocaleDateString( + getLocalizer().getLocale(), + { + day: "numeric", + month: "long", + year: "numeric", + } + ), + }), + }; + } + return null; +} + +/** + * Convert a date string to a Date object. + * + * Strips the time portion of the date string to avoid timezone-related issues + */ +function toDateObject(dateString: string): Date { + dateString = dateString.replace(/[T ].*/, ""); + const [year, month, day] = dateString.split("-").map(Number); + if (isNaN(year) || isNaN(month) || isNaN(day)) { + return new Date(dateString); + } + return new Date(year, month - 1, day); +} diff --git a/packages/service/src/sku/sku.i18n.yml b/packages/service/src/sku/sku.i18n.yml new file mode 100644 index 000000000..0be0d1891 --- /dev/null +++ b/packages/service/src/sku/sku.i18n.yml @@ -0,0 +1,3 @@ +sku: + notFound: "SKU '{skuName}' not found" + eolWarning: "This SKU is scheduled for retirement on {eolDate}" diff --git a/packages/service/src/test-util/fakes.ts b/packages/service/src/test-util/fakes.ts index 81355500a..a32fecc0a 100644 --- a/packages/service/src/test-util/fakes.ts +++ b/packages/service/src/test-util/fakes.ts @@ -18,6 +18,7 @@ import { BatchNodeVMExtensionOutput, } from "../node/node-models"; import { Pool, PoolOutput } from "../pool/pool-models"; +import { SupportedSku, SupportedSkuType } from "../sku"; /** * A fake dataset which includes Batch accounts, pools, etc. @@ -67,6 +68,11 @@ export interface BatchFakeSet extends FakeSet { listBatchNodes(poolId: string): BatchNodeOutput[]; listBatchNodeExtensions(nodeId: string): BatchNodeVMExtensionOutput[]; + + listSupportedSkus( + type: SupportedSkuType, + locationName: string + ): SupportedSku[]; } export abstract class AbstractBatchFakeSet @@ -86,6 +92,14 @@ export abstract class AbstractBatchFakeSet [nodeId: string]: BatchNodeVMExtensionOutput[]; }; + protected abstract supportedVirtualMachineSkus: { + [location: string]: SupportedSku[]; + }; + + protected abstract supportedCloudServiceSkus: { + [location: string]: SupportedSku[]; + }; + getBatchAccount(batchAccountId: string): BatchAccountOutput | undefined { return this.batchAccounts[batchAccountId.toLowerCase()]; } @@ -151,8 +165,37 @@ export abstract class AbstractBatchFakeSet listBatchNodeExtensions(nodeId: string): BatchNodeVMExtensionOutput[] { return this.batchNodeExtensions[nodeId] ?? []; } + + listSupportedSkus( + type: SupportedSkuType, + locationName: string + ): SupportedSku[] { + const list = + type === SupportedSkuType.CloudService + ? this.supportedCloudServiceSkus + : this.supportedVirtualMachineSkus; + return list[locationName]; + } } +let locId = 100; +const loc = (name: string, displayName: string): Location => ({ + id: `${++locId}`, + name, + displayName, + regionalDisplayName: displayName, + metadata: { + regionType: "Logical", + regionCategory: "Recommended", + }, +}); + +export const FakeLocations = { + Arrakis: loc("arrakis", "Arrakis"), + Valinor: loc("valinor", "Valinor"), + Winterfell: loc("winterfell", "Winterfell"), +}; + export class BasicBatchFakeSet extends AbstractBatchFakeSet { defaultTenantArmId: string = "/tenants/99999999-9999-9999-9999-999999999999"; @@ -755,6 +798,103 @@ export class BasicBatchFakeSet extends AbstractBatchFakeSet { }, ], }; + + supportedVirtualMachineSkus: { [location: string]: SupportedSku[] } = { + [FakeLocations.Arrakis.name]: [ + { + name: "Standard_NC4as_T4_v3", + familyName: "Standard NCASv3_T4 Family", + capabilities: [], + }, + { + name: "Standard_NC64as_T4_v3", + familyName: "Standard NCASv3_T4 Family", + capabilities: [ + { name: "GPUs", value: "1" }, + { name: "PremiumIO", value: "True" }, + ], + }, + { + name: "Standard_NC8as_T4_v3", + familyName: "Standard NCASv3_T4 Family", + batchSupportEndOfLife: "2024-08-31T00:00:00Z", + }, + { + name: "Standard_NV36adms_A10_v5", + familyName: "StandardNVADSA10v5Family", + capabilities: [], + }, + ], + [FakeLocations.Valinor.name]: [ + { + name: "Standard_ND96asr_v4", + familyName: "Standard NDASv4_A100 Family", + capabilities: [], + }, + { + name: "Standard_NV12ads_A10_v5", + familyName: "StandardNVADSA10v5Family", + capabilities: [], + batchSupportEndOfLife: "2018-04-01T00:00:00Z", + }, + { + name: "Standard_NV18ads_A10_v5", + familyName: "StandardNVADSA10v5Family", + capabilities: [], + }, + ], + }; + + supportedCloudServiceSkus: { [location: string]: SupportedSku[] } = { + [FakeLocations.Winterfell.name]: [ + { + name: "Small", + familyName: "standardA0_A7Family", + capabilities: [ + { name: "MaxResourceVolumeMB", value: "20480" }, + { name: "vCPUs", value: "1" }, + ], + batchSupportEndOfLife: "2022-12-31T00:00:00Z", + }, + { + name: "A5", + familyName: "standardA0_A7Family", + batchSupportEndOfLife: "2024-08-31T00:00:00Z", + capabilities: [ + { name: "Cores", value: "2" }, + { name: "MemoryInMb", value: "14336" }, + ], + }, + ], + [FakeLocations.Arrakis.name]: [ + { + name: "A5", + familyName: "standardA0_A7Family", + batchSupportEndOfLife: "2024-08-31T00:00:00Z", + capabilities: [ + { name: "Cores", value: "2" }, + { name: "MemoryInMb", value: "14336" }, + ], + }, + { + name: "A7", + familyName: "standardA0_A7Family", + capabilities: [ + { name: "Cores", value: "8" }, + { name: "MemoryInMb", value: "57344" }, + { name: "SupportedByWebWorkerRoles", value: "true" }, + { name: "SupportedByVirtualMachines", value: "true" }, + { name: "MaxDataDiskCount", value: "16" }, + { name: "WebWorkerResourceDiskSizeInMb", value: "2088960" }, + { + name: "VirtualMachineResourceDiskSizeInMb", + value: "619520", + }, + ], + batchSupportEndOfLife: "2024-08-31T00:00:00Z", + }, + ], + }; } /** diff --git a/packages/service/swagger/README.md b/packages/service/swagger/README.md index 5fe252516..b80a44e3b 100644 --- a/packages/service/swagger/README.md +++ b/packages/service/swagger/README.md @@ -14,7 +14,7 @@ generate-sample: false license-header: MICROSOFT_MIT_NO_VERSION output-folder: ../src/internal/arm-batch-rest source-code-folder-path: ./generated -input-file: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/batch/resource-manager/Microsoft.Batch/stable/2023-11-01/BatchManagement.json +input-file: https://raw.githubusercontent.com/Azure/azure-rest-api-specs/main/specification/batch/resource-manager/Microsoft.Batch/stable/2024-02-01/BatchManagement.json package-version: 1.0.0-beta.1 rest-level-client: true add-credentials: true diff --git a/web/src/index.tsx b/web/src/index.tsx index def1212e1..bd6fccb4a 100644 --- a/web/src/index.tsx +++ b/web/src/index.tsx @@ -22,7 +22,7 @@ import { BatchBrowserDependencyFactories, BatchFormControlResolver, } from "@batch/ui-react"; -import { FakeNodeService } from "@batch/ui-service"; +import { FakeNodeService, FakeSkuService } from "@batch/ui-service"; import { BatchDependencyName } from "@batch/ui-service/lib/environment"; import { FakePoolService } from "@batch/ui-service/lib/pool"; import * as React from "react"; @@ -65,6 +65,7 @@ export async function init(rootEl: HTMLElement): Promise { [DependencyName.Notifier]: () => new AlertNotifier(), // TODO: update with real notification implementation [BatchDependencyName.PoolService]: () => new FakePoolService(), [BatchDependencyName.NodeService]: () => new FakeNodeService(), + [BatchDependencyName.SkuService]: () => new FakeSkuService(), [DependencyName.ResourceGroupService]: () => new FakeResourceGroupService(), [DependencyName.StorageAccountService]: () =>