From 7c362f9b4a61288ebcdb6cc199b2dce8e4961070 Mon Sep 17 00:00:00 2001 From: Sophie Stadler Date: Wed, 30 Aug 2023 11:08:11 -0400 Subject: [PATCH 1/8] Add host settings page --- src/gql/generated/types.ts | 42 +++- src/pages/distroSettings/Tabs.tsx | 17 +- .../distroSettings/tabs/HostTab/HostTab.tsx | 14 ++ .../tabs/HostTab/getFormSchema.ts | 208 ++++++++++++++++++ .../tabs/HostTab/schemaFields.ts | 32 +++ .../tabs/HostTab/transformers.ts | 104 +++++++++ .../distroSettings/tabs/HostTab/types.ts | 39 ++++ src/pages/distroSettings/tabs/index.tsx | 3 +- src/pages/distroSettings/tabs/testData.ts | 9 +- src/pages/distroSettings/tabs/transformers.ts | 5 +- src/pages/distroSettings/tabs/types.ts | 3 +- 11 files changed, 459 insertions(+), 17 deletions(-) create mode 100644 src/pages/distroSettings/tabs/HostTab/HostTab.tsx create mode 100644 src/pages/distroSettings/tabs/HostTab/getFormSchema.ts create mode 100644 src/pages/distroSettings/tabs/HostTab/schemaFields.ts create mode 100644 src/pages/distroSettings/tabs/HostTab/transformers.ts create mode 100644 src/pages/distroSettings/tabs/HostTab/types.ts diff --git a/src/gql/generated/types.ts b/src/gql/generated/types.ts index 0aee3e75a0..b2c98d7d21 100644 --- a/src/gql/generated/types.ts +++ b/src/gql/generated/types.ts @@ -60,6 +60,18 @@ export type Annotation = { webhookConfigured: Scalars["Boolean"]; }; +export enum Arch { + Linux_32Bit = "LINUX_32_BIT", + Linux_64Bit = "LINUX_64_BIT", + LinuxArm_64Bit = "LINUX_ARM_64_BIT", + LinuxPpc_64Bit = "LINUX_PPC_64_BIT", + LinuxZseries = "LINUX_ZSERIES", + Osx_64Bit = "OSX_64_BIT", + OsxArm_64Bit = "OSX_ARM_64_BIT", + Windows_32Bit = "WINDOWS_32_BIT", + Windows_64Bit = "WINDOWS_64_BIT", +} + export enum BannerTheme { Announcement = "ANNOUNCEMENT", Important = "IMPORTANT", @@ -67,14 +79,20 @@ export enum BannerTheme { Warning = "WARNING", } +export enum BootstrapMethod { + LegacySsh = "LEGACY_SSH", + Ssh = "SSH", + UserData = "USER_DATA", +} + export type BootstrapSettings = { __typename?: "BootstrapSettings"; clientDir: Scalars["String"]; - communication: Scalars["String"]; + communication: CommunicationMethod; env: Array; jasperBinaryDir: Scalars["String"]; jasperCredentialsPath: Scalars["String"]; - method: Scalars["String"]; + method: BootstrapMethod; preconditionScripts: Array; resourceLimits: ResourceLimits; rootDir: Scalars["String"]; @@ -84,11 +102,11 @@ export type BootstrapSettings = { export type BootstrapSettingsInput = { clientDir: Scalars["String"]; - communication: Scalars["String"]; + communication: CommunicationMethod; env: Array; jasperBinaryDir: Scalars["String"]; jasperCredentialsPath: Scalars["String"]; - method: Scalars["String"]; + method: BootstrapMethod; preconditionScripts: Array; resourceLimits: ResourceLimitsInput; rootDir: Scalars["String"]; @@ -227,6 +245,12 @@ export type CommitQueueParamsInput = { message?: InputMaybe; }; +export enum CommunicationMethod { + LegacySsh = "LEGACY_SSH", + Rpc = "RPC", + Ssh = "SSH", +} + export type ContainerResources = { __typename?: "ContainerResources"; cpu: Scalars["Int"]; @@ -319,7 +343,7 @@ export type DisplayTask = { export type Distro = { __typename?: "Distro"; aliases: Array; - arch: Scalars["String"]; + arch: Arch; authorizedKeysFile: Scalars["String"]; bootstrapSettings: BootstrapSettings; cloneMethod: CloneMethod; @@ -383,7 +407,7 @@ export type DistroInfo = { export type DistroInput = { aliases: Array; - arch: Scalars["String"]; + arch: Arch; authorizedKeysFile: Scalars["String"]; bootstrapSettings: BootstrapSettingsInput; cloneMethod: CloneMethod; @@ -5130,7 +5154,7 @@ export type DistroQuery = { distro?: { __typename?: "Distro"; aliases: Array; - arch: string; + arch: Arch; authorizedKeysFile: string; cloneMethod: CloneMethod; containerPool: string; @@ -5153,10 +5177,10 @@ export type DistroQuery = { bootstrapSettings: { __typename?: "BootstrapSettings"; clientDir: string; - communication: string; + communication: CommunicationMethod; jasperBinaryDir: string; jasperCredentialsPath: string; - method: string; + method: BootstrapMethod; rootDir: string; serviceUser: string; shellPath: string; diff --git a/src/pages/distroSettings/Tabs.tsx b/src/pages/distroSettings/Tabs.tsx index d26ce5ad75..c321ce0593 100644 --- a/src/pages/distroSettings/Tabs.tsx +++ b/src/pages/distroSettings/Tabs.tsx @@ -6,7 +6,13 @@ import { DistroQuery } from "gql/generated/types"; import { useDistroSettingsContext } from "./Context"; import { Header } from "./Header"; import { NavigationModal } from "./NavigationModal"; -import { EventLogTab, GeneralTab, ProjectTab, TaskTab } from "./tabs/index"; +import { + EventLogTab, + GeneralTab, + HostTab, + ProjectTab, + TaskTab, +} from "./tabs/index"; import { gqlToFormMap } from "./tabs/transformers"; interface Props { @@ -45,6 +51,15 @@ export const DistroSettingsTabs: React.FC = ({ distro }) => { } /> + + } + /> = ({ distroData, provider }) => { + const initialFormState = distroData; + + const formSchema = useMemo(() => getFormSchema({ provider }), [provider]); + + return ( + + ); +}; diff --git a/src/pages/distroSettings/tabs/HostTab/getFormSchema.ts b/src/pages/distroSettings/tabs/HostTab/getFormSchema.ts new file mode 100644 index 0000000000..e30854ec0f --- /dev/null +++ b/src/pages/distroSettings/tabs/HostTab/getFormSchema.ts @@ -0,0 +1,208 @@ +import { GetFormSchema } from "components/SpruceForm"; +import { CardFieldTemplate } from "components/SpruceForm/FieldTemplates"; +import { + Arch, + BootstrapMethod, + CommunicationMethod, + Provider, +} from "gql/generated/types"; + +type FormSchemaParams = { + provider: Provider; +}; + +export const getFormSchema = ({ + provider, +}: FormSchemaParams): ReturnType => { + const hasStaticProvider = provider === Provider.Static; + const hasDockerProvider = provider === Provider.Docker; + + return { + fields: {}, + schema: { + type: "object" as "object", + properties: { + setup: { + type: "object" as "object", + title: "Host Setup", + properties: { + bootstrapMethod: { + type: "string" as "string", + title: "Host Bootstrap Method", + oneOf: Object.entries(bootstrapMethodToCopy).map( + ([value, title]) => ({ + type: "string" as "string", + title, + enum: [value], + }) + ), + }, + communicationMethod: { + type: "string" as "string", + title: "Host Communication Method", + oneOf: Object.entries(communicationMethodToCopy).map( + ([value, title]) => ({ + type: "string" as "string", + title, + enum: [value], + }) + ), + }, + arch: { + type: "string" as "string", + title: "Agent Architecture", + oneOf: Object.entries(architectureToCopy).map( + ([value, title]) => ({ + type: "string" as "string", + title, + enum: [value], + }) + ), + }, + workDir: { + type: "string" as "string", + title: "Working Directory", + }, + setupScript: { + type: "string" as "string", + title: "Setup Script", + }, + setupAsSudo: { + type: "boolean" as "boolean", + title: "Run script as sudo", + }, + }, + }, + sshConfig: { + type: "object" as "object", + title: "SSH Configuration", + properties: { + user: { + type: "string" as "string", + title: "SSH User", + }, + sshKey: { + type: "string" as "string", + title: "SSH Key", + }, + authorizedKeysFile: { + type: "string" as "string", + title: "Authorized Keys File", + }, + sshOptions: { + type: "array" as "array", + title: "SSH Options", + items: { + type: "string" as "string", + title: "SSH Option", + }, + }, + }, + }, + allocation: { + type: "object" as "object", + title: "Host Allocation", + properties: { + version: { + type: "string" as "string", + title: "Host Allocator Version", + }, + roundingRule: { + type: "string" as "string", + title: "Host Allocator Rounding Rule", + }, + feedbackRule: { + type: "string" as "string", + title: "Host Allocator Feedback Rule", + }, + hostsOverallocatedRule: { + type: "string" as "string", + title: "Host Overallocation Rule", + }, + minimumHosts: { + type: "number" as "number", + title: "Minimum Number of Hosts Allowed", + }, + maximumHosts: { + type: "number" as "number", + title: "Maxiumum Number of Hosts Allowed", + }, + acceptableHostIdleTime: { + type: "number" as "number", + title: "Acceptable Host Idle Time (s)", + }, + futureHostFraction: { + type: "number" as "number", + title: "Future Host Fraction", + }, + }, + }, + }, + }, + uiSchema: { + setup: { + "ui:ObjectFieldTemplate": CardFieldTemplate, + bootstrapMethod: { + "ui:allowDeselect": false, + }, + communicationMethod: { + "ui:allowDeselect": false, + }, + arch: { + "ui:allowDeselect": false, + }, + setupScript: { + "ui:widget": "textarea", + }, + }, + sshConfig: { + "ui:ObjectFieldTemplate": CardFieldTemplate, + authorizedKeysFile: { + ...(!hasStaticProvider && { "ui:widget": "hidden" }), + }, + sshOptions: { + "ui:addButtonText": "Add SSH option", + }, + }, + allocation: { + "ui:ObjectFieldTemplate": CardFieldTemplate, + ...((hasStaticProvider || hasDockerProvider) && { + minimumHosts: { + "ui:widget": "hidden", + }, + maximumHosts: { + "ui:widget": "hidden", + }, + acceptableHostIdleTime: { + "ui:widget": "hidden", + }, + futureHostFraction: { + "ui:widget": "hidden", + }, + }), + }, + }, + }; +}; + +const architectureToCopy = { + [Arch.Linux_64Bit]: "Linux 64-bit", + [Arch.LinuxArm_64Bit]: "Linux ARM 64-bit", + [Arch.LinuxPpc_64Bit]: "Linux PowerPC 64-bit", + [Arch.LinuxZseries]: "Linux zSeries", + [Arch.Osx_64Bit]: "macOS 64-bit", + [Arch.OsxArm_64Bit]: "macOS ARM 64-bit", + [Arch.Windows_64Bit]: "Windows 64-bit", +}; + +const bootstrapMethodToCopy = { + [BootstrapMethod.LegacySsh]: "Legacy SSH", + [BootstrapMethod.Ssh]: "SSH", + [BootstrapMethod.UserData]: "User Data", +}; + +const communicationMethodToCopy = { + [CommunicationMethod.LegacySsh]: "Legacy SSH", + [CommunicationMethod.Ssh]: "SSH", + [CommunicationMethod.Rpc]: "RPC", +}; diff --git a/src/pages/distroSettings/tabs/HostTab/schemaFields.ts b/src/pages/distroSettings/tabs/HostTab/schemaFields.ts new file mode 100644 index 0000000000..f47969f520 --- /dev/null +++ b/src/pages/distroSettings/tabs/HostTab/schemaFields.ts @@ -0,0 +1,32 @@ +export const version = { + type: "string" as "string", + title: "Host Allocator Version", +}; +export const roundingRule = { + type: "string" as "string", + title: "Host Allocator Rounding Rule", +}; +export const feedbackRule = { + type: "string" as "string", + title: "Host Allocator Feedback Rule", +}; +export const hostsOverallocatedRule = { + type: "string" as "string", + title: "Host Overallocation Rule", +}; +export const minimumHosts = { + type: "number" as "number", + title: "Minimum Number of Hosts Allowed", +}; +export const maximumHosts = { + type: "number" as "number", + title: "Maxiumum Number of Hosts Allowed", +}; +export const acceptableHostIdleTime = { + type: "number" as "number", + title: "Acceptable Host Idle Time (s)", +}; +export const futureHostFraction = { + type: "number" as "number", + title: "Future Host Fraction", +}; diff --git a/src/pages/distroSettings/tabs/HostTab/transformers.ts b/src/pages/distroSettings/tabs/HostTab/transformers.ts new file mode 100644 index 0000000000..1bd029020e --- /dev/null +++ b/src/pages/distroSettings/tabs/HostTab/transformers.ts @@ -0,0 +1,104 @@ +import { DistroSettingsTabRoutes } from "constants/routes"; +import { BootstrapMethod } from "gql/generated/types"; +import { FormToGqlFunction, GqlToFormFunction } from "../types"; + +type Tab = DistroSettingsTabRoutes.Host; + +export const gqlToForm = ((data) => { + if (!data) return null; + + const { + arch, + authorizedKeysFile, + bootstrapSettings: { communication, method }, + hostAllocatorSettings, + setup, + setupAsSudo, + sshKey, + sshOptions, + user, + workDir, + } = data; + + switch (method) { + case BootstrapMethod.LegacySsh: + return { + setup: { + bootstrapMethod: method, + communicationMethod: communication, + arch, + workDir, + setupScript: setup, + setupAsSudo, + }, + sshConfig: { + user, + sshKey, + authorizedKeysFile, + sshOptions, + }, + allocation: { + version: hostAllocatorSettings.version, + roundingRule: hostAllocatorSettings.roundingRule, + feedbackRule: hostAllocatorSettings.feedbackRule, + hostsOverallocatedRule: hostAllocatorSettings.hostsOverallocatedRule, + minimumHosts: hostAllocatorSettings.minimumHosts, + maximumHosts: hostAllocatorSettings.maximumHosts, + acceptableHostIdleTime: hostAllocatorSettings.acceptableHostIdleTime, + futureHostFraction: hostAllocatorSettings.futureHostFraction, + }, + }; + default: + throw new Error(`Unknown bootstrap method '${method}'`); + } +}) satisfies GqlToFormFunction; + +export const formToGql = (({ allocation, setup, sshConfig }, distro) => { + const { bootstrapMethod } = setup; + + switch (bootstrapMethod) { + case BootstrapMethod.LegacySsh: + return { + ...distro, + arch: setup.arch, + authorizedKeysFile: sshConfig.authorizedKeysFile, + bootstrapSettings: { + clientDir: "", + communication: setup.communicationMethod, + env: [], + jasperBinaryDir: "", + jasperCredentialsPath: "", + method: bootstrapMethod, + preconditionScripts: [], + resourceLimits: { + lockedMemoryKb: 0, + numFiles: 0, + numProcesses: 0, + numTasks: 0, + virtualMemoryKb: 0, + }, + rootDir: "", + serviceUser: "", + shellPath: "", + }, + hostAllocatorSettings: { + acceptableHostIdleTime: allocation.acceptableHostIdleTime, + feedbackRule: allocation.feedbackRule, + futureHostFraction: allocation.futureHostFraction, + hostsOverallocatedRule: allocation.hostsOverallocatedRule, + maximumHosts: allocation.maximumHosts, + minimumHosts: allocation.minimumHosts, + roundingRule: allocation.roundingRule, + version: allocation.version, + }, + setup: setup.setupScript, + setupAsSudo: setup.setupAsSudo, + sshKey: sshConfig.sshKey, + sshOptions: sshConfig.sshOptions, + user: sshConfig.user, + workDir: setup.workDir, + }; + default: + return distro; + } +}) satisfies FormToGqlFunction; diff --git a/src/pages/distroSettings/tabs/HostTab/types.ts b/src/pages/distroSettings/tabs/HostTab/types.ts new file mode 100644 index 0000000000..57760f0e9d --- /dev/null +++ b/src/pages/distroSettings/tabs/HostTab/types.ts @@ -0,0 +1,39 @@ +import { + Arch, + BootstrapMethod, + CommunicationMethod, + Provider, +} from "gql/generated/types"; + +export interface HostFormState { + setup: { + bootstrapMethod: BootstrapMethod; + communicationMethod: CommunicationMethod; + arch: Arch; + workDir: string; + setupScript: string; + setupAsSudo: boolean; + }; + sshConfig: { + user: string; + sshKey: string; + authorizedKeysFile: string; + sshOptions: string[]; + }; + allocation: { + // TODO: Replace next 4 with enums. + version: string; + roundingRule: string; + feedbackRule: string; + hostsOverallocatedRule: string; + minimumHosts: number; + maximumHosts: number; + acceptableHostIdleTime: number; + futureHostFraction: number; + }; +} + +export type TabProps = { + distroData: HostFormState; + provider: Provider; +}; diff --git a/src/pages/distroSettings/tabs/index.tsx b/src/pages/distroSettings/tabs/index.tsx index d17a5f887c..972a5f4759 100644 --- a/src/pages/distroSettings/tabs/index.tsx +++ b/src/pages/distroSettings/tabs/index.tsx @@ -1,4 +1,5 @@ export { EventLogTab } from "./EventLogTab/EventLogTab"; export { GeneralTab } from "./GeneralTab/GeneralTab"; -export { TaskTab } from "./TaskTab/TaskTab"; +export { HostTab } from "./HostTab/HostTab"; export { ProjectTab } from "./ProjectTab/ProjectTab"; +export { TaskTab } from "./TaskTab/TaskTab"; diff --git a/src/pages/distroSettings/tabs/testData.ts b/src/pages/distroSettings/tabs/testData.ts index 4d36d17de8..eafd8a0d1c 100644 --- a/src/pages/distroSettings/tabs/testData.ts +++ b/src/pages/distroSettings/tabs/testData.ts @@ -1,5 +1,8 @@ import { + Arch, + BootstrapMethod, CloneMethod, + CommunicationMethod, DispatcherVersion, DistroQuery, FinderVersion, @@ -10,11 +13,11 @@ import { const distroData: DistroQuery["distro"] = { __typename: "Distro", aliases: ["rhel71-power8", "rhel71-power8-build"], - arch: "linux_ppc64le", + arch: Arch.LinuxPpc_64Bit, authorizedKeysFile: "", bootstrapSettings: { clientDir: "/home/evg/client", - communication: "legacy-ssh", + communication: CommunicationMethod.LegacySsh, env: [ { key: "foo", @@ -23,7 +26,7 @@ const distroData: DistroQuery["distro"] = { ], jasperBinaryDir: "/home/evg/jasper", jasperCredentialsPath: "/home/evg/jasper/creds.json", - method: "legacy-ssh", + method: BootstrapMethod.LegacySsh, preconditionScripts: [], resourceLimits: { lockedMemoryKb: -1, diff --git a/src/pages/distroSettings/tabs/transformers.ts b/src/pages/distroSettings/tabs/transformers.ts index 29ae7ddd32..48c72fbd68 100644 --- a/src/pages/distroSettings/tabs/transformers.ts +++ b/src/pages/distroSettings/tabs/transformers.ts @@ -1,6 +1,7 @@ import { DistroSettingsTabRoutes } from "constants/routes"; import { DistroInput } from "gql/generated/types"; import * as general from "./GeneralTab/transformers"; +import * as host from "./HostTab/transformers"; import * as project from "./ProjectTab/transformers"; import * as task from "./TaskTab/transformers"; import { @@ -16,7 +17,7 @@ export const formToGqlMap: { [T in WritableDistroSettingsType]: FormToGqlFunction; } = { [DistroSettingsTabRoutes.General]: general.formToGql, - [DistroSettingsTabRoutes.Host]: () => fakeReturn, + [DistroSettingsTabRoutes.Host]: host.formToGql, [DistroSettingsTabRoutes.Project]: project.formToGql, [DistroSettingsTabRoutes.Provider]: () => fakeReturn, [DistroSettingsTabRoutes.Task]: task.formToGql, @@ -26,7 +27,7 @@ export const gqlToFormMap: { [T in WritableDistroSettingsType]?: GqlToFormFunction; } = { [DistroSettingsTabRoutes.General]: general.gqlToForm, - [DistroSettingsTabRoutes.Host]: () => fakeReturn, + [DistroSettingsTabRoutes.Host]: host.gqlToForm, [DistroSettingsTabRoutes.Project]: project.gqlToForm, [DistroSettingsTabRoutes.Provider]: () => fakeReturn, [DistroSettingsTabRoutes.Task]: task.gqlToForm, diff --git a/src/pages/distroSettings/tabs/types.ts b/src/pages/distroSettings/tabs/types.ts index 51218c2ce4..7fe065fc13 100644 --- a/src/pages/distroSettings/tabs/types.ts +++ b/src/pages/distroSettings/tabs/types.ts @@ -1,6 +1,7 @@ import { DistroSettingsTabRoutes } from "constants/routes"; import { DistroQuery, DistroInput } from "gql/generated/types"; import { GeneralFormState } from "./GeneralTab/types"; +import { HostFormState } from "./HostTab/types"; import { ProjectFormState } from "./ProjectTab/types"; import { TaskFormState } from "./TaskTab/types"; @@ -16,7 +17,7 @@ export type FormStateMap = { [DistroSettingsTabRoutes.General]: GeneralFormState; [DistroSettingsTabRoutes.Provider]: any; [DistroSettingsTabRoutes.Task]: TaskFormState; - [DistroSettingsTabRoutes.Host]: any; + [DistroSettingsTabRoutes.Host]: HostFormState; [DistroSettingsTabRoutes.Project]: ProjectFormState; }[T]; }; From 45f67a6ffaa5365371a04aeab01554cd32725f8c Mon Sep 17 00:00:00 2001 From: Sophie Stadler Date: Wed, 30 Aug 2023 17:50:45 -0400 Subject: [PATCH 2/8] Update transformers and add tests --- src/gql/generated/types.ts | 50 ++++-- src/pages/distroSettings/HeaderButtons.tsx | 27 ++-- src/pages/distroSettings/Tabs.tsx | 8 +- .../tabs/HostTab/getFormSchema.ts | 108 +++++++++---- .../tabs/HostTab/schemaFields.ts | 32 ---- .../tabs/HostTab/transformers.test.ts | 75 +++++++++ .../tabs/HostTab/transformers.ts | 144 ++++++++---------- .../distroSettings/tabs/HostTab/types.ts | 13 +- src/pages/distroSettings/tabs/testData.ts | 12 +- src/pages/distroSettings/tabs/types.ts | 1 - 10 files changed, 285 insertions(+), 185 deletions(-) delete mode 100644 src/pages/distroSettings/tabs/HostTab/schemaFields.ts create mode 100644 src/pages/distroSettings/tabs/HostTab/transformers.test.ts diff --git a/src/gql/generated/types.ts b/src/gql/generated/types.ts index b2c98d7d21..a618d6e104 100644 --- a/src/gql/generated/types.ts +++ b/src/gql/generated/types.ts @@ -61,14 +61,12 @@ export type Annotation = { }; export enum Arch { - Linux_32Bit = "LINUX_32_BIT", Linux_64Bit = "LINUX_64_BIT", LinuxArm_64Bit = "LINUX_ARM_64_BIT", LinuxPpc_64Bit = "LINUX_PPC_64_BIT", LinuxZseries = "LINUX_ZSERIES", Osx_64Bit = "OSX_64_BIT", OsxArm_64Bit = "OSX_ARM_64_BIT", - Windows_32Bit = "WINDOWS_32_BIT", Windows_64Bit = "WINDOWS_64_BIT", } @@ -527,6 +525,12 @@ export type ExternalLinkInput = { urlTemplate: Scalars["String"]; }; +export enum FeedbackRule { + Default = "DEFAULT", + NoFeedback = "NO_FEEDBACK", + WaitsOverThresh = "WAITS_OVER_THRESH", +} + export type File = { __typename?: "File"; link: Scalars["String"]; @@ -685,26 +689,30 @@ export type Host = { export type HostAllocatorSettings = { __typename?: "HostAllocatorSettings"; acceptableHostIdleTime: Scalars["Duration"]; - feedbackRule: Scalars["String"]; + feedbackRule: FeedbackRule; futureHostFraction: Scalars["Float"]; - hostsOverallocatedRule: Scalars["String"]; + hostsOverallocatedRule: OverallocatedRule; maximumHosts: Scalars["Int"]; minimumHosts: Scalars["Int"]; - roundingRule: Scalars["String"]; - version: Scalars["String"]; + roundingRule: RoundingRule; + version: HostAllocatorVersion; }; export type HostAllocatorSettingsInput = { acceptableHostIdleTime: Scalars["Int"]; - feedbackRule: Scalars["String"]; + feedbackRule: FeedbackRule; futureHostFraction: Scalars["Float"]; - hostsOverallocatedRule: Scalars["String"]; + hostsOverallocatedRule: OverallocatedRule; maximumHosts: Scalars["Int"]; minimumHosts: Scalars["Int"]; - roundingRule: Scalars["String"]; - version: Scalars["String"]; + roundingRule: RoundingRule; + version: HostAllocatorVersion; }; +export enum HostAllocatorVersion { + Utilization = "UTILIZATION", +} + export type HostEventLogData = { __typename?: "HostEventLogData"; agentBuild: Scalars["String"]; @@ -1337,6 +1345,12 @@ export type OomTrackerInfo = { pids?: Maybe>>; }; +export enum OverallocatedRule { + Default = "DEFAULT", + Ignore = "IGNORE", + Terminate = "TERMINATE", +} + export type Parameter = { __typename?: "Parameter"; key: Scalars["String"]; @@ -2181,6 +2195,12 @@ export type ResourceLimitsInput = { virtualMemoryKb: Scalars["Int"]; }; +export enum RoundingRule { + Default = "DEFAULT", + Down = "DOWN", + Up = "UP", +} + /** SaveDistroInput is the input to the saveDistro mutation. */ export type SaveDistroInput = { distro: DistroInput; @@ -2724,6 +2744,7 @@ export type TriggerAlias = { project: Scalars["String"]; status: Scalars["String"]; taskRegex: Scalars["String"]; + unscheduleDownstreamVersions?: Maybe; }; export type TriggerAliasInput = { @@ -2735,6 +2756,7 @@ export type TriggerAliasInput = { project: Scalars["String"]; status: Scalars["String"]; taskRegex: Scalars["String"]; + unscheduleDownstreamVersions?: InputMaybe; }; export type UiConfig = { @@ -5212,13 +5234,13 @@ export type DistroQuery = { hostAllocatorSettings: { __typename?: "HostAllocatorSettings"; acceptableHostIdleTime: number; - feedbackRule: string; + feedbackRule: FeedbackRule; futureHostFraction: number; - hostsOverallocatedRule: string; + hostsOverallocatedRule: OverallocatedRule; maximumHosts: number; minimumHosts: number; - roundingRule: string; - version: string; + roundingRule: RoundingRule; + version: HostAllocatorVersion; }; iceCreamSettings: { __typename?: "IceCreamSettings"; diff --git a/src/pages/distroSettings/HeaderButtons.tsx b/src/pages/distroSettings/HeaderButtons.tsx index 4231d6b369..14abbbbb4f 100644 --- a/src/pages/distroSettings/HeaderButtons.tsx +++ b/src/pages/distroSettings/HeaderButtons.tsx @@ -18,7 +18,7 @@ import { import { SAVE_DISTRO } from "gql/mutations"; import { useDistroSettingsContext } from "./Context"; import { formToGqlMap } from "./tabs/transformers"; -import { WritableDistroSettingsType } from "./tabs/types"; +import { FormToGqlFunction, WritableDistroSettingsType } from "./tabs/types"; interface Props { distro: DistroQuery["distro"]; @@ -61,21 +61,16 @@ export const HeaderButtons: React.FC = ({ distro, tab }) => { }); const handleSave = () => { - // Only perform the save operation is the tab is valid. - // eslint-disable-next-line no-prototype-builtins - if (formToGqlMap.hasOwnProperty(tab)) { - const formToGql = formToGqlMap[tab]; - // @ts-expect-error - const changes = formToGql(formData, distro); - saveDistro({ - variables: { - distro: changes, - onSave: onSaveOperation, - }, - }); - setModalOpen(false); - sendEvent({ name: "Save distro", section: tab }); - } + const formToGql: FormToGqlFunction = formToGqlMap[tab]; + const changes = formToGql(formData, distro); + saveDistro({ + variables: { + distro: changes, + onSave: onSaveOperation, + }, + }); + setModalOpen(false); + sendEvent({ name: "Save distro", section: tab }); }; return ( diff --git a/src/pages/distroSettings/Tabs.tsx b/src/pages/distroSettings/Tabs.tsx index bbbf384cec..65e5a374af 100644 --- a/src/pages/distroSettings/Tabs.tsx +++ b/src/pages/distroSettings/Tabs.tsx @@ -15,6 +15,7 @@ import { TaskTab, } from "./tabs/index"; import { gqlToFormMap } from "./tabs/transformers"; +import { FormStateMap } from "./tabs/types"; interface Props { distro: DistroQuery["distro"]; @@ -27,7 +28,6 @@ export const DistroSettingsTabs: React.FC = ({ distro }) => { const tabData = useMemo(() => getTabData(distro), [distro]); useEffect(() => { - // @ts-expect-error TODO: Type when all tabs have been implemented setInitialData(tabData); }, [setInitialData, tabData]); @@ -84,15 +84,13 @@ export const DistroSettingsTabs: React.FC = ({ distro }) => { ); }; -/* Map data from query to the tab to which it will be passed */ -// TODO: Type when all tabs have been implemented -const getTabData = (data: Props["distro"]) => +const getTabData = (data: Props["distro"]): FormStateMap => Object.keys(gqlToFormMap).reduce( (obj, tab) => ({ ...obj, [tab]: gqlToFormMap[tab](data), }), - {} + {} as FormStateMap ); const Container = styled.div` diff --git a/src/pages/distroSettings/tabs/HostTab/getFormSchema.ts b/src/pages/distroSettings/tabs/HostTab/getFormSchema.ts index e30854ec0f..13a8493987 100644 --- a/src/pages/distroSettings/tabs/HostTab/getFormSchema.ts +++ b/src/pages/distroSettings/tabs/HostTab/getFormSchema.ts @@ -4,7 +4,11 @@ import { Arch, BootstrapMethod, CommunicationMethod, + FeedbackRule, + HostAllocatorVersion, + OverallocatedRule, Provider, + RoundingRule, } from "gql/generated/types"; type FormSchemaParams = { @@ -29,35 +33,17 @@ export const getFormSchema = ({ bootstrapMethod: { type: "string" as "string", title: "Host Bootstrap Method", - oneOf: Object.entries(bootstrapMethodToCopy).map( - ([value, title]) => ({ - type: "string" as "string", - title, - enum: [value], - }) - ), + oneOf: enumSelect(bootstrapMethodToCopy), }, communicationMethod: { type: "string" as "string", title: "Host Communication Method", - oneOf: Object.entries(communicationMethodToCopy).map( - ([value, title]) => ({ - type: "string" as "string", - title, - enum: [value], - }) - ), + oneOf: enumSelect(communicationMethodToCopy), }, arch: { type: "string" as "string", title: "Agent Architecture", - oneOf: Object.entries(architectureToCopy).map( - ([value, title]) => ({ - type: "string" as "string", - title, - enum: [value], - }) - ), + oneOf: enumSelect(architectureToCopy), }, workDir: { type: "string" as "string", @@ -106,26 +92,32 @@ export const getFormSchema = ({ version: { type: "string" as "string", title: "Host Allocator Version", + oneOf: enumSelect(hostAllocatorVersionToCopy), }, roundingRule: { type: "string" as "string", title: "Host Allocator Rounding Rule", + oneOf: enumSelect(roundingRuleToCopy), }, feedbackRule: { type: "string" as "string", title: "Host Allocator Feedback Rule", + oneOf: enumSelect(feedbackRuleToCopy), }, hostsOverallocatedRule: { type: "string" as "string", title: "Host Overallocation Rule", + oneOf: enumSelect(overallocatedRuleToCopy), }, minimumHosts: { type: "number" as "number", title: "Minimum Number of Hosts Allowed", + minimum: 0, }, maximumHosts: { type: "number" as "number", title: "Maxiumum Number of Hosts Allowed", + minimum: 0, }, acceptableHostIdleTime: { type: "number" as "number", @@ -134,6 +126,8 @@ export const getFormSchema = ({ futureHostFraction: { type: "number" as "number", title: "Future Host Fraction", + minimum: 0, + maximum: 1, }, }, }, @@ -162,29 +156,59 @@ export const getFormSchema = ({ }, sshOptions: { "ui:addButtonText": "Add SSH option", + "ui:orderable": false, }, }, allocation: { "ui:ObjectFieldTemplate": CardFieldTemplate, - ...((hasStaticProvider || hasDockerProvider) && { - minimumHosts: { + version: { + "ui:allowDeselect": false, + }, + roundingRule: { + "ui:allowDeselect": false, + }, + feedbackRule: { + "ui:allowDeselect": false, + }, + hostsOverallocatedRule: { + "ui:allowDeselect": false, + }, + minimumHosts: { + "ui:data-cy": "minimum-hosts-input", + ...((hasStaticProvider || hasDockerProvider) && { "ui:widget": "hidden", - }, - maximumHosts: { + }), + }, + maximumHosts: { + "ui:data-cy": "maximum-hosts-input", + ...((hasStaticProvider || hasDockerProvider) && { "ui:widget": "hidden", - }, - acceptableHostIdleTime: { + }), + }, + acceptableHostIdleTime: { + "ui:data-cy": "idle-time-input", + ...((hasStaticProvider || hasDockerProvider) && { "ui:widget": "hidden", - }, - futureHostFraction: { + }), + }, + futureHostFraction: { + "ui:data-cy": "future-fraction-input", + ...((hasStaticProvider || hasDockerProvider) && { "ui:widget": "hidden", - }, - }), + }), + }, }, }, }; }; +const enumSelect = (enumObject: Record) => + Object.entries(enumObject).map(([value, title]) => ({ + type: "string" as "string", + title, + enum: [value], + })); + const architectureToCopy = { [Arch.Linux_64Bit]: "Linux 64-bit", [Arch.LinuxArm_64Bit]: "Linux ARM 64-bit", @@ -206,3 +230,25 @@ const communicationMethodToCopy = { [CommunicationMethod.Ssh]: "SSH", [CommunicationMethod.Rpc]: "RPC", }; + +const hostAllocatorVersionToCopy = { + [HostAllocatorVersion.Utilization]: "Utilization", +}; + +const roundingRuleToCopy = { + [RoundingRule.Default]: "Default", + [RoundingRule.Down]: "Round down", + [RoundingRule.Up]: "Round up", +}; + +const feedbackRuleToCopy = { + [FeedbackRule.Default]: "Default", + [FeedbackRule.NoFeedback]: "No feedback", + [FeedbackRule.WaitsOverThresh]: "Wait over threshold", +}; + +const overallocatedRuleToCopy = { + [OverallocatedRule.Default]: "Default", + [OverallocatedRule.Ignore]: "No terminations when overallocated", + [OverallocatedRule.Terminate]: "Terminate hosts when overallocated", +}; diff --git a/src/pages/distroSettings/tabs/HostTab/schemaFields.ts b/src/pages/distroSettings/tabs/HostTab/schemaFields.ts deleted file mode 100644 index f47969f520..0000000000 --- a/src/pages/distroSettings/tabs/HostTab/schemaFields.ts +++ /dev/null @@ -1,32 +0,0 @@ -export const version = { - type: "string" as "string", - title: "Host Allocator Version", -}; -export const roundingRule = { - type: "string" as "string", - title: "Host Allocator Rounding Rule", -}; -export const feedbackRule = { - type: "string" as "string", - title: "Host Allocator Feedback Rule", -}; -export const hostsOverallocatedRule = { - type: "string" as "string", - title: "Host Overallocation Rule", -}; -export const minimumHosts = { - type: "number" as "number", - title: "Minimum Number of Hosts Allowed", -}; -export const maximumHosts = { - type: "number" as "number", - title: "Maxiumum Number of Hosts Allowed", -}; -export const acceptableHostIdleTime = { - type: "number" as "number", - title: "Acceptable Host Idle Time (s)", -}; -export const futureHostFraction = { - type: "number" as "number", - title: "Future Host Fraction", -}; diff --git a/src/pages/distroSettings/tabs/HostTab/transformers.test.ts b/src/pages/distroSettings/tabs/HostTab/transformers.test.ts new file mode 100644 index 0000000000..dbc68c4b10 --- /dev/null +++ b/src/pages/distroSettings/tabs/HostTab/transformers.test.ts @@ -0,0 +1,75 @@ +import { + Arch, + BootstrapMethod, + CommunicationMethod, + DistroInput, + FeedbackRule, + HostAllocatorVersion, + OverallocatedRule, + RoundingRule, +} from "gql/generated/types"; +import { distroData } from "../testData"; +import { formToGql, gqlToForm } from "./transformers"; +import { HostFormState } from "./types"; + +describe("provider tab", () => { + describe("static provider", () => { + it("correctly converts from GQL to a form", () => { + expect(gqlToForm(distroData)).toStrictEqual(form); + }); + + it("correctly converts from a form to GQL", () => { + expect(formToGql(form, distroData)).toStrictEqual(gql); + }); + }); +}); + +const form: HostFormState = { + setup: { + bootstrapMethod: BootstrapMethod.LegacySsh, + communicationMethod: CommunicationMethod.LegacySsh, + arch: Arch.LinuxPpc_64Bit, + workDir: "/data/evg", + setupScript: "ls -alF", + setupAsSudo: true, + }, + sshConfig: { + user: "admin", + sshKey: "fakeSshKey", + authorizedKeysFile: "", + sshOptions: ["BatchMode=yes", "ConnectTimeout=10"], + }, + allocation: { + version: HostAllocatorVersion.Utilization, + roundingRule: RoundingRule.Default, + feedbackRule: FeedbackRule.Default, + hostsOverallocatedRule: OverallocatedRule.Default, + minimumHosts: 0, + maximumHosts: 0, + acceptableHostIdleTime: 0, + futureHostFraction: 0, + }, +}; + +const gql: DistroInput = { + ...distroData, + bootstrapSettings: { + clientDir: "", + communication: CommunicationMethod.LegacySsh, + env: [], + jasperBinaryDir: "", + jasperCredentialsPath: "", + method: BootstrapMethod.LegacySsh, + preconditionScripts: [], + resourceLimits: { + lockedMemoryKb: 0, + numFiles: 0, + numProcesses: 0, + numTasks: 0, + virtualMemoryKb: 0, + }, + rootDir: "", + serviceUser: "", + shellPath: "", + }, +}; diff --git a/src/pages/distroSettings/tabs/HostTab/transformers.ts b/src/pages/distroSettings/tabs/HostTab/transformers.ts index 1bd029020e..53c9cc7645 100644 --- a/src/pages/distroSettings/tabs/HostTab/transformers.ts +++ b/src/pages/distroSettings/tabs/HostTab/transformers.ts @@ -1,5 +1,4 @@ import { DistroSettingsTabRoutes } from "constants/routes"; -import { BootstrapMethod } from "gql/generated/types"; import { FormToGqlFunction, GqlToFormFunction } from "../types"; type Tab = DistroSettingsTabRoutes.Host; @@ -20,85 +19,76 @@ export const gqlToForm = ((data) => { workDir, } = data; - switch (method) { - case BootstrapMethod.LegacySsh: - return { - setup: { - bootstrapMethod: method, - communicationMethod: communication, - arch, - workDir, - setupScript: setup, - setupAsSudo, - }, - sshConfig: { - user, - sshKey, - authorizedKeysFile, - sshOptions, - }, - allocation: { - version: hostAllocatorSettings.version, - roundingRule: hostAllocatorSettings.roundingRule, - feedbackRule: hostAllocatorSettings.feedbackRule, - hostsOverallocatedRule: hostAllocatorSettings.hostsOverallocatedRule, - minimumHosts: hostAllocatorSettings.minimumHosts, - maximumHosts: hostAllocatorSettings.maximumHosts, - acceptableHostIdleTime: hostAllocatorSettings.acceptableHostIdleTime, - futureHostFraction: hostAllocatorSettings.futureHostFraction, - }, - }; - default: - throw new Error(`Unknown bootstrap method '${method}'`); - } + return { + setup: { + bootstrapMethod: method, + communicationMethod: communication, + arch, + workDir, + setupScript: setup, + setupAsSudo, + }, + sshConfig: { + user, + sshKey, + authorizedKeysFile, + sshOptions, + }, + allocation: { + version: hostAllocatorSettings.version, + roundingRule: hostAllocatorSettings.roundingRule, + feedbackRule: hostAllocatorSettings.feedbackRule, + hostsOverallocatedRule: hostAllocatorSettings.hostsOverallocatedRule, + minimumHosts: hostAllocatorSettings.minimumHosts, + maximumHosts: hostAllocatorSettings.maximumHosts, + acceptableHostIdleTime: hostAllocatorSettings.acceptableHostIdleTime, + futureHostFraction: hostAllocatorSettings.futureHostFraction, + }, + }; }) satisfies GqlToFormFunction; export const formToGql = (({ allocation, setup, sshConfig }, distro) => { const { bootstrapMethod } = setup; - switch (bootstrapMethod) { - case BootstrapMethod.LegacySsh: - return { - ...distro, - arch: setup.arch, - authorizedKeysFile: sshConfig.authorizedKeysFile, - bootstrapSettings: { - clientDir: "", - communication: setup.communicationMethod, - env: [], - jasperBinaryDir: "", - jasperCredentialsPath: "", - method: bootstrapMethod, - preconditionScripts: [], - resourceLimits: { - lockedMemoryKb: 0, - numFiles: 0, - numProcesses: 0, - numTasks: 0, - virtualMemoryKb: 0, - }, - rootDir: "", - serviceUser: "", - shellPath: "", - }, - hostAllocatorSettings: { - acceptableHostIdleTime: allocation.acceptableHostIdleTime, - feedbackRule: allocation.feedbackRule, - futureHostFraction: allocation.futureHostFraction, - hostsOverallocatedRule: allocation.hostsOverallocatedRule, - maximumHosts: allocation.maximumHosts, - minimumHosts: allocation.minimumHosts, - roundingRule: allocation.roundingRule, - version: allocation.version, - }, - setup: setup.setupScript, - setupAsSudo: setup.setupAsSudo, - sshKey: sshConfig.sshKey, - sshOptions: sshConfig.sshOptions, - user: sshConfig.user, - workDir: setup.workDir, - }; - default: - return distro; - } + return { + ...distro, + arch: setup.arch, + authorizedKeysFile: sshConfig.authorizedKeysFile, + bootstrapSettings: { + // TODO: Set fields when they've been added to the form state. + clientDir: "", + communication: setup.communicationMethod, + env: [], + jasperBinaryDir: "", + jasperCredentialsPath: "", + method: bootstrapMethod, + preconditionScripts: [], + resourceLimits: { + lockedMemoryKb: 0, + numFiles: 0, + numProcesses: 0, + numTasks: 0, + virtualMemoryKb: 0, + }, + rootDir: "", + serviceUser: "", + shellPath: "", + }, + hostAllocatorSettings: { + acceptableHostIdleTime: allocation.acceptableHostIdleTime, + feedbackRule: allocation.feedbackRule, + futureHostFraction: allocation.futureHostFraction, + hostsOverallocatedRule: allocation.hostsOverallocatedRule, + maximumHosts: allocation.maximumHosts, + minimumHosts: allocation.minimumHosts, + roundingRule: allocation.roundingRule, + version: allocation.version, + }, + setup: setup.setupScript, + setupAsSudo: setup.setupAsSudo, + sshKey: sshConfig.sshKey, + sshOptions: sshConfig.sshOptions, + user: sshConfig.user, + workDir: setup.workDir, + }; }) satisfies FormToGqlFunction; diff --git a/src/pages/distroSettings/tabs/HostTab/types.ts b/src/pages/distroSettings/tabs/HostTab/types.ts index 57760f0e9d..de4bf53651 100644 --- a/src/pages/distroSettings/tabs/HostTab/types.ts +++ b/src/pages/distroSettings/tabs/HostTab/types.ts @@ -2,7 +2,11 @@ import { Arch, BootstrapMethod, CommunicationMethod, + FeedbackRule, + HostAllocatorVersion, + OverallocatedRule, Provider, + RoundingRule, } from "gql/generated/types"; export interface HostFormState { @@ -21,11 +25,10 @@ export interface HostFormState { sshOptions: string[]; }; allocation: { - // TODO: Replace next 4 with enums. - version: string; - roundingRule: string; - feedbackRule: string; - hostsOverallocatedRule: string; + version: HostAllocatorVersion; + roundingRule: RoundingRule; + feedbackRule: FeedbackRule; + hostsOverallocatedRule: OverallocatedRule; minimumHosts: number; maximumHosts: number; acceptableHostIdleTime: number; diff --git a/src/pages/distroSettings/tabs/testData.ts b/src/pages/distroSettings/tabs/testData.ts index 88fe5a4bcf..6cc5bdc1e5 100644 --- a/src/pages/distroSettings/tabs/testData.ts +++ b/src/pages/distroSettings/tabs/testData.ts @@ -5,9 +5,13 @@ import { CommunicationMethod, DispatcherVersion, DistroQuery, + FeedbackRule, FinderVersion, + HostAllocatorVersion, + OverallocatedRule, PlannerVersion, Provider, + RoundingRule, } from "gql/generated/types"; const distroData: DistroQuery["distro"] = { @@ -68,13 +72,13 @@ const distroData: DistroQuery["distro"] = { }, hostAllocatorSettings: { acceptableHostIdleTime: 0, - feedbackRule: "", + feedbackRule: FeedbackRule.Default, futureHostFraction: 0, - hostsOverallocatedRule: "", + hostsOverallocatedRule: OverallocatedRule.Default, maximumHosts: 0, minimumHosts: 0, - roundingRule: "", - version: "utilization", + roundingRule: RoundingRule.Default, + version: HostAllocatorVersion.Utilization, }, iceCreamSettings: { configPath: "", diff --git a/src/pages/distroSettings/tabs/types.ts b/src/pages/distroSettings/tabs/types.ts index f0af1036cf..788f0e731f 100644 --- a/src/pages/distroSettings/tabs/types.ts +++ b/src/pages/distroSettings/tabs/types.ts @@ -12,7 +12,6 @@ export { WritableDistroSettingsTabs }; export type WritableDistroSettingsType = (typeof WritableDistroSettingsTabs)[keyof typeof WritableDistroSettingsTabs]; -// TODO: Specify type as tabs are added. export type FormStateMap = { [T in WritableDistroSettingsType]: { [DistroSettingsTabRoutes.General]: GeneralFormState; From 8975cc3616c9696bdd3634c33a970d89bb4cca2e Mon Sep 17 00:00:00 2001 From: Sophie Stadler Date: Thu, 31 Aug 2023 11:59:34 -0400 Subject: [PATCH 3/8] Add cypress test --- .../distroSettings/host_section.ts | 57 +++++++++++++++++++ .../tabs/HostTab/getFormSchema.ts | 3 + 2 files changed, 60 insertions(+) create mode 100644 cypress/integration/distroSettings/host_section.ts diff --git a/cypress/integration/distroSettings/host_section.ts b/cypress/integration/distroSettings/host_section.ts new file mode 100644 index 0000000000..581f30d75e --- /dev/null +++ b/cypress/integration/distroSettings/host_section.ts @@ -0,0 +1,57 @@ +import { save } from "./utils"; + +describe("host section", () => { + describe("using legacy ssh", () => { + beforeEach(() => { + cy.visit("/distro/localhost/settings/host"); + }); + + it("shows the correct fields when distro has static provider", () => { + cy.dataCy("authorized-keys-input").should("exist"); + cy.dataCy("minimum-hosts-input").should("not.exist"); + cy.dataCy("maximum-hosts-input").should("not.exist"); + cy.dataCy("idle-time-input").should("not.exist"); + cy.dataCy("future-fraction-input").should("not.exist"); + }); + + it("errors when selecting an incompatible host communication method", () => { + cy.selectLGOption("Host Communication Method", "RPC"); + save(); + cy.validateToast("error"); + cy.selectLGOption("Host Communication Method", "Legacy SSH"); + }); + + it("updates host fields", () => { + cy.selectLGOption("Agent Architecture", "Linux ARM 64-bit"); + cy.getInputByLabel("Working Directory").clear(); + cy.getInputByLabel("Working Directory").type("/usr/local/bin"); + cy.getInputByLabel("SSH User").clear(); + cy.getInputByLabel("SSH User").type("sudo"); + cy.contains("button", "Add SSH option").click(); + cy.getInputByLabel("SSH Option").type("BatchMode=yes"); + cy.selectLGOption("Host Allocator Rounding Rule", "Round down"); + cy.selectLGOption("Host Allocator Feedback Rule", "No feedback"); + cy.selectLGOption( + "Host Overallocation Rule", + "Terminate hosts when overallocated" + ); + + save(); + cy.validateToast("success"); + + // Reset fields + cy.selectLGOption("Agent Architecture", "Linux 64-bit"); + cy.getInputByLabel("Working Directory").clear(); + cy.getInputByLabel("Working Directory").type("/home/ubuntu/smoke"); + cy.getInputByLabel("SSH User").clear(); + cy.getInputByLabel("SSH User").type("ubuntu"); + cy.dataCy("delete-item-button").click(); + cy.selectLGOption("Host Allocator Rounding Rule", "Default"); + cy.selectLGOption("Host Allocator Feedback Rule", "Default"); + cy.selectLGOption("Host Overallocation Rule", "Default"); + + save(); + cy.validateToast("success"); + }); + }); +}); diff --git a/src/pages/distroSettings/tabs/HostTab/getFormSchema.ts b/src/pages/distroSettings/tabs/HostTab/getFormSchema.ts index 13a8493987..7272a460aa 100644 --- a/src/pages/distroSettings/tabs/HostTab/getFormSchema.ts +++ b/src/pages/distroSettings/tabs/HostTab/getFormSchema.ts @@ -81,6 +81,8 @@ export const getFormSchema = ({ items: { type: "string" as "string", title: "SSH Option", + default: "", + minLength: 1, }, }, }, @@ -152,6 +154,7 @@ export const getFormSchema = ({ sshConfig: { "ui:ObjectFieldTemplate": CardFieldTemplate, authorizedKeysFile: { + "ui:data-cy": "authorized-keys-input", ...(!hasStaticProvider && { "ui:widget": "hidden" }), }, sshOptions: { From d065bb7b0c66cba5de9774a7e818b3d6dce586b6 Mon Sep 17 00:00:00 2001 From: Sophie Stadler Date: Thu, 31 Aug 2023 12:20:15 -0400 Subject: [PATCH 4/8] Handle unvalidated dynamic method call --- src/pages/distroSettings/HeaderButtons.tsx | 24 +++++++++++++--------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/pages/distroSettings/HeaderButtons.tsx b/src/pages/distroSettings/HeaderButtons.tsx index 14abbbbb4f..4818fbf476 100644 --- a/src/pages/distroSettings/HeaderButtons.tsx +++ b/src/pages/distroSettings/HeaderButtons.tsx @@ -61,16 +61,20 @@ export const HeaderButtons: React.FC = ({ distro, tab }) => { }); const handleSave = () => { - const formToGql: FormToGqlFunction = formToGqlMap[tab]; - const changes = formToGql(formData, distro); - saveDistro({ - variables: { - distro: changes, - onSave: onSaveOperation, - }, - }); - setModalOpen(false); - sendEvent({ name: "Save distro", section: tab }); + // Only perform the save operation is the tab is valid. + // eslint-disable-next-line no-prototype-builtins + if (formToGqlMap.hasOwnProperty(tab)) { + const formToGql: FormToGqlFunction = formToGqlMap[tab]; + const changes = formToGql(formData, distro); + saveDistro({ + variables: { + distro: changes, + onSave: onSaveOperation, + }, + }); + setModalOpen(false); + sendEvent({ name: "Save distro", section: tab }); + } }; return ( From 22cc671c85ff7795c10922c1d0f113de55f0730d Mon Sep 17 00:00:00 2001 From: Sophie Stadler Date: Tue, 5 Sep 2023 15:00:26 -0400 Subject: [PATCH 5/8] Address CR comments --- .../distroSettings/host_section.ts | 5 +- src/gql/generated/types.ts | 8 ++ src/gql/mocks/getSpruceConfig.ts | 6 ++ src/gql/queries/get-spruce-config.graphql | 4 + .../distroSettings/tabs/HostTab/HostTab.tsx | 13 +-- .../{getFormSchema.ts => getFormSchema.tsx} | 41 ++++++--- .../tabs/HostTab/transformers.test.ts | 14 ++- .../tabs/HostTab/transformers.ts | 86 +++++++------------ 8 files changed, 95 insertions(+), 82 deletions(-) rename src/pages/distroSettings/tabs/HostTab/{getFormSchema.ts => getFormSchema.tsx} (87%) diff --git a/cypress/integration/distroSettings/host_section.ts b/cypress/integration/distroSettings/host_section.ts index 581f30d75e..cc7958243f 100644 --- a/cypress/integration/distroSettings/host_section.ts +++ b/cypress/integration/distroSettings/host_section.ts @@ -17,7 +17,10 @@ describe("host section", () => { it("errors when selecting an incompatible host communication method", () => { cy.selectLGOption("Host Communication Method", "RPC"); save(); - cy.validateToast("error"); + cy.validateToast( + "error", + "validating changes for distro 'localhost': 'ERROR: bootstrapping hosts using legacy SSH is incompatible with non-legacy host communication'" + ); cy.selectLGOption("Host Communication Method", "Legacy SSH"); }); diff --git a/src/gql/generated/types.ts b/src/gql/generated/types.ts index ecf073d85a..b14c75ef82 100644 --- a/src/gql/generated/types.ts +++ b/src/gql/generated/types.ts @@ -2212,6 +2212,12 @@ export enum RoundingRule { Up = "UP", } +export type SshKey = { + __typename?: "SSHKey"; + location: Scalars["String"]["output"]; + name: Scalars["String"]["output"]; +}; + /** SaveDistroInput is the input to the saveDistro mutation. */ export type SaveDistroInput = { distro: DistroInput; @@ -2326,6 +2332,7 @@ export type SpruceConfig = { bannerTheme?: Maybe; githubOrgs: Array; jira?: Maybe; + keys: Array; providers?: Maybe; slack?: Maybe; spawnHost: SpawnHostConfig; @@ -7789,6 +7796,7 @@ export type SpruceConfigQuery = { banner?: string | null; bannerTheme?: string | null; jira?: { __typename?: "JiraConfig"; host?: string | null } | null; + keys: Array<{ __typename?: "SSHKey"; location: string; name: string }>; providers?: { __typename?: "CloudProviderConfig"; aws?: { diff --git a/src/gql/mocks/getSpruceConfig.ts b/src/gql/mocks/getSpruceConfig.ts index 2fc31490b0..5061ee8cc2 100644 --- a/src/gql/mocks/getSpruceConfig.ts +++ b/src/gql/mocks/getSpruceConfig.ts @@ -22,6 +22,12 @@ export const getSpruceConfigMock: ApolloMock< defaultProject: "evergreen", __typename: "UIConfig", }, + keys: [ + { + name: "fake_key", + location: "/path/to/key", + }, + ], jira: { host: "jira.mongodb.org", __typename: "JiraConfig" }, providers: { aws: { diff --git a/src/gql/queries/get-spruce-config.graphql b/src/gql/queries/get-spruce-config.graphql index a89b2e2fd6..aebb318891 100644 --- a/src/gql/queries/get-spruce-config.graphql +++ b/src/gql/queries/get-spruce-config.graphql @@ -5,6 +5,10 @@ query SpruceConfig { jira { host } + keys { + location + name + } providers { aws { maxVolumeSizePerUser diff --git a/src/pages/distroSettings/tabs/HostTab/HostTab.tsx b/src/pages/distroSettings/tabs/HostTab/HostTab.tsx index 406b071de7..f7df1db7db 100644 --- a/src/pages/distroSettings/tabs/HostTab/HostTab.tsx +++ b/src/pages/distroSettings/tabs/HostTab/HostTab.tsx @@ -1,14 +1,17 @@ import { useMemo } from "react"; +import { useSpruceConfig } from "hooks"; import { BaseTab } from "../BaseTab"; import { getFormSchema } from "./getFormSchema"; import { TabProps } from "./types"; export const HostTab: React.FC = ({ distroData, provider }) => { - const initialFormState = distroData; + const spruceConfig = useSpruceConfig(); + const sshKeys = spruceConfig?.keys; - const formSchema = useMemo(() => getFormSchema({ provider }), [provider]); - - return ( - + const formSchema = useMemo( + () => getFormSchema({ provider, sshKeys }), + [provider, sshKeys] ); + + return ; }; diff --git a/src/pages/distroSettings/tabs/HostTab/getFormSchema.ts b/src/pages/distroSettings/tabs/HostTab/getFormSchema.tsx similarity index 87% rename from src/pages/distroSettings/tabs/HostTab/getFormSchema.ts rename to src/pages/distroSettings/tabs/HostTab/getFormSchema.tsx index 7272a460aa..d284dc5415 100644 --- a/src/pages/distroSettings/tabs/HostTab/getFormSchema.ts +++ b/src/pages/distroSettings/tabs/HostTab/getFormSchema.tsx @@ -1,3 +1,4 @@ +import { InlineCode } from "@leafygreen-ui/typography"; import { GetFormSchema } from "components/SpruceForm"; import { CardFieldTemplate } from "components/SpruceForm/FieldTemplates"; import { @@ -9,17 +10,21 @@ import { OverallocatedRule, Provider, RoundingRule, + SshKey, } from "gql/generated/types"; type FormSchemaParams = { provider: Provider; + sshKeys: SshKey[]; }; export const getFormSchema = ({ provider, + sshKeys, }: FormSchemaParams): ReturnType => { const hasStaticProvider = provider === Provider.Static; const hasDockerProvider = provider === Provider.Docker; + const hasEC2Provider = !hasStaticProvider && !hasDockerProvider; return { fields: {}, @@ -70,6 +75,11 @@ export const getFormSchema = ({ sshKey: { type: "string" as "string", title: "SSH Key", + oneOf: sshKeys.map(({ location, name }) => ({ + type: "string" as "string", + title: `${name} – ${location}`, + enum: [name], + })), }, authorizedKeysFile: { type: "string" as "string", @@ -153,13 +163,24 @@ export const getFormSchema = ({ }, sshConfig: { "ui:ObjectFieldTemplate": CardFieldTemplate, + sshKey: { + "ui:allowDeselect": false, + }, authorizedKeysFile: { "ui:data-cy": "authorized-keys-input", ...(!hasStaticProvider && { "ui:widget": "hidden" }), }, sshOptions: { "ui:addButtonText": "Add SSH option", + "ui:descriptionNode": ( + <> + Option keywords supported by ssh_config. + + ), "ui:orderable": false, + items: { + "ui:placeholder": "ConnectTimeout=10", + }, }, }, allocation: { @@ -178,27 +199,19 @@ export const getFormSchema = ({ }, minimumHosts: { "ui:data-cy": "minimum-hosts-input", - ...((hasStaticProvider || hasDockerProvider) && { - "ui:widget": "hidden", - }), + ...(!hasEC2Provider && { "ui:widget": "hidden" }), }, maximumHosts: { "ui:data-cy": "maximum-hosts-input", - ...((hasStaticProvider || hasDockerProvider) && { - "ui:widget": "hidden", - }), + ...(!hasEC2Provider && { "ui:widget": "hidden" }), }, acceptableHostIdleTime: { "ui:data-cy": "idle-time-input", - ...((hasStaticProvider || hasDockerProvider) && { - "ui:widget": "hidden", - }), + ...(!hasEC2Provider && { "ui:widget": "hidden" }), }, futureHostFraction: { "ui:data-cy": "future-fraction-input", - ...((hasStaticProvider || hasDockerProvider) && { - "ui:widget": "hidden", - }), + ...(!hasEC2Provider && { "ui:widget": "hidden" }), }, }, }, @@ -206,10 +219,10 @@ export const getFormSchema = ({ }; const enumSelect = (enumObject: Record) => - Object.entries(enumObject).map(([value, title]) => ({ + Object.entries(enumObject).map(([key, title]) => ({ type: "string" as "string", title, - enum: [value], + enum: [key], })); const architectureToCopy = { diff --git a/src/pages/distroSettings/tabs/HostTab/transformers.test.ts b/src/pages/distroSettings/tabs/HostTab/transformers.test.ts index dbc68c4b10..7132ea7d2c 100644 --- a/src/pages/distroSettings/tabs/HostTab/transformers.test.ts +++ b/src/pages/distroSettings/tabs/HostTab/transformers.test.ts @@ -12,15 +12,13 @@ import { distroData } from "../testData"; import { formToGql, gqlToForm } from "./transformers"; import { HostFormState } from "./types"; -describe("provider tab", () => { - describe("static provider", () => { - it("correctly converts from GQL to a form", () => { - expect(gqlToForm(distroData)).toStrictEqual(form); - }); +describe("host tab", () => { + it("correctly converts from GQL to a form", () => { + expect(gqlToForm(distroData)).toStrictEqual(form); + }); - it("correctly converts from a form to GQL", () => { - expect(formToGql(form, distroData)).toStrictEqual(gql); - }); + it("correctly converts from a form to GQL", () => { + expect(formToGql(form, distroData)).toStrictEqual(gql); }); }); diff --git a/src/pages/distroSettings/tabs/HostTab/transformers.ts b/src/pages/distroSettings/tabs/HostTab/transformers.ts index 53c9cc7645..9356046667 100644 --- a/src/pages/distroSettings/tabs/HostTab/transformers.ts +++ b/src/pages/distroSettings/tabs/HostTab/transformers.ts @@ -34,61 +34,39 @@ export const gqlToForm = ((data) => { authorizedKeysFile, sshOptions, }, - allocation: { - version: hostAllocatorSettings.version, - roundingRule: hostAllocatorSettings.roundingRule, - feedbackRule: hostAllocatorSettings.feedbackRule, - hostsOverallocatedRule: hostAllocatorSettings.hostsOverallocatedRule, - minimumHosts: hostAllocatorSettings.minimumHosts, - maximumHosts: hostAllocatorSettings.maximumHosts, - acceptableHostIdleTime: hostAllocatorSettings.acceptableHostIdleTime, - futureHostFraction: hostAllocatorSettings.futureHostFraction, - }, + allocation: hostAllocatorSettings, }; }) satisfies GqlToFormFunction; -export const formToGql = (({ allocation, setup, sshConfig }, distro) => { - const { bootstrapMethod } = setup; - - return { - ...distro, - arch: setup.arch, - authorizedKeysFile: sshConfig.authorizedKeysFile, - bootstrapSettings: { - // TODO: Set fields when they've been added to the form state. - clientDir: "", - communication: setup.communicationMethod, - env: [], - jasperBinaryDir: "", - jasperCredentialsPath: "", - method: bootstrapMethod, - preconditionScripts: [], - resourceLimits: { - lockedMemoryKb: 0, - numFiles: 0, - numProcesses: 0, - numTasks: 0, - virtualMemoryKb: 0, - }, - rootDir: "", - serviceUser: "", - shellPath: "", +export const formToGql = (({ allocation, setup, sshConfig }, distro) => ({ + ...distro, + arch: setup.arch, + authorizedKeysFile: sshConfig.authorizedKeysFile, + bootstrapSettings: { + // TODO: Set fields when they've been added to the form state. + clientDir: "", + communication: setup.communicationMethod, + env: [], + jasperBinaryDir: "", + jasperCredentialsPath: "", + method: setup.bootstrapMethod, + preconditionScripts: [], + resourceLimits: { + lockedMemoryKb: 0, + numFiles: 0, + numProcesses: 0, + numTasks: 0, + virtualMemoryKb: 0, }, - hostAllocatorSettings: { - acceptableHostIdleTime: allocation.acceptableHostIdleTime, - feedbackRule: allocation.feedbackRule, - futureHostFraction: allocation.futureHostFraction, - hostsOverallocatedRule: allocation.hostsOverallocatedRule, - maximumHosts: allocation.maximumHosts, - minimumHosts: allocation.minimumHosts, - roundingRule: allocation.roundingRule, - version: allocation.version, - }, - setup: setup.setupScript, - setupAsSudo: setup.setupAsSudo, - sshKey: sshConfig.sshKey, - sshOptions: sshConfig.sshOptions, - user: sshConfig.user, - workDir: setup.workDir, - }; -}) satisfies FormToGqlFunction; + rootDir: "", + serviceUser: "", + shellPath: "", + }, + hostAllocatorSettings: allocation, + setup: setup.setupScript, + setupAsSudo: setup.setupAsSudo, + sshKey: sshConfig.sshKey, + sshOptions: sshConfig.sshOptions, + user: sshConfig.user, + workDir: setup.workDir, +})) satisfies FormToGqlFunction; From 2130941a97e82c66f8961e1521bbff3e7c159b6a Mon Sep 17 00:00:00 2001 From: Sophie Stadler Date: Wed, 6 Sep 2023 15:37:56 -0400 Subject: [PATCH 6/8] Make ssh options full width --- src/components/SpruceForm/ElementWrapper.tsx | 10 +- .../SpruceForm/FieldTemplates/index.tsx | 1 + .../SpruceForm/Widgets/LeafyGreenWidgets.tsx | 105 ++++++++---------- .../SpruceForm/Widgets/MultiSelect.tsx | 43 ++++--- .../tabs/HostTab/getFormSchema.tsx | 5 + 5 files changed, 82 insertions(+), 82 deletions(-) diff --git a/src/components/SpruceForm/ElementWrapper.tsx b/src/components/SpruceForm/ElementWrapper.tsx index 406631e0e7..aa5757e618 100644 --- a/src/components/SpruceForm/ElementWrapper.tsx +++ b/src/components/SpruceForm/ElementWrapper.tsx @@ -1,7 +1,15 @@ import styled from "@emotion/styled"; +import { STANDARD_FIELD_WIDTH } from "./utils"; -const ElementWrapper = styled.div` +type ElementWrapperProps = { + limitMaxWidth?: boolean; +}; + +const ElementWrapper = styled.div` margin-bottom: 20px; + + ${({ limitMaxWidth }) => + limitMaxWidth && `max-width: ${STANDARD_FIELD_WIDTH}px;`} `; export default ElementWrapper; diff --git a/src/components/SpruceForm/FieldTemplates/index.tsx b/src/components/SpruceForm/FieldTemplates/index.tsx index 394a5ea2ae..18cc578f4b 100644 --- a/src/components/SpruceForm/FieldTemplates/index.tsx +++ b/src/components/SpruceForm/FieldTemplates/index.tsx @@ -50,4 +50,5 @@ const DefaultFieldContainer = styled.div<{ border?: "top" | "bottom" }>` ${({ border }) => border && `border-${border}: 1px solid ${gray.light1}; padding-${border}: ${size.s};`} + width: 100%; `; diff --git a/src/components/SpruceForm/Widgets/LeafyGreenWidgets.tsx b/src/components/SpruceForm/Widgets/LeafyGreenWidgets.tsx index acc89574e9..cbb4f21802 100644 --- a/src/components/SpruceForm/Widgets/LeafyGreenWidgets.tsx +++ b/src/components/SpruceForm/Widgets/LeafyGreenWidgets.tsx @@ -19,7 +19,6 @@ import Icon from "components/Icon"; import { size, zIndex } from "constants/tokens"; import { OneOf } from "types/utils"; import ElementWrapper from "../ElementWrapper"; -import { STANDARD_FIELD_WIDTH } from "../utils"; import { EnumSpruceWidgetProps, SpruceWidgetProps } from "./types"; import { isNullish, processErrors } from "./utils"; @@ -59,30 +58,26 @@ export const LeafyGreenTextInput: React.FC< }; return ( - - - - target.value === "" ? onChange(emptyValue) : onChange(target.value) - } - aria-label={label} - {...inputProps} - /> - {!!warnings?.length && ( - - {warnings.join(", ")} - - )} - + + + target.value === "" ? onChange(emptyValue) : onChange(target.value) + } + aria-label={label} + {...inputProps} + /> + {!!warnings?.length && ( + {warnings.join(", ")} + )} ); }; @@ -182,35 +177,33 @@ export const LeafyGreenSelect: React.FC< ariaLabelledBy ? { "aria-labelledby": ariaLabelledBy } : { label }; return ( - - - - + + ); }; @@ -432,7 +425,3 @@ const StyledSegmentedControl = styled(SegmentedControl)` box-sizing: border-box; margin-bottom: ${size.s}; `; - -export const MaxWidthContainer = styled.div` - max-width: ${STANDARD_FIELD_WIDTH}px; -`; diff --git a/src/components/SpruceForm/Widgets/MultiSelect.tsx b/src/components/SpruceForm/Widgets/MultiSelect.tsx index 6338ca78da..ad4c1a5ac0 100644 --- a/src/components/SpruceForm/Widgets/MultiSelect.tsx +++ b/src/components/SpruceForm/Widgets/MultiSelect.tsx @@ -4,7 +4,6 @@ import Dropdown from "components/Dropdown"; import { TreeSelect, ALL_VALUE } from "components/TreeSelect"; import { size } from "constants/tokens"; import ElementWrapper from "../ElementWrapper"; -import { MaxWidthContainer } from "./LeafyGreenWidgets"; import { EnumSpruceWidgetProps } from "./types"; export const MultiSelect: React.FC = ({ @@ -39,28 +38,26 @@ export const MultiSelect: React.FC = ({ const selectedOptions = [...value, ...(includeAll ? [ALL_VALUE] : [])]; return ( - - - - - - - - {rawErrors.length > 0 && {rawErrors.join(", ")}} - - + + + + + + + {rawErrors.length > 0 && {rawErrors.join(", ")}} + ); }; diff --git a/src/pages/distroSettings/tabs/HostTab/getFormSchema.tsx b/src/pages/distroSettings/tabs/HostTab/getFormSchema.tsx index d284dc5415..1e5f17541e 100644 --- a/src/pages/distroSettings/tabs/HostTab/getFormSchema.tsx +++ b/src/pages/distroSettings/tabs/HostTab/getFormSchema.tsx @@ -1,3 +1,4 @@ +import { css } from "@emotion/react"; import { InlineCode } from "@leafygreen-ui/typography"; import { GetFormSchema } from "components/SpruceForm"; import { CardFieldTemplate } from "components/SpruceForm/FieldTemplates"; @@ -177,8 +178,12 @@ export const getFormSchema = ({ Option keywords supported by ssh_config. ), + "ui:fullWidth": true, "ui:orderable": false, items: { + "ui:elementWrapperCSS": css` + max-width: unset; + `, "ui:placeholder": "ConnectTimeout=10", }, }, From 5a6e83e81c3a8cc5644a66930711adb242ad71c5 Mon Sep 17 00:00:00 2001 From: Sophie Stadler Date: Thu, 7 Sep 2023 12:40:21 -0400 Subject: [PATCH 7/8] Fix unit test --- src/pages/distroSettings/tabs/HostTab/transformers.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/distroSettings/tabs/HostTab/transformers.test.ts b/src/pages/distroSettings/tabs/HostTab/transformers.test.ts index 7132ea7d2c..3f48478ef4 100644 --- a/src/pages/distroSettings/tabs/HostTab/transformers.test.ts +++ b/src/pages/distroSettings/tabs/HostTab/transformers.test.ts @@ -26,7 +26,7 @@ const form: HostFormState = { setup: { bootstrapMethod: BootstrapMethod.LegacySsh, communicationMethod: CommunicationMethod.LegacySsh, - arch: Arch.LinuxPpc_64Bit, + arch: Arch.Linux_64Bit, workDir: "/data/evg", setupScript: "ls -alF", setupAsSudo: true, From 45c12cfc9548b9f8991fd025e8a5095b47f38f06 Mon Sep 17 00:00:00 2001 From: Sophie Stadler Date: Fri, 8 Sep 2023 10:08:17 -0400 Subject: [PATCH 8/8] Fix width issues --- src/pages/distroSettings/tabs/HostTab/getFormSchema.tsx | 5 ----- src/pages/projectSettings/tabs/VariablesTab/VariableRow.tsx | 1 + 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/pages/distroSettings/tabs/HostTab/getFormSchema.tsx b/src/pages/distroSettings/tabs/HostTab/getFormSchema.tsx index 1e5f17541e..d284dc5415 100644 --- a/src/pages/distroSettings/tabs/HostTab/getFormSchema.tsx +++ b/src/pages/distroSettings/tabs/HostTab/getFormSchema.tsx @@ -1,4 +1,3 @@ -import { css } from "@emotion/react"; import { InlineCode } from "@leafygreen-ui/typography"; import { GetFormSchema } from "components/SpruceForm"; import { CardFieldTemplate } from "components/SpruceForm/FieldTemplates"; @@ -178,12 +177,8 @@ export const getFormSchema = ({ Option keywords supported by ssh_config. ), - "ui:fullWidth": true, "ui:orderable": false, items: { - "ui:elementWrapperCSS": css` - max-width: unset; - `, "ui:placeholder": "ConnectTimeout=10", }, }, diff --git a/src/pages/projectSettings/tabs/VariablesTab/VariableRow.tsx b/src/pages/projectSettings/tabs/VariablesTab/VariableRow.tsx index 6f1dd14557..7c1d456fbd 100644 --- a/src/pages/projectSettings/tabs/VariablesTab/VariableRow.tsx +++ b/src/pages/projectSettings/tabs/VariablesTab/VariableRow.tsx @@ -76,5 +76,6 @@ const OptionRow = styled.div` > div { flex-shrink: 0; margin-right: ${size.s}; + width: fit-content; } `;