From 3f7f18cb0d3327e18cd23c2b2b59bcd5eaee8054 Mon Sep 17 00:00:00 2001 From: Florian Stadler Date: Fri, 24 Jan 2025 14:56:48 +0100 Subject: [PATCH] Use InputPropertyError for validations in the node group components (#1592) pu/pu introduced new error types for component authors to use. The `InputPropertyError` and `InputPropertiesError` errors are pretty printed by the engine and don't confuse users with a load of GRPC stack traces (see [docs](https://www.pulumi.com/docs/reference/pkg/nodejs/pulumi/pulumi/classes/InputPropertyError.html)). Additionally those errors accumulate multiple validation errors and allow presenting them all to users. Previously we exited on the first error. For adding EFA support (https://github.com/pulumi/pulumi-eks/issues/1564) we'd like to make use of these new errors. In preparation for that I've updated the node group components to make use of this new error type. --- nodejs/eks/nodes/ami.test.ts | 10 +- nodejs/eks/nodes/ami.ts | 17 +- nodejs/eks/nodes/nodegroup.test.ts | 93 +++----- nodejs/eks/nodes/nodegroup.ts | 370 ++++++++++++++++------------- 4 files changed, 253 insertions(+), 237 deletions(-) diff --git a/nodejs/eks/nodes/ami.test.ts b/nodejs/eks/nodes/ami.test.ts index 3d953aba9..ce9591439 100644 --- a/nodejs/eks/nodes/ami.test.ts +++ b/nodejs/eks/nodes/ami.test.ts @@ -46,29 +46,29 @@ describe("toAmiType", () => { }); describe("getOperatingSystem", () => { test("should return the provided operating system if available", () => { - const operatingSystem = getOperatingSystem(undefined, OperatingSystem.AL2023, undefined); + const operatingSystem = getOperatingSystem(undefined, OperatingSystem.AL2023); expect(operatingSystem).toBe(OperatingSystem.AL2023); }); test("should return the default operating system if no AMI type or operating system is provided", () => { - const operatingSystem = getOperatingSystem(undefined, undefined, undefined); + const operatingSystem = getOperatingSystem(undefined, undefined); expect(operatingSystem).toBe(DEFAULT_OS); }); test("should resolve the operating system based on the provided AMI type", () => { - const operatingSystem = getOperatingSystem("AL2023_ARM_64_STANDARD", undefined, undefined); + const operatingSystem = getOperatingSystem("AL2023_ARM_64_STANDARD", undefined); expect(operatingSystem).toBe(OperatingSystem.AL2023); }); test("should throw an error for unknown AMI type", () => { expect(() => { - getOperatingSystem("unknown-ami-type", undefined, undefined); + getOperatingSystem("unknown-ami-type", undefined); }).toThrow("Cannot determine OS of unknown AMI type: unknown-ami-type"); }); test("should throw an error if the operating system does not match the AMI type", () => { expect(() => { - getOperatingSystem("AL2023_ARM_64_STANDARD", OperatingSystem.Bottlerocket, undefined); + getOperatingSystem("AL2023_ARM_64_STANDARD", OperatingSystem.Bottlerocket); }).toThrow( "Operating system 'Bottlerocket' does not match the detected operating system 'AL2023' of AMI type 'AL2023_ARM_64_STANDARD'.", ); diff --git a/nodejs/eks/nodes/ami.ts b/nodejs/eks/nodes/ami.ts index a49648c69..fdbeb2020 100644 --- a/nodejs/eks/nodes/ami.ts +++ b/nodejs/eks/nodes/ami.ts @@ -200,7 +200,6 @@ export function toAmiType(str: string): AmiType | undefined { export function getOperatingSystem( amiType: string | undefined, operatingSystem: OperatingSystem | undefined, - parent: pulumi.Resource | undefined, ): OperatingSystem { if (operatingSystem && !amiType) { return operatingSystem; @@ -210,20 +209,20 @@ export function getOperatingSystem( const resolvedAmiType = toAmiType(amiType); if (!resolvedAmiType) { - throw new pulumi.ResourceError( - `Cannot determine OS of unknown AMI type: ${amiType}`, - parent, - ); + throw new pulumi.InputPropertyError({ + propertyPath: "amiType", + reason: `Cannot determine OS of unknown AMI type: ${amiType}`, + }); } const resolvedOs = getAmiMetadata(resolvedAmiType).os; // if users provided both OS and AMI type, we should check if they match if (operatingSystem && operatingSystem !== resolvedOs) { - throw new pulumi.ResourceError( - `Operating system '${operatingSystem}' does not match the detected operating system '${resolvedOs}' of AMI type '${amiType}'.`, - parent, - ); + throw new pulumi.InputPropertyError({ + propertyPath: "operatingSystem", + reason: `Operating system '${operatingSystem}' does not match the detected operating system '${resolvedOs}' of AMI type '${amiType}'.`, + }); } return resolvedOs; diff --git a/nodejs/eks/nodes/nodegroup.test.ts b/nodejs/eks/nodes/nodegroup.test.ts index 2ad7f731d..e645d2ba1 100644 --- a/nodejs/eks/nodes/nodegroup.test.ts +++ b/nodejs/eks/nodes/nodegroup.test.ts @@ -307,40 +307,40 @@ const nonGravitonInstances = [ describe("isGravitonInstance", () => { gravitonInstances.forEach((instanceType) => { test(`${instanceType} should return true for a Graviton instance`, () => { - expect(isGravitonInstance(instanceType, undefined)).toBe(true); + expect(isGravitonInstance(instanceType, "instanceTypes")).toBe(true); }); }); nonGravitonInstances.forEach((instanceType) => { test(`${instanceType} should return false for non-Graviton instance`, () => { - expect(isGravitonInstance(instanceType, undefined)).toBe(false); + expect(isGravitonInstance(instanceType, "instanceTypes")).toBe(false); }); }); describe("getArchitecture", () => { test("should return 'x86_64' when only x86_64 instances are provided", () => { const instanceTypes = ["c5.large", "m5.large", "t3.large"]; - const architecture = getArchitecture(instanceTypes, undefined); + const architecture = getArchitecture(instanceTypes, "instanceTypes"); expect(architecture).toBe("x86_64"); }); test("should return 'arm64' when only arm64 instances are provided", () => { const instanceTypes = ["c6g.large", "m6g.large", "t4g.large"]; - const architecture = getArchitecture(instanceTypes, undefined); + const architecture = getArchitecture(instanceTypes, "instanceTypes"); expect(architecture).toBe("arm64"); }); test("should throw an error when both x86_64 and arm64 instances are provided", () => { const instanceTypes = ["c5.large", "c6g.large"]; expect(() => { - getArchitecture(instanceTypes, undefined); - }).toThrowError( + getArchitecture(instanceTypes, "instanceTypes"); + }).toThrow( "Cannot determine architecture of instance types. The provided instance types do not share a common architecture", ); }); test("should return 'x86_64' when no instance types are provided", () => { const instanceTypes: string[] = []; - const architecture = getArchitecture(instanceTypes, undefined); + const architecture = getArchitecture(instanceTypes, "instanceTypes"); expect(architecture).toBe("x86_64"); }); }); @@ -484,15 +484,10 @@ describe("resolveInstanceProfileName", function () { test("no args, no c.nodeGroupOptions throws", async () => { expect(() => - resolveInstanceProfileName( - "nodegroup-name", - {}, - { - nodeGroupOptions: {}, - } as pulumi.UnwrappedObject, - undefined as any, - ), - ).toThrowError("an instanceProfile or instanceProfileName is required"); + resolveInstanceProfileName({}, { + nodeGroupOptions: {}, + } as pulumi.UnwrappedObject), + ).toThrow("an instanceProfile or instanceProfileName is required"); }); test("both args.instanceProfile and args.instanceProfileName throws", async () => { @@ -500,7 +495,6 @@ describe("resolveInstanceProfileName", function () { const name = "nodegroup-name"; expect(() => resolveInstanceProfileName( - name, { instanceProfile: instanceProfile, instanceProfileName: "instanceProfileName", @@ -508,47 +502,34 @@ describe("resolveInstanceProfileName", function () { { nodeGroupOptions: {}, } as pulumi.UnwrappedObject, - undefined as any, ), - ).toThrowError( - `invalid args for node group ${name}, instanceProfile and instanceProfileName are mutually exclusive`, - ); + ).toThrow(`instanceProfile and instanceProfileName are mutually exclusive`); }); test("both c.nodeGroupOptions.instanceProfileName and c.nodeGroupOptions.instanceProfile throws", async () => { const instanceProfile = new aws.iam.InstanceProfile("instanceProfile", {}); const name = "nodegroup-name"; expect(() => - resolveInstanceProfileName( - name, - {}, - { - nodeGroupOptions: { - instanceProfile: instanceProfile, - instanceProfileName: "instanceProfileName", - }, - } as pulumi.UnwrappedObject, - undefined as any, - ), - ).toThrowError( - `invalid args for node group ${name}, instanceProfile and instanceProfileName are mutually exclusive`, - ); + resolveInstanceProfileName({}, { + nodeGroupOptions: { + instanceProfile: instanceProfile, + instanceProfileName: "instanceProfileName", + }, + } as pulumi.UnwrappedObject), + ).toThrow(`instanceProfile and instanceProfileName are mutually exclusive`); }); test("args.instanceProfile returns passed instanceProfile", async () => { const instanceProfile = new aws.iam.InstanceProfile("instanceProfile", { name: "passedInstanceProfile", }); - const nodeGroupName = "nodegroup-name"; const resolvedInstanceProfileName = resolveInstanceProfileName( - nodeGroupName, { instanceProfile: instanceProfile, }, { nodeGroupOptions: {}, } as pulumi.UnwrappedObject, - undefined as any, ); const expected = await promisify(instanceProfile.name); const recieved = await promisify(resolvedInstanceProfileName); @@ -559,34 +540,26 @@ describe("resolveInstanceProfileName", function () { const instanceProfile = new aws.iam.InstanceProfile("instanceProfile", { name: "passedInstanceProfile", }); - const nodeGroupName = "nodegroup-name"; - const resolvedInstanceProfileName = resolveInstanceProfileName( - nodeGroupName, - {}, - { - nodeGroupOptions: { - instanceProfile: instanceProfile, - }, - } as pulumi.UnwrappedObject, - undefined as any, - ); + const resolvedInstanceProfileName = resolveInstanceProfileName({}, { + nodeGroupOptions: { + instanceProfile: instanceProfile, + }, + } as pulumi.UnwrappedObject); const expected = await promisify(instanceProfile.name); const recieved = await promisify(resolvedInstanceProfileName); expect(recieved).toEqual(expected); }); test("args.instanceProfileName returns passed InstanceProfile", async () => { - const nodeGroupName = "nodegroup-name"; + const nodeGroupName = "-name"; const existingInstanceProfileName = "existingInstanceProfileName"; const resolvedInstanceProfileName = resolveInstanceProfileName( - nodeGroupName, { instanceProfileName: existingInstanceProfileName, }, { nodeGroupOptions: {}, } as pulumi.UnwrappedObject, - undefined as any, ); const expected = existingInstanceProfileName; const received = await promisify(resolvedInstanceProfileName); @@ -594,18 +567,12 @@ describe("resolveInstanceProfileName", function () { }); test("nodeGroupOptions.instanceProfileName returns existing InstanceProfile", async () => { - const nodeGroupName = "nodegroup-name"; const existingInstanceProfileName = "existingInstanceProfileName"; - const resolvedInstanceProfileName = resolveInstanceProfileName( - nodeGroupName, - {}, - { - nodeGroupOptions: { - instanceProfileName: existingInstanceProfileName, - }, - } as pulumi.UnwrappedObject, - undefined as any, - ); + const resolvedInstanceProfileName = resolveInstanceProfileName({}, { + nodeGroupOptions: { + instanceProfileName: existingInstanceProfileName, + }, + } as pulumi.UnwrappedObject); const expected = existingInstanceProfileName; const received = await promisify(resolvedInstanceProfileName); expect(received).toEqual(expected); diff --git a/nodejs/eks/nodes/nodegroup.ts b/nodejs/eks/nodes/nodegroup.ts index 4d1c840a3..f6833556a 100644 --- a/nodejs/eks/nodes/nodegroup.ts +++ b/nodejs/eks/nodes/nodegroup.ts @@ -548,19 +548,17 @@ export type NodeGroupV2Args = Omit & { }; export function resolveInstanceProfileName( - name: string, args: Omit, c: pulumi.UnwrappedObject, - parent: pulumi.ComponentResource, ): pulumi.Output { if ( (args.instanceProfile || c.nodeGroupOptions.instanceProfile) && (args.instanceProfileName || c.nodeGroupOptions.instanceProfileName) ) { - throw new pulumi.ResourceError( - `invalid args for node group ${name}, instanceProfile and instanceProfileName are mutually exclusive`, - parent, - ); + throw new pulumi.InputPropertyError({ + propertyPath: "instanceProfile", + reason: `instanceProfile and instanceProfileName are mutually exclusive`, + }); } if (args.instanceProfile) { @@ -572,10 +570,10 @@ export function resolveInstanceProfileName( } else if (c.nodeGroupOptions.instanceProfileName) { return pulumi.output(c.nodeGroupOptions.instanceProfileName!); } else { - throw new pulumi.ResourceError( - `an instanceProfile or instanceProfileName is required`, - parent, - ); + throw new pulumi.InputPropertyError({ + propertyPath: "instanceProfile", + reason: `an instanceProfile or instanceProfileName is required`, + }); } } @@ -586,22 +584,22 @@ function createNodeGroup( parent: pulumi.ComponentResource, provider?: pulumi.ProviderResource, ): NodeGroupData { - const instanceProfileName = core.apply((c) => - resolveInstanceProfileName(name, args, c, parent), - ); + const validationErrors: pulumi.InputPropertyErrorDetails[] = []; + + const instanceProfileName = core.apply((c) => resolveInstanceProfileName(args, c)); if (args.clusterIngressRule && args.clusterIngressRuleId) { - throw new pulumi.ResourceError( - `invalid args for node group ${name}, clusterIngressRule and clusterIngressRuleId are mutually exclusive`, - parent, - ); + validationErrors.push({ + propertyPath: "clusterIngressRule", + reason: `clusterIngressRule and clusterIngressRuleId are mutually exclusive`, + }); } if (args.nodeSecurityGroup && args.nodeSecurityGroupId) { - throw new pulumi.ResourceError( - `invalid args for node group ${name}, nodeSecurityGroup and nodeSecurityGroupId are mutually exclusive`, - parent, - ); + validationErrors.push({ + propertyPath: "nodeSecurityGroup", + reason: `nodeSecurityGroup and nodeSecurityGroupId are mutually exclusive`, + }); } const coreSecurityGroupId = core.nodeGroupOptions.nodeSecurityGroup?.apply((sg) => sg?.id); @@ -614,22 +612,27 @@ function createNodeGroup( .apply(([coreSecurityGroup, nodeSecurityGroup, sgTags]) => { if (coreSecurityGroup && nodeSecurityGroup) { if (sgTags && coreSecurityGroup !== nodeSecurityGroup) { - throw new pulumi.ResourceError( - `The NodeGroup's nodeSecurityGroup and the cluster option nodeSecurityGroupTags are mutually exclusive. Choose a single approach`, - parent, - ); + throw new pulumi.InputPropertyError({ + propertyPath: args.nodeSecurityGroup + ? "nodeSecurityGroup" + : "nodeSecurityGroupId", + reason: `The NodeGroup's nodeSecurityGroup and the cluster option nodeSecurityGroupTags are mutually exclusive. Choose a single approach`, + }); } } }); if (args.nodePublicKey && args.keyName) { - throw new pulumi.ResourceError( - "nodePublicKey and keyName are mutually exclusive. Choose a single approach", - parent, - ); + validationErrors.push({ + propertyPath: "nodePublicKey", + reason: "nodePublicKey and keyName are mutually exclusive. Choose a single approach", + }); } if (args.amiId && args.gpu) { - throw new pulumi.ResourceError("amiId and gpu are mutually exclusive.", parent); + validationErrors.push({ + propertyPath: "amiId", + reason: "amiId and gpu are mutually exclusive.", + }); } if ( @@ -640,10 +643,10 @@ function createNodeGroup( args.kubeletExtraArgs || args.bootstrapExtraArgs) ) { - throw new pulumi.ResourceError( - "nodeUserDataOverride and any combination of {nodeUserData, labels, taints, kubeletExtraArgs, or bootstrapExtraArgs} is mutually exclusive.", - parent, - ); + validationErrors.push({ + propertyPath: "nodeUserDataOverride", + reason: "nodeUserDataOverride and any combination of {nodeUserData, labels, taints, kubeletExtraArgs, or bootstrapExtraArgs} is mutually exclusive.", + }); } let nodeSecurityGroupId: pulumi.Output; @@ -664,10 +667,10 @@ function createNodeGroup( let eksClusterIngressRuleId: pulumi.Output; if (args.nodeSecurityGroup || args.nodeSecurityGroupId) { if (args.clusterIngressRule === undefined && args.clusterIngressRuleId === undefined) { - throw new pulumi.ResourceError( - `invalid args for node group ${name}, clusterIngressRule or clusterIngressRuleId is required when nodeSecurityGroup is manually specified`, - parent, - ); + validationErrors.push({ + propertyPath: "clusterIngressRule", + reason: `clusterIngressRule or clusterIngressRuleId is required when nodeSecurityGroup is manually specified`, + }); } nodeSecurityGroup = args.nodeSecurityGroup; @@ -729,7 +732,7 @@ function createNodeGroup( const os = pulumi .all([args.amiType, args.operatingSystem]) .apply(([amiType, operatingSystem]) => { - return getOperatingSystem(amiType, operatingSystem, parent); + return getOperatingSystem(amiType, operatingSystem); }); const serviceCidr = getClusterServiceCidr(core.cluster.kubernetesNetworkConfig); @@ -787,37 +790,45 @@ function createNodeGroup( pulumi .all([args.nodeRootVolumeIops, args.nodeRootVolumeType, args.nodeRootVolumeThroughput]) .apply(([nodeRootVolumeIops, nodeRootVolumeType, nodeRootVolumeThroughput]) => { + const errors: pulumi.InputPropertyErrorDetails[] = []; if (nodeRootVolumeIops && nodeRootVolumeType !== "io1") { - throw new pulumi.ResourceError( - "Cannot create a cluster node root volume of non-io1 type with provisioned IOPS (nodeRootVolumeIops).", - parent, - ); + errors.push({ + propertyPath: "nodeRootVolumeIops", + reason: "Cannot create a cluster node root volume of non-io1 type with provisioned IOPS (nodeRootVolumeIops).", + }); } if (nodeRootVolumeType === "io1" && nodeRootVolumeIops) { if (!numeric.test(nodeRootVolumeIops?.toString())) { - throw new pulumi.ResourceError( - "Cannot create a cluster node root volume of io1 type without provisioned IOPS (nodeRootVolumeIops) as integer value.", - parent, - ); + errors.push({ + propertyPath: "nodeRootVolumeIops", + reason: "Cannot create a cluster node root volume of io1 type without provisioned IOPS (nodeRootVolumeIops) as integer value.", + }); } } if (nodeRootVolumeThroughput && nodeRootVolumeType !== "gp3") { - throw new pulumi.ResourceError( - "Cannot create a cluster node root volume of non-gp3 type with provisioned throughput (nodeRootVolumeThroughput).", - parent, - ); + errors.push({ + propertyPath: "nodeRootVolumeThroughput", + reason: "Cannot create a cluster node root volume of non-gp3 type with provisioned throughput (nodeRootVolumeThroughput).", + }); } if (nodeRootVolumeType === "gp3" && nodeRootVolumeThroughput) { if (!numeric.test(nodeRootVolumeThroughput?.toString())) { - throw new pulumi.ResourceError( - "Cannot create a cluster node root volume of gp3 type without provisioned throughput (nodeRootVolumeThroughput) as integer value.", - parent, - ); + errors.push({ + propertyPath: "nodeRootVolumeThroughput", + reason: "Cannot create a cluster node root volume of gp3 type without provisioned throughput (nodeRootVolumeThroughput) as integer value.", + }); } } + + if (errors.length > 0) { + throw new pulumi.InputPropertiesError({ + message: "Invalid arguments for node group", + errors: errors, + }); + } }); const amiInfo = pulumi.output(amiId).apply((id) => @@ -893,6 +904,13 @@ function createNodeGroup( }; }); + if (validationErrors.length > 0) { + throw new pulumi.InputPropertiesError({ + message: "Invalid arguments for node group", + errors: validationErrors, + }); + } + const nodeLaunchConfiguration = new aws.ec2.LaunchConfiguration( `${name}-nodeLaunchConfiguration`, { @@ -1044,22 +1062,22 @@ export function createNodeGroupV2( parent: pulumi.ComponentResource, provider?: pulumi.ProviderResource, ): NodeGroupV2Data { - const instanceProfileName = core.apply((c) => - resolveInstanceProfileName(name, args, c, parent), - ); + const validationErrors: pulumi.InputPropertyErrorDetails[] = []; + + const instanceProfileName = core.apply((c) => resolveInstanceProfileName(args, c)); if (args.clusterIngressRule && args.clusterIngressRuleId) { - throw new pulumi.ResourceError( - `invalid args for node group ${name}, clusterIngressRule and clusterIngressRuleId are mutually exclusive`, - parent, - ); + validationErrors.push({ + propertyPath: "clusterIngressRule", + reason: `clusterIngressRule and clusterIngressRuleId are mutually exclusive`, + }); } if (args.nodeSecurityGroup && args.nodeSecurityGroupId) { - throw new pulumi.ResourceError( - `invalid args for node group ${name}, nodeSecurityGroup and nodeSecurityGroupId are mutually exclusive`, - parent, - ); + validationErrors.push({ + propertyPath: "nodeSecurityGroup", + reason: `nodeSecurityGroup and nodeSecurityGroupId are mutually exclusive`, + }); } const coreSecurityGroupId = core.nodeGroupOptions.nodeSecurityGroup?.apply((sg) => sg?.id); @@ -1072,23 +1090,26 @@ export function createNodeGroupV2( .apply(([coreSecurityGroup, nodeSecurityGroup, sgTags]) => { if (coreSecurityGroup && nodeSecurityGroup) { if (sgTags && coreSecurityGroup !== nodeSecurityGroup) { - throw new pulumi.ResourceError( - `The NodeGroup's nodeSecurityGroup and the cluster option nodeSecurityGroupTags are mutually exclusive. Choose a single approach`, - parent, - ); + throw new pulumi.InputPropertyError({ + propertyPath: "nodeSecurityGroup", + reason: `The NodeGroup's nodeSecurityGroup and the cluster option nodeSecurityGroupTags are mutually exclusive. Choose a single approach`, + }); } } }); if (args.nodePublicKey && args.keyName) { - throw new pulumi.ResourceError( - "nodePublicKey and keyName are mutually exclusive. Choose a single approach", - parent, - ); + validationErrors.push({ + propertyPath: "nodePublicKey", + reason: "nodePublicKey and keyName are mutually exclusive. Choose a single approach", + }); } if (args.amiId && args.gpu) { - throw new pulumi.ResourceError("amiId and gpu are mutually exclusive.", parent); + validationErrors.push({ + propertyPath: "amiId", + reason: "amiId and gpu are mutually exclusive.", + }); } if ( @@ -1099,10 +1120,10 @@ export function createNodeGroupV2( args.kubeletExtraArgs || args.bootstrapExtraArgs) ) { - throw new pulumi.ResourceError( - "nodeUserDataOverride and any combination of {nodeUserData, labels, taints, kubeletExtraArgs, or bootstrapExtraArgs} is mutually exclusive.", - parent, - ); + validationErrors.push({ + propertyPath: "nodeUserDataOverride", + reason: "nodeUserDataOverride and any combination of {nodeUserData, labels, taints, kubeletExtraArgs, or bootstrapExtraArgs} is mutually exclusive.", + }); } let nodeSecurityGroupId: pulumi.Output; @@ -1123,10 +1144,10 @@ export function createNodeGroupV2( let eksClusterIngressRuleId: pulumi.Output; if (args.nodeSecurityGroup || args.nodeSecurityGroupId) { if (args.clusterIngressRule === undefined && args.clusterIngressRuleId === undefined) { - throw new pulumi.ResourceError( - `invalid args for node group ${name}, clusterIngressRule or clusterIngressRuleId is required when nodeSecurityGroup is manually specified`, - parent, - ); + validationErrors.push({ + propertyPath: "clusterIngressRule", + reason: `clusterIngressRule or clusterIngressRuleId is required when nodeSecurityGroup is manually specified`, + }); } nodeSecurityGroup = args.nodeSecurityGroup; @@ -1183,7 +1204,7 @@ export function createNodeGroupV2( const os = pulumi .all([args.amiType, args.operatingSystem]) .apply(([amiType, operatingSystem]) => { - return getOperatingSystem(amiType, operatingSystem, parent); + return getOperatingSystem(amiType, operatingSystem); }); const serviceCidr = getClusterServiceCidr(core.cluster.kubernetesNetworkConfig); @@ -1241,37 +1262,45 @@ export function createNodeGroupV2( pulumi .all([args.nodeRootVolumeIops, args.nodeRootVolumeType, args.nodeRootVolumeThroughput]) .apply(([nodeRootVolumeIops, nodeRootVolumeType, nodeRootVolumeThroughput]) => { + const errors: pulumi.InputPropertyErrorDetails[] = []; if (nodeRootVolumeIops && nodeRootVolumeType !== "io1") { - throw new pulumi.ResourceError( - "Cannot create a cluster node root volume of non-io1 type with provisioned IOPS (nodeRootVolumeIops).", - parent, - ); + errors.push({ + propertyPath: "nodeRootVolumeIops", + reason: "Cannot create a cluster node root volume of non-io1 type with provisioned IOPS (nodeRootVolumeIops).", + }); } if (nodeRootVolumeType === "io1" && nodeRootVolumeIops) { if (!numeric.test(nodeRootVolumeIops?.toString())) { - throw new pulumi.ResourceError( - "Cannot create a cluster node root volume of io1 type without provisioned IOPS (nodeRootVolumeIops) as integer value.", - parent, - ); + errors.push({ + propertyPath: "nodeRootVolumeIops", + reason: "Cannot create a cluster node root volume of io1 type without provisioned IOPS (nodeRootVolumeIops) as integer value.", + }); } } if (nodeRootVolumeThroughput && nodeRootVolumeType !== "gp3") { - throw new pulumi.ResourceError( - "Cannot create a cluster node root volume of non-gp3 type with provisioned throughput (nodeRootVolumeThroughput).", - parent, - ); + errors.push({ + propertyPath: "nodeRootVolumeThroughput", + reason: "Cannot create a cluster node root volume of non-gp3 type with provisioned throughput (nodeRootVolumeThroughput).", + }); } if (nodeRootVolumeType === "gp3" && nodeRootVolumeThroughput) { if (!numeric.test(nodeRootVolumeThroughput?.toString())) { - throw new pulumi.ResourceError( - "Cannot create a cluster node root volume of gp3 type without provisioned throughput (nodeRootVolumeThroughput) as integer value.", - parent, - ); + errors.push({ + propertyPath: "nodeRootVolumeThroughput", + reason: "Cannot create a cluster node root volume of gp3 type without provisioned throughput (nodeRootVolumeThroughput) as integer value.", + }); } } + + if (errors.length > 0) { + throw new pulumi.InputPropertiesError({ + message: "Invalid arguments for node group", + errors: errors, + }); + } }); const marketOptions = args.spotPrice @@ -1364,6 +1393,13 @@ export function createNodeGroupV2( ]; }); + if (validationErrors.length > 0) { + throw new pulumi.InputPropertiesError({ + message: "Invalid arguments for node group", + errors: validationErrors, + }); + } + const nodeLaunchTemplate = new aws.ec2.LaunchTemplate( `${name}-launchTemplate`, { @@ -1816,20 +1852,13 @@ export function createManagedNodeGroup( args.version = pulumi.output(args.version ?? core.cluster.version); } - // Compute the nodegroup role. - if (!args.nodeRole && !args.nodeRoleArn) { - // throw new pulumi.ResourceError(`An IAM role, or role ARN must be provided to create a managed node group`); - throw new pulumi.ResourceError( - `An IAM role, or role ARN must be provided to create a managed node group`, - parent, - ); - } + const validationErrors: pulumi.InputPropertyErrorDetails[] = []; if (args.nodeRole && args.nodeRoleArn) { - throw new pulumi.ResourceError( - "You cannot specify both nodeRole and nodeRoleArn when creating a managed node group.", - parent, - ); + validationErrors.push({ + propertyPath: "nodeRole", + reason: "You cannot specify both nodeRole and nodeRoleArn when creating a managed node group.", + }); } const amiIdMutuallyExclusive: (keyof Omit)[] = [ @@ -1838,10 +1867,10 @@ export function createManagedNodeGroup( ]; amiIdMutuallyExclusive.forEach((key) => { if (args.amiId && args[key]) { - throw new pulumi.ResourceError( - `You cannot specify both amiId and ${key} when creating a managed node group.`, - parent, - ); + validationErrors.push({ + propertyPath: "amiId", + reason: `You cannot specify both amiId and ${key} when creating a managed node group.`, + }); } }); @@ -1851,7 +1880,14 @@ export function createManagedNodeGroup( } else if (args.nodeRole) { roleArn = pulumi.output(args.nodeRole).apply((r) => r.arn); } else { - throw new pulumi.ResourceError("The managed node group role provided is undefined", parent); + validationErrors.push({ + propertyPath: "nodeRole", + reason: "An IAM role, or role ARN must be provided to create a managed node group", + }); + throw new pulumi.InputPropertiesError({ + message: "The input properties for the managed node group are invalid.", + errors: validationErrors, + }); } // Check that the nodegroup role has been set on the cluster to @@ -1872,10 +1908,10 @@ export function createManagedNodeGroup( .apply(([authMode, role]) => { // access entries can be added out of band, so we don't require them to be set in the cluster. if (!supportsAccessEntries(authMode) && !role) { - throw new pulumi.ResourceError( - `A managed node group cannot be created without first setting its role in the cluster's instanceRoles`, - parent, - ); + throw new pulumi.InputPropertyError({ + propertyPath: "nodeRole", + reason: "A managed node group cannot be created without first setting its role in the cluster's instanceRoles", + }); } }); @@ -1907,12 +1943,12 @@ export function createManagedNodeGroup( // If the user specifies a custom LaunchTemplate, we throw an error and suggest that the user should include those in the launch template that they are providing. // If neither of these are provided, we can use the default launch template for managed node groups. if (args.launchTemplate && requiresCustomLaunchTemplate(args)) { - throw new pulumi.ResourceError( - `If you provide a custom launch template, you cannot provide any of ${customLaunchTemplateArgs.join( + validationErrors.push({ + propertyPath: "launchTemplate", + reason: `If you provide a custom launch template, you cannot provide any of ${customLaunchTemplateArgs.join( ", ", )}. Please include these in the launch template that you are providing.`, - parent, - ); + }); } const userDataArgs = { @@ -1923,12 +1959,19 @@ export function createManagedNodeGroup( }; if (requiresCustomUserData(userDataArgs) && args.userData) { - throw new pulumi.ResourceError( - `If you provide custom userData, you cannot provide any of ${customUserDataArgs.join( + validationErrors.push({ + propertyPath: "userData", + reason: `If you provide custom userData, you cannot provide any of ${customUserDataArgs.join( ", ", )}. Please include these in the userData that you are providing.`, - parent, - ); + }); + } + + if (validationErrors.length > 0) { + throw new pulumi.InputPropertiesError({ + message: "The input properties for the managed node group are invalid.", + errors: validationErrors, + }); } let launchTemplate: aws.ec2.LaunchTemplate | undefined; @@ -1952,7 +1995,13 @@ export function createManagedNodeGroup( } else if (amiType === undefined && args.operatingSystem !== undefined) { // if no ami type is provided, but operating system is provided, determine the ami type based on the operating system - amiType = determineAmiType(args.operatingSystem, args.gpu, args.instanceTypes, parent); + amiType = determineAmiType( + args.operatingSystem, + args.gpu, + args.instanceTypes, + "instanceTypes", + parent, + ); } const ignoreScalingChanges = args.ignoreScalingChanges @@ -2019,7 +2068,7 @@ function createMNGCustomLaunchTemplate( const os = pulumi .all([args.amiType, args.operatingSystem]) .apply(([amiType, operatingSystem]) => { - return getOperatingSystem(amiType, operatingSystem, parent); + return getOperatingSystem(amiType, operatingSystem); }); const taints = args.taints @@ -2029,7 +2078,7 @@ function createMNGCustomLaunchTemplate( return { [taint.key]: { value: taint.value, - effect: mapMngTaintEffect(taint.effect, parent), + effect: mapMngTaintEffect(taint.effect), }, }; }) @@ -2159,9 +2208,9 @@ function createMNGCustomLaunchTemplate( * @param effect - The taint effect string. Must be one of "NO_SCHEDULE", "NO_EXECUTE", "PREFER_NO_SCHEDULE". * @param parent - The parent Pulumi resource. * @returns The corresponding Kubernetes taint effect. - * @throws {pulumi.ResourceError} If the provided effect is invalid. + * @throws {pulumi.InputPropertyError} If the provided effect is invalid. */ -function mapMngTaintEffect(effect: string, parent: pulumi.Resource): TaintEffect { +function mapMngTaintEffect(effect: string): TaintEffect { switch (effect) { case "NO_SCHEDULE": return "NoSchedule"; @@ -2170,10 +2219,10 @@ function mapMngTaintEffect(effect: string, parent: pulumi.Resource): TaintEffect case "PREFER_NO_SCHEDULE": return "PreferNoSchedule"; default: - throw new pulumi.ResourceError( - `Invalid taint effect: ${effect}. Must be one of NO_SCHEDULE, NO_EXECUTE, PREFER_NO_SCHEDULE.`, - parent, - ); + throw new pulumi.InputPropertyError({ + propertyPath: "taints", + reason: `Invalid taint effect: ${effect}. Must be one of NO_SCHEDULE, NO_EXECUTE, PREFER_NO_SCHEDULE.`, + }); } } @@ -2205,30 +2254,33 @@ function getRecommendedAMI( parent: pulumi.Resource | undefined, ): pulumi.Input { let instanceTypes: pulumi.Input[]> | undefined; + let instanceTypesPropertyPath: string = ""; if ("instanceType" in args && args.instanceType) { instanceTypes = [args.instanceType]; + instanceTypesPropertyPath = "instanceType"; } else if ("instanceTypes" in args) { instanceTypes = args.instanceTypes; + instanceTypesPropertyPath = "instanceTypes"; } const os = pulumi .all([args.amiType, args.operatingSystem]) .apply(([amiType, operatingSystem]) => { - return getOperatingSystem(amiType, operatingSystem, parent); + return getOperatingSystem(amiType, operatingSystem); }); const amiType = args.amiType ? pulumi.output(args.amiType).apply((amiType) => { const resolvedType = toAmiType(amiType); if (resolvedType === undefined) { - throw new pulumi.ResourceError( - `Cannot resolve recommended AMI for AMI type: ${amiType}. Please provide the AMI ID and userdata.`, - parent, - ); + throw new pulumi.InputPropertyError({ + propertyPath: "amiType", + reason: `Cannot resolve recommended AMI for AMI type: ${amiType}. Please provide the AMI ID and userdata.`, + }); } return resolvedType; }) - : determineAmiType(os, args.gpu, instanceTypes, parent); + : determineAmiType(os, args.gpu, instanceTypes, instanceTypesPropertyPath, parent); // if specified use the version from the args, otherwise use the version from the cluster. const version = args.version ? args.version : k8sVersion; @@ -2259,13 +2311,13 @@ const ec2InstanceRegex = /([a-z]+)([0-9]+)([a-z])?\-?([a-z]+)?\.([a-zA-Z0-9\-]+) * * See https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instance-types.html. */ -export function isGravitonInstance( - instanceType: string, - parent: pulumi.Resource | undefined, -): boolean { +export function isGravitonInstance(instanceType: string, propertyPath: string): boolean { const match = instanceType.toString().match(ec2InstanceRegex); if (!match) { - throw new pulumi.ResourceError(`Invalid EC2 instance type: ${instanceType}`, parent); + throw new pulumi.InputPropertyError({ + propertyPath, + reason: `Invalid EC2 instance type: ${instanceType}`, + }); } const processorFamily = match[3]; @@ -2281,12 +2333,13 @@ function determineAmiType( os: pulumi.Input, gpu: pulumi.Input | undefined, instanceTypes: pulumi.Input[]> | undefined, + instanceTypesPropertyPath: string, parent: pulumi.Resource | undefined, ): pulumi.Output { const architecture = pulumi.output(instanceTypes).apply((instanceTypes) => { return pulumi .all(instanceTypes ?? []) - .apply((instanceTypes) => getArchitecture(instanceTypes, parent)); + .apply((instanceTypes) => getArchitecture(instanceTypes, instanceTypesPropertyPath)); }); return pulumi @@ -2299,27 +2352,24 @@ function determineAmiType( * * @param instanceTypes - An array of instance types. * @returns The architecture of the instance types, either "arm64" or "x86_64". - * @throws {pulumi.ResourceError} If the provided instance types do not share a common architecture. + * @throws {pulumi.InputPropertyError} If the provided instance types do not share a common architecture. */ -export function getArchitecture( - instanceTypes: string[], - parent: pulumi.Resource | undefined, -): CpuArchitecture { +export function getArchitecture(instanceTypes: string[], propertyPath: string): CpuArchitecture { let hasGravitonInstances = false; let hasX64Instances = false; instanceTypes.forEach((instanceType) => { - if (isGravitonInstance(instanceType, parent)) { + if (isGravitonInstance(instanceType, propertyPath)) { hasGravitonInstances = true; } else { hasX64Instances = true; } if (hasGravitonInstances && hasX64Instances) { - throw new pulumi.ResourceError( - "Cannot determine architecture of instance types. The provided instance types do not share a common architecture", - parent, - ); + throw new pulumi.InputPropertyError({ + propertyPath, + reason: "Cannot determine architecture of instance types. The provided instance types do not share a common architecture", + }); } });