diff --git a/internal/cloudinit/cloudinit.go b/internal/cloudinit/cloudinit.go index 5fedbf8..b574fc1 100644 --- a/internal/cloudinit/cloudinit.go +++ b/internal/cloudinit/cloudinit.go @@ -4,11 +4,15 @@ package cloudinit import ( + "errors" + "fmt" "maps" "gopkg.in/yaml.v3" ) +var ErrReservedKey = errors.New("reserved key in Extra map") + // WriteFile represents a file to be written by cloud-init. type WriteFile struct { Path string `yaml:"path"` @@ -68,6 +72,18 @@ func BuildCloudConfig(opts CloudConfigOpts) (string, error) { doc["users"] = []map[string]interface{}{user} } + // Validate that Extra keys don't collide with reserved keys + reservedKeys := []string{"packages", "write_files", "runcmd", "users", "ssh_pwauth"} + for _, key := range reservedKeys { + if _, exists := opts.Extra[key]; exists { + return "", fmt.Errorf( + "extra[%q] collides with reserved cloud-config key: %w", + key, + ErrReservedKey, + ) + } + } + // Merge extra keys at top level maps.Copy(doc, opts.Extra) diff --git a/internal/cloudinit/cloudinit_test.go b/internal/cloudinit/cloudinit_test.go index 1ef6563..e79f3fc 100644 --- a/internal/cloudinit/cloudinit_test.go +++ b/internal/cloudinit/cloudinit_test.go @@ -333,4 +333,89 @@ var _ = Describe("BuildCloudConfig", func() { Expect(user["name"]).To(Equal("testuser")) }) }) + + Context("Extra key collision validation", func() { + It("should reject Extra with 'packages' key", func() { + opts := cloudinit.CloudConfigOpts{ + Packages: []string{"vim"}, + Extra: map[string]interface{}{ + "packages": []string{"curl"}, + }, + } + _, err := cloudinit.BuildCloudConfig(opts) + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(ContainSubstring("packages"))) + Expect(err).To(MatchError(ContainSubstring("reserved"))) + }) + + It("should reject Extra with 'write_files' key", func() { + opts := cloudinit.CloudConfigOpts{ + Extra: map[string]interface{}{ + "write_files": []interface{}{}, + }, + } + _, err := cloudinit.BuildCloudConfig(opts) + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(ContainSubstring("write_files"))) + }) + + It("should reject Extra with 'runcmd' key", func() { + opts := cloudinit.CloudConfigOpts{ + Extra: map[string]interface{}{ + "runcmd": []interface{}{}, + }, + } + _, err := cloudinit.BuildCloudConfig(opts) + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(ContainSubstring("runcmd"))) + }) + + It("should reject Extra with 'users' key", func() { + opts := cloudinit.CloudConfigOpts{ + Extra: map[string]interface{}{ + "users": []interface{}{}, + }, + } + _, err := cloudinit.BuildCloudConfig(opts) + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(ContainSubstring("users"))) + }) + + It("should reject Extra with 'ssh_pwauth' key", func() { + opts := cloudinit.CloudConfigOpts{ + Extra: map[string]interface{}{ + "ssh_pwauth": true, + }, + } + _, err := cloudinit.BuildCloudConfig(opts) + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(ContainSubstring("ssh_pwauth"))) + }) + + It("should allow Extra with non-reserved keys", func() { + opts := cloudinit.CloudConfigOpts{ + Extra: map[string]interface{}{ + "timezone": "UTC", + "hostname": "test-vm", + "locale": "en_US.UTF-8", + }, + } + result, err := cloudinit.BuildCloudConfig(opts) + Expect(err).NotTo(HaveOccurred()) + + var parsed map[string]interface{} + Expect(yaml.Unmarshal([]byte(result), &parsed)).To(Succeed()) + Expect(parsed["timezone"]).To(Equal("UTC")) + Expect(parsed["hostname"]).To(Equal("test-vm")) + Expect(parsed["locale"]).To(Equal("en_US.UTF-8")) + }) + + It("should allow empty Extra map", func() { + opts := cloudinit.CloudConfigOpts{ + Extra: map[string]interface{}{}, + } + _, err := cloudinit.BuildCloudConfig(opts) + Expect(err).NotTo(HaveOccurred()) + }) + }) })