diff --git a/pkg/eks/api.go b/pkg/eks/api.go index e8427d19f9..255299b294 100644 --- a/pkg/eks/api.go +++ b/pkg/eks/api.go @@ -212,18 +212,100 @@ func newAWSProvider(spec *api.ProviderConfig, configurationLoader AWSConfigurati return provider, nil } +// resolveYAMLAnchors processes YAML anchors and aliases, returning clean YAML +// that can be safely parsed by strict unmarshaling. It removes the "aliases" +// field commonly used for anchor definitions as it's not part of ClusterConfig schema. +func resolveYAMLAnchors(data []byte) ([]byte, error) { + // Security: Limit input size to prevent memory exhaustion attacks + const maxInputSize = 1024 * 1024 // 1MB limit + if len(data) > maxInputSize { + return nil, fmt.Errorf("YAML input too large: %d bytes exceeds limit of %d bytes", len(data), maxInputSize) + } + + // Security: Check for excessive nesting depth to prevent stack overflow + const maxNestingDepth = 10 + if nestingDepth := countNestingDepth(data); nestingDepth > maxNestingDepth { + return nil, fmt.Errorf("YAML nesting too deep: %d levels exceeds limit of %d", nestingDepth, maxNestingDepth) + } + + // Resolve YAML anchors and aliases by unmarshaling to interface{} first. + // This step processes any YAML anchors (&anchor) and aliases (*alias) in the input, + // expanding them to their full values. + var resolved interface{} + if err := yaml.Unmarshal(data, &resolved); err != nil { + return nil, err + } + + // Marshal back to get resolved YAML without anchors/aliases + resolvedData, err := yaml.Marshal(resolved) + if err != nil { + return nil, err + } + + // Security: Check for excessive expansion (YAML bomb protection) + const maxExpansionRatio = 10 + if len(resolvedData) > len(data)*maxExpansionRatio { + return nil, fmt.Errorf("YAML expansion too large: %d bytes expanded from %d bytes (ratio: %d, limit: %d)", + len(resolvedData), len(data), len(resolvedData)/len(data), maxExpansionRatio) + } + + // Remove the "aliases" field commonly used for YAML anchor definitions + // as it's not part of the ClusterConfig schema + var temp map[string]interface{} + if err := yaml.Unmarshal(resolvedData, &temp); err != nil { + return nil, err + } + + // Remove only the aliases field, maintaining strict validation for everything else + delete(temp, "aliases") + + // Marshal back to clean YAML + return yaml.Marshal(temp) +} + +// countNestingDepth estimates YAML nesting depth by counting indentation +func countNestingDepth(data []byte) int { + lines := strings.Split(string(data), "\n") + maxDepth := 0 + for _, line := range lines { + if strings.TrimSpace(line) == "" || strings.HasPrefix(strings.TrimSpace(line), "#") { + continue + } + depth := 0 + for _, char := range line { + if char == ' ' { + depth++ + } else if char == '\t' { + depth += 2 // Count tabs as 2 spaces + } else { + break + } + } + if depth/2 > maxDepth { // Assuming 2-space indentation + maxDepth = depth / 2 + } + } + return maxDepth +} + // ParseConfig parses data into a ClusterConfig func ParseConfig(data []byte) (*api.ClusterConfig, error) { + // Resolve YAML anchors and aliases before parsing + cleanData, err := resolveYAMLAnchors(data) + if err != nil { + return nil, err + } + // strict mode is not available in runtime.Decode, so we use the parser // directly; we don't store the resulting object, this is just the means // of detecting any unknown keys // NOTE: we must use sigs.k8s.io/yaml, as it behaves differently from // github.com/ghodss/yaml, which didn't handle nested structs well - if err := yaml.UnmarshalStrict(data, &api.ClusterConfig{}); err != nil { + if err := yaml.UnmarshalStrict(cleanData, &api.ClusterConfig{}); err != nil { return nil, err } - obj, err := runtime.Decode(scheme.Codecs.UniversalDeserializer(), data) + obj, err := runtime.Decode(scheme.Codecs.UniversalDeserializer(), cleanData) if err != nil { return nil, err } diff --git a/pkg/eks/api_anchors_test.go b/pkg/eks/api_anchors_test.go new file mode 100644 index 0000000000..7e5f6a0da4 --- /dev/null +++ b/pkg/eks/api_anchors_test.go @@ -0,0 +1,297 @@ +package eks_test + +import ( + "fmt" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + api "github.com/weaveworks/eksctl/pkg/apis/eksctl.io/v1alpha5" + "github.com/weaveworks/eksctl/pkg/eks" +) + +var _ = Describe("ParseConfig with YAML anchors and aliases", func() { + BeforeEach(func() { + err := api.Register() + Expect(err).NotTo(HaveOccurred()) + }) + + It("should parse ClusterConfig with YAML anchors and aliases", func() { + yamlWithAnchors := `--- +apiVersion: eksctl.io/v1alpha5 +kind: ClusterConfig + +aliases: + genericAttachPolicyARNs: &genericAttachPolicyARNs + - arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly + - arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy + + genericNodeGroupSettings: &genericNodeGroupSettings + minSize: 2 + maxSize: 5 + desiredCapacity: 2 + volumeSize: 30 + volumeType: gp3 + instanceTypes: + - "t3a.small" + - "t3.small" + +metadata: + name: eks-demo + region: us-east-1 + +managedNodeGroups: + - name: generic-1 + <<: *genericNodeGroupSettings + iam: + attachPolicyARNs: *genericAttachPolicyARNs + + - name: generic-2 + <<: *genericNodeGroupSettings + iam: + attachPolicyARNs: *genericAttachPolicyARNs` + + cfg, err := eks.ParseConfig([]byte(yamlWithAnchors)) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + Expect(cfg.Metadata.Name).To(Equal("eks-demo")) + Expect(cfg.Metadata.Region).To(Equal("us-east-1")) + Expect(cfg.ManagedNodeGroups).To(HaveLen(2)) + + // Verify first node group + ng1 := cfg.ManagedNodeGroups[0] + Expect(ng1.Name).To(Equal("generic-1")) + Expect(*ng1.MinSize).To(Equal(2)) + Expect(*ng1.MaxSize).To(Equal(5)) + Expect(*ng1.DesiredCapacity).To(Equal(2)) + Expect(*ng1.VolumeSize).To(Equal(30)) + Expect(*ng1.VolumeType).To(Equal("gp3")) + Expect(ng1.InstanceTypes).To(Equal([]string{"t3a.small", "t3.small"})) + Expect(ng1.IAM.AttachPolicyARNs).To(Equal([]string{ + "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly", + "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy", + })) + + // Verify second node group has same settings + ng2 := cfg.ManagedNodeGroups[1] + Expect(ng2.Name).To(Equal("generic-2")) + Expect(*ng2.MinSize).To(Equal(2)) + Expect(*ng2.MaxSize).To(Equal(5)) + Expect(*ng2.DesiredCapacity).To(Equal(2)) + Expect(*ng2.VolumeSize).To(Equal(30)) + Expect(*ng2.VolumeType).To(Equal("gp3")) + Expect(ng2.InstanceTypes).To(Equal([]string{"t3a.small", "t3.small"})) + Expect(ng2.IAM.AttachPolicyARNs).To(Equal([]string{ + "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly", + "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy", + })) + }) + + It("should parse ClusterConfig with inline anchors", func() { + yamlWithInlineAnchors := `--- +apiVersion: eksctl.io/v1alpha5 +kind: ClusterConfig + +metadata: + name: eks-demo + region: us-east-1 + +managedNodeGroups: + - name: generic-al2 + instanceTypes: &instance-types + - "t3a.small" + - "t3.small" + minSize: 2 + iam: + attachPolicyARNs: &policy-arns + - arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly + + - name: generic-al2023 + instanceTypes: *instance-types + minSize: 3 + iam: + attachPolicyARNs: *policy-arns` + + cfg, err := eks.ParseConfig([]byte(yamlWithInlineAnchors)) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.ManagedNodeGroups).To(HaveLen(2)) + Expect(cfg.ManagedNodeGroups[0].InstanceTypes).To(Equal(cfg.ManagedNodeGroups[1].InstanceTypes)) + Expect(cfg.ManagedNodeGroups[0].IAM.AttachPolicyARNs).To(Equal(cfg.ManagedNodeGroups[1].IAM.AttachPolicyARNs)) + }) + + It("should parse ClusterConfig without anchors normally", func() { + yamlWithoutAnchors := `--- +apiVersion: eksctl.io/v1alpha5 +kind: ClusterConfig + +metadata: + name: simple-cluster + region: us-west-2 + +managedNodeGroups: + - name: workers + instanceTypes: + - t3.medium` + + cfg, err := eks.ParseConfig([]byte(yamlWithoutAnchors)) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Metadata.Name).To(Equal("simple-cluster")) + Expect(cfg.ManagedNodeGroups).To(HaveLen(1)) + }) + + It("should reject invalid YAML with malformed anchors", func() { + malformedYAML := `--- +apiVersion: eksctl.io/v1alpha5 +kind: ClusterConfig + +metadata: + name: test + region: us-east-1 + +managedNodeGroups: + - name: workers + instanceTypes: *nonexistent-anchor` + + _, err := eks.ParseConfig([]byte(malformedYAML)) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unknown anchor")) + }) + + It("should reject YAML input that is too large", func() { + // Create a YAML that exceeds the 1MB limit + largeYAML := "apiVersion: eksctl.io/v1alpha5\nkind: ClusterConfig\nmetadata:\n name: " + strings.Repeat("a", 1025*1024) + _, err := eks.ParseConfig([]byte(largeYAML)) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("YAML input too large")) + }) + + It("should reject YAML with excessive nesting depth", func() { + // Create deeply nested YAML that exceeds the 10 level limit + deepYAML := "apiVersion: eksctl.io/v1alpha5\nkind: ClusterConfig\nmetadata:\n name: test\n" + for i := 0; i < 12; i++ { + deepYAML += strings.Repeat(" ", i) + "level" + fmt.Sprintf("%d:\n", i) + } + deepYAML += strings.Repeat(" ", 12) + "value: deep" + + _, err := eks.ParseConfig([]byte(deepYAML)) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("YAML nesting too deep")) + }) + + It("should reject unknown top-level fields while allowing aliases", func() { + yamlWithUnknownField := `--- +apiVersion: eksctl.io/v1alpha5 +kind: ClusterConfig + +metadata: + name: test + region: us-east-1 + +unknownField: should-be-rejected + +managedNodeGroups: + - name: workers + instanceTypes: + - t3.medium` + + _, err := eks.ParseConfig([]byte(yamlWithUnknownField)) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("unknown field")) + }) + + It("should handle empty YAML gracefully", func() { + emptyYAML := `--- +apiVersion: eksctl.io/v1alpha5 +kind: ClusterConfig` + + cfg, err := eks.ParseConfig([]byte(emptyYAML)) + Expect(err).NotTo(HaveOccurred()) // Basic parsing should succeed + Expect(cfg).NotTo(BeNil()) + Expect(cfg.TypeMeta.APIVersion).To(Equal("eksctl.io/v1alpha5")) + Expect(cfg.TypeMeta.Kind).To(Equal("ClusterConfig")) + }) + + It("should handle YAML with only aliases section", func() { + aliasOnlyYAML := `--- +apiVersion: eksctl.io/v1alpha5 +kind: ClusterConfig + +aliases: + unused: &unused + value: test + +metadata: + name: test + region: us-east-1` + + cfg, err := eks.ParseConfig([]byte(aliasOnlyYAML)) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.Metadata.Name).To(Equal("test")) + }) + + It("should handle nested anchors", func() { + nestedAnchorsYAML := `--- +apiVersion: eksctl.io/v1alpha5 +kind: ClusterConfig + +aliases: + base: &base + minSize: 1 + extended: &extended + <<: *base + maxSize: 5 + +metadata: + name: test + region: us-east-1 + +managedNodeGroups: + - name: workers + <<: *extended` + + cfg, err := eks.ParseConfig([]byte(nestedAnchorsYAML)) + Expect(err).NotTo(HaveOccurred()) + Expect(*cfg.ManagedNodeGroups[0].MinSize).To(Equal(1)) + Expect(*cfg.ManagedNodeGroups[0].MaxSize).To(Equal(5)) + }) + + It("should reject invalid YAML syntax", func() { + invalidYAML := `--- +apiVersion: eksctl.io/v1alpha5 +kind: ClusterConfig +metadata: + name: test + region: us-east-1 + invalid: [unclosed array` + + _, err := eks.ParseConfig([]byte(invalidYAML)) + Expect(err).To(HaveOccurred()) + }) + + It("should handle mixed anchor types in same config", func() { + mixedAnchorsYAML := `--- +apiVersion: eksctl.io/v1alpha5 +kind: ClusterConfig + +metadata: + name: test + region: us-east-1 + +managedNodeGroups: + - name: ng1 + instanceTypes: &instances + - t3.medium + minSize: &min-size 2 + maxSize: 5 + - name: ng2 + instanceTypes: *instances + minSize: *min-size + maxSize: 10` + + cfg, err := eks.ParseConfig([]byte(mixedAnchorsYAML)) + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.ManagedNodeGroups[0].InstanceTypes).To(Equal(cfg.ManagedNodeGroups[1].InstanceTypes)) + Expect(*cfg.ManagedNodeGroups[0].MinSize).To(Equal(*cfg.ManagedNodeGroups[1].MinSize)) + }) +}) diff --git a/userdocs/src/usage/faq.md b/userdocs/src/usage/faq.md index dd16bc3c9a..5ad0be45e6 100644 --- a/userdocs/src/usage/faq.md +++ b/userdocs/src/usage/faq.md @@ -5,6 +5,31 @@ Yes! From version `0.40.0` you can run `eksctl` against any cluster, whether it was created by `eksctl` or not. Find out more [here](/usage/unowned-clusters). +???+ question "Can I use YAML anchors to reduce configuration duplication?" + + Yes! `eksctl` supports YAML anchors and aliases to avoid repeating configuration: + + ```yaml + # Inline anchors (recommended) + managedNodeGroups: + - name: workers-1 + instanceTypes: &instance-types + - t3.medium + - t3.large + - name: workers-2 + instanceTypes: *instance-types + + # Or with aliases section + aliases: + common-settings: &common + minSize: 2 + maxSize: 10 + + managedNodeGroups: + - name: workers + <<: *common + ``` + ## Nodegroups ???+ question "How can I change the instance type of my nodegroup?"