diff --git a/rest-api/api/pkg/api/model/instance.go b/rest-api/api/pkg/api/model/instance.go index 4665055fad..496b5e106a 100644 --- a/rest-api/api/pkg/api/model/instance.go +++ b/rest-api/api/pkg/api/model/instance.go @@ -789,7 +789,7 @@ func (icr *APIInstanceCreateRequest) ValidateAndSetOperatingSystemData(cfg *conf if len(userDataMap.Content) > 0 { documentRoot = userDataMap.Content[0] - if documentRoot.Kind == yaml.MappingNode { + if util.PhoneHomeSupportsUserDataRoot(documentRoot) { isUserDataValidYAML = true } } @@ -815,7 +815,7 @@ func (icr *APIInstanceCreateRequest) ValidateAndSetOperatingSystemData(cfg *conf // so we want to do this check silently and not alert people who // are using non-YAML user-data. - if err := util.RemovePhoneHomeFromUserData(documentRoot, cutil.GetPtr(cfg.GetSitePhoneHomeUrl())); err != nil { + if _, err := util.RemovePhoneHomeFromUserData(documentRoot, cutil.GetPtr(cfg.GetSitePhoneHomeUrl())); err != nil { return validation.Errors{ "userData": errors.New("failed to disable phone-home in userData after processing phone home config"), } @@ -824,7 +824,7 @@ func (icr *APIInstanceCreateRequest) ValidateAndSetOperatingSystemData(cfg *conf } // If there's still user-data, marshal so that it can be stored in the DB later - if isUserDataValidYAML && len(documentRoot.Content) > 0 { + if isUserDataValidYAML && (documentRoot.Kind == yaml.SequenceNode || len(documentRoot.Content) > 0) { byteUserData, err := yaml.Marshal(userDataMap) if err != nil { @@ -1114,7 +1114,7 @@ func (bicr *APIBatchInstanceCreateRequest) ValidateAndSetOperatingSystemData(cfg if len(userDataMap.Content) > 0 { documentRoot = userDataMap.Content[0] - if documentRoot.Kind == yaml.MappingNode { + if util.PhoneHomeSupportsUserDataRoot(documentRoot) { isUserDataValidYAML = true } } @@ -1135,7 +1135,7 @@ func (bicr *APIBatchInstanceCreateRequest) ValidateAndSetOperatingSystemData(cfg } } else if isUserDataValidYAML { - if err := util.RemovePhoneHomeFromUserData(documentRoot, cutil.GetPtr(cfg.GetSitePhoneHomeUrl())); err != nil { + if _, err := util.RemovePhoneHomeFromUserData(documentRoot, cutil.GetPtr(cfg.GetSitePhoneHomeUrl())); err != nil { return validation.Errors{ "userData": errors.New("failed to disable phone-home in userData after processing phone home config"), } @@ -1143,7 +1143,7 @@ func (bicr *APIBatchInstanceCreateRequest) ValidateAndSetOperatingSystemData(cfg } // If there's still user-data, marshal so that it can be stored in the DB later - if isUserDataValidYAML && len(documentRoot.Content) > 0 { + if isUserDataValidYAML && (documentRoot.Kind == yaml.SequenceNode || len(documentRoot.Content) > 0) { byteUserData, err := yaml.Marshal(userDataMap) if err != nil { @@ -1401,7 +1401,7 @@ func (iur *APIInstanceUpdateRequest) ValidateAndSetOperatingSystemData(cfg *conf if len(userDataMap.Content) > 0 { documentRoot = userDataMap.Content[0] - if documentRoot.Kind == yaml.MappingNode { + if util.PhoneHomeSupportsUserDataRoot(documentRoot) { isUserDataValidYAML = true } } @@ -1427,7 +1427,7 @@ func (iur *APIInstanceUpdateRequest) ValidateAndSetOperatingSystemData(cfg *conf // so we want to do this check silently and not alert people who // are using non-YAML user-data. - if err := util.RemovePhoneHomeFromUserData(documentRoot, cutil.GetPtr(cfg.GetSitePhoneHomeUrl())); err != nil { + if _, err := util.RemovePhoneHomeFromUserData(documentRoot, cutil.GetPtr(cfg.GetSitePhoneHomeUrl())); err != nil { return validation.Errors{ "userData": errors.New("failed to disable phone-home in userData after processing phone home config"), } @@ -1435,7 +1435,7 @@ func (iur *APIInstanceUpdateRequest) ValidateAndSetOperatingSystemData(cfg *conf } // If there's still user-data, marshal so that it can be stored in the DB later - if isUserDataValidYAML && len(documentRoot.Content) > 0 { + if isUserDataValidYAML && (documentRoot.Kind == yaml.SequenceNode || len(documentRoot.Content) > 0) { byteUserData, err := yaml.Marshal(userDataMap) if err != nil { diff --git a/rest-api/api/pkg/api/model/operatingsystem.go b/rest-api/api/pkg/api/model/operatingsystem.go index 6b4fe28f1c..f1142f5794 100644 --- a/rest-api/api/pkg/api/model/operatingsystem.go +++ b/rest-api/api/pkg/api/model/operatingsystem.go @@ -449,7 +449,7 @@ func (oscr *APIOperatingSystemCreateRequest) ValidateAndSetUserData(phonehomeUrl // counts as valid YAML. if len(userDataMap.Content) > 0 { documentRoot = userDataMap.Content[0] - if documentRoot.Kind == yaml.MappingNode { + if util.PhoneHomeSupportsUserDataRoot(documentRoot) { isUserDataValidYAML = true } } @@ -797,7 +797,7 @@ func (osur *APIOperatingSystemUpdateRequest) ValidateAndSetUserData(phonehomeUrl // counts as valid YAML. if len(userDataMap.Content) > 0 { documentRoot = userDataMap.Content[0] - if documentRoot.Kind == yaml.MappingNode { + if util.PhoneHomeSupportsUserDataRoot(documentRoot) { isUserDataValidYAML = true } } @@ -824,7 +824,7 @@ func (osur *APIOperatingSystemUpdateRequest) ValidateAndSetUserData(phonehomeUrl // but the UI will always send false if phone-home is unchecked, // so we want to do this check silently and not alert people who // are using non-YAML user-data. - if err := util.RemovePhoneHomeFromUserData(documentRoot, &phonehomeUrl); err != nil { + if _, err := util.RemovePhoneHomeFromUserData(documentRoot, &phonehomeUrl); err != nil { return validation.Errors{ "userData": errors.New("failed to remove phone home config from userData"), } @@ -836,11 +836,12 @@ func (osur *APIOperatingSystemUpdateRequest) ValidateAndSetUserData(phonehomeUrl return nil } - if len(documentRoot.Content) == 0 { + if documentRoot.Kind == yaml.MappingNode && len(documentRoot.Content) == 0 { // If we've arrived here, then the original user-data // was valid, but phone-home has been disabled, and the // phone-home block was the only thing in the original YAML, - // so just blank the DB field. + // so just blank the DB field. An emptied #cloud-config-archive is + // serialized below instead, so it keeps its header. osur.UserData = cutil.GetPtr("") return nil } diff --git a/rest-api/api/pkg/api/model/operatingsystem_test.go b/rest-api/api/pkg/api/model/operatingsystem_test.go index fca567041e..2e131fbaae 100644 --- a/rest-api/api/pkg/api/model/operatingsystem_test.go +++ b/rest-api/api/pkg/api/model/operatingsystem_test.go @@ -514,6 +514,84 @@ func TestAPIOperatingSystemCreateRequest_ValidateAndSetUserData(t *testing.T) { } } +func TestAPIOperatingSystemCreateRequest_ValidateAndSetUserData_Archive(t *testing.T) { + const phoneHomeURL = "http://localhost/phone-home" + + const archive = `#cloud-config-archive +- type: text/cloud-config + content: | + #cloud-config + packages: + - curl +` + + t.Run("appends phone-home as a new entry in a cloud-config-archive", func(t *testing.T) { + req := APIOperatingSystemCreateRequest{ + Name: "test-name", + TenantID: cutil.GetPtr(uuid.NewString()), + UserData: cutil.GetPtr(archive), + PhoneHomeEnabled: cutil.GetPtr(true), + } + + require.NoError(t, req.ValidateAndSetUserData(phoneHomeURL)) + require.NotNil(t, req.UserData) + assert.True(t, strings.HasPrefix(*req.UserData, "#cloud-config-archive\n"), + "archive header must be preserved: %s", *req.UserData) + assert.Contains(t, *req.UserData, phoneHomeURL) + }) + + t.Run("replaces a standalone phone-home entry rather than duplicating it", func(t *testing.T) { + withPhoneHome := archive + `- type: text/cloud-config + content: | + #cloud-config + phone_home: + url: http://existing +` + req := APIOperatingSystemCreateRequest{ + Name: "test-name", + TenantID: cutil.GetPtr(uuid.NewString()), + UserData: cutil.GetPtr(withPhoneHome), + PhoneHomeEnabled: cutil.GetPtr(true), + } + + require.NoError(t, req.ValidateAndSetUserData(phoneHomeURL)) + require.NotNil(t, req.UserData) + assert.Contains(t, *req.UserData, phoneHomeURL) + assert.NotContains(t, *req.UserData, "http://existing") + assert.Equal(t, 1, strings.Count(*req.UserData, "phone_home:")) + }) +} + +func TestAPIOperatingSystemUpdateRequest_ValidateAndSetUserData_EmptiedArchiveKeepsHeader(t *testing.T) { + const phoneHomeURL = "http://localhost/phone-home" + + // Disabling phone-home on an archive whose only entry was phone-home must + // leave a valid (empty) #cloud-config-archive, not blank the field. + existing := &cdbm.OperatingSystem{ + ID: uuid.New(), + Name: "ab", + UserData: cutil.GetPtr(`#cloud-config-archive +- type: text/cloud-config + content: | + #cloud-config + phone_home: + url: ` + phoneHomeURL + ` +`), + PhoneHomeEnabled: true, + Status: cdbm.OperatingSystemStatusReady, + Type: cdbm.OperatingSystemTypeIPXE, + CreatedBy: uuid.New(), + } + + req := APIOperatingSystemUpdateRequest{PhoneHomeEnabled: cutil.GetPtr(false)} + + require.NoError(t, req.ValidateAndSetUserData(phoneHomeURL, existing)) + require.NotNil(t, req.UserData) + assert.True(t, strings.HasPrefix(*req.UserData, "#cloud-config-archive"), + "emptied archive must keep its header, got: %q", *req.UserData) + assert.NotContains(t, *req.UserData, "phone_home") +} + func TestAPIOperatingSystemUpdateRequest_ValidateAndSetUserData(t *testing.T) { type fields struct { Name string diff --git a/rest-api/api/pkg/api/model/util/util.go b/rest-api/api/pkg/api/model/util/util.go index 7fecf3b651..a28477830e 100644 --- a/rest-api/api/pkg/api/model/util/util.go +++ b/rest-api/api/pkg/api/model/util/util.go @@ -15,13 +15,17 @@ import ( const ( // configuration for phone home - SitePhoneHomeName = "phone_home" - SitePhoneHomePost = "post" - SitePhoneHomePostAll = "all" - SitePhoneHomeUrl = "url" - SiteCloudConfig = "#cloud-config" - autoinstallName = "autoinstall" - autoinstallUserData = "user-data" + SitePhoneHomeName = "phone_home" + SitePhoneHomePost = "post" + SitePhoneHomePostAll = "all" + SitePhoneHomeUrl = "url" + SiteCloudConfig = "#cloud-config" + SiteCloudConfigArchive = "#cloud-config-archive" + autoinstallName = "autoinstall" + autoinstallUserData = "user-data" + archiveEntryType = "type" + archiveEntryContent = "content" + archiveContentType = "text/cloud-config" ) // Removes cloud-init phone-home blocks from the document root and, for @@ -29,27 +33,45 @@ const ( // If `url` is nil, then any phone-home block found will be removed. // If `url` is non-nil, then the phone-home block will only be removed if // the URL matches the value of `url`. -func RemovePhoneHomeFromUserData(documentRoot *yaml.Node, url *string) error { - if documentRoot == nil || documentRoot.Kind != yaml.MappingNode { - return fmt.Errorf("node must be non-nil MappingNode for user-data removal") +// RemovePhoneHomeFromUserData reports whether it removed any phone-home block, +// so callers can tell an untouched document from a modified one even when the +// removal happened in a nested autoinstall.user-data mapping. +func RemovePhoneHomeFromUserData(documentRoot *yaml.Node, url *string) (bool, error) { + if documentRoot == nil { + return false, fmt.Errorf("node must be non-nil for user-data removal") + } + + // A #cloud-config-archive is a YAML sequence: phone-home lives in its own + // cloud-config entry rather than at the document root. + if isCloudConfigArchive(documentRoot) { + return removePhoneHomeFromArchive(documentRoot, url) + } + + if !isCloudConfig(documentRoot) { + return false, fmt.Errorf("node must be a #cloud-config mapping or a #cloud-config-archive for user-data removal") } - removePhoneHomeFromMapping(documentRoot, url) + removed := removePhoneHomeFromMapping(documentRoot, url) autoinstallNode := mappingValue(documentRoot, autoinstallName) if autoinstallNode == nil || autoinstallNode.Kind != yaml.MappingNode { - return nil + return removed, nil } targetUserDataNode := mappingValue(autoinstallNode, autoinstallUserData) if targetUserDataNode != nil && targetUserDataNode.Kind == yaml.MappingNode { - removePhoneHomeFromMapping(targetUserDataNode, url) + if removePhoneHomeFromMapping(targetUserDataNode, url) { + removed = true + } } - return nil + return removed, nil } -func removePhoneHomeFromMapping(mappingNode *yaml.Node, url *string) { +// removePhoneHomeFromMapping removes phone-home from a mapping node and reports +// whether it removed anything. +func removePhoneHomeFromMapping(mappingNode *yaml.Node, url *string) bool { + removed := false contentLen := len(mappingNode.Content) // If phone-home is being disabled, then delete @@ -87,6 +109,7 @@ func removePhoneHomeFromMapping(mappingNode *yaml.Node, url *string) { // Reduce the loop limit since the // list being worked on is shorter now. contentLen = len(mappingNode.Content) + removed = true continue } @@ -104,6 +127,7 @@ func removePhoneHomeFromMapping(mappingNode *yaml.Node, url *string) { mappingNode.Content = append(mappingNode.Content[:i], mappingNode.Content[i+2:]...) i -= 2 contentLen = len(mappingNode.Content) + removed = true break } } @@ -112,11 +136,23 @@ func removePhoneHomeFromMapping(mappingNode *yaml.Node, url *string) { } } } + + return removed } func InsertPhoneHomeIntoUserData(documentRoot *yaml.Node, url string) error { - if documentRoot == nil || documentRoot.Kind != yaml.MappingNode { - return fmt.Errorf("node must be non-nil MappingNode for user-data insertion") + if documentRoot == nil { + return fmt.Errorf("node must be non-nil for user-data insertion") + } + + // A #cloud-config-archive is a YAML sequence: append phone-home as a new + // cloud-config entry instead of inserting into the document root. + if isCloudConfigArchive(documentRoot) { + return insertPhoneHomeIntoArchive(documentRoot, url) + } + + if !isCloudConfig(documentRoot) { + return fmt.Errorf("node must be a #cloud-config mapping or a #cloud-config-archive for user-data insertion") } if documentRoot.Content == nil { @@ -146,7 +182,7 @@ func InsertPhoneHomeIntoUserData(documentRoot *yaml.Node, url string) error { // Remove existing phone-home blocks from both supported locations before // inserting the canonical block. - if err := RemovePhoneHomeFromUserData(documentRoot, nil); err != nil { + if _, err := RemovePhoneHomeFromUserData(documentRoot, nil); err != nil { return err } @@ -187,6 +223,209 @@ func InsertPhoneHomeIntoUserData(documentRoot *yaml.Node, url string) error { return nil } +// PhoneHomeSupportsUserDataRoot reports whether a cloud-init user-data document +// root can carry a phone-home block: a #cloud-config document (mapping) has the +// block inserted at its root, while a #cloud-config-archive (sequence) gets a +// dedicated phone-home cloud-config appended as a new archive entry. +func PhoneHomeSupportsUserDataRoot(documentRoot *yaml.Node) bool { + return isCloudConfig(documentRoot) || isCloudConfigArchive(documentRoot) +} + +// isCloudConfig reports whether documentRoot is #cloud-config user-data: a +// mapping whose header, if present, is the #cloud-config marker. A header-less +// mapping is accepted (the header is added on output), but a mapping carrying a +// different header - e.g. a #!/bin/bash script that happens to parse as a map - +// is rejected. +func isCloudConfig(documentRoot *yaml.Node) bool { + if documentRoot == nil || documentRoot.Kind != yaml.MappingNode { + return false + } + + header := userDataHeader(documentRoot) + + return header == "" || header == SiteCloudConfig +} + +// isCloudConfigArchive reports whether documentRoot is a #cloud-config-archive: +// a YAML sequence whose first line carries the cloud-init archive header. A +// header-less list is not valid cloud-init user-data, so it is not treated as +// an archive. +func isCloudConfigArchive(documentRoot *yaml.Node) bool { + return documentRoot != nil && documentRoot.Kind == yaml.SequenceNode && + userDataHeader(documentRoot) == SiteCloudConfigArchive +} + +// userDataHeader returns the cloud-init format header that yaml attaches as the +// head comment of the document's first child (e.g. #cloud-config or +// #cloud-config-archive), or "" when there is none. +func userDataHeader(documentRoot *yaml.Node) string { + if documentRoot == nil { + return "" + } + + // yaml attaches the header to the node itself for an empty archive, and to + // the first child otherwise. + comment := documentRoot.HeadComment + if comment == "" && len(documentRoot.Content) > 0 { + comment = documentRoot.Content[0].HeadComment + } + + firstLine, _, _ := strings.Cut(comment, "\n") + + return strings.TrimSpace(firstLine) +} + +// insertPhoneHomeIntoArchive appends a dedicated phone-home cloud-config entry to +// a #cloud-config-archive. cloud-init merges each archive part independently, so a +// standalone phone_home part takes effect on its own. The entry content is built +// by the same InsertPhoneHomeIntoUserData path used for #cloud-config documents, +// so both formats share one source of truth for the phone-home block. Any +// existing phone-home entry is removed first to keep re-enabling idempotent. +func insertPhoneHomeIntoArchive(archiveRoot *yaml.Node, url string) error { + if _, err := removePhoneHomeFromArchive(archiveRoot, nil); err != nil { + return err + } + + content := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + if err := InsertPhoneHomeIntoUserData(content, url); err != nil { + return err + } + + rendered, err := yaml.Marshal(content) + if err != nil { + return errors.New("failed to render phone-home cloud-config") + } + + archiveRoot.Content = append(archiveRoot.Content, newCloudConfigArchiveEntry(string(rendered))) + + return nil +} + +// removePhoneHomeFromArchive strips phone-home from every cloud-config entry of a +// #cloud-config-archive using the same RemovePhoneHomeFromUserData path as +// #cloud-config documents. Entries left empty are dropped, entries with no +// phone-home are left untouched, and the archive header comment yaml attaches to +// the first element is preserved even when that element is removed. +func removePhoneHomeFromArchive(archiveRoot *yaml.Node, url *string) (bool, error) { + // Capture the archive header so it survives even when its carrier entry is + // removed. yaml attaches it to the first entry, or to the sequence node + // itself for an empty archive. + header := archiveRoot.HeadComment + if header == "" && len(archiveRoot.Content) > 0 { + header = archiveRoot.Content[0].HeadComment + } + + archiveRemoved := false + kept := archiveRoot.Content[:0] + for _, entry := range archiveRoot.Content { + content := cloudConfigArchiveContent(entry) + if content == nil { + kept = append(kept, entry) + continue + } + + document := &yaml.Node{} + if err := yaml.Unmarshal([]byte(content.Value), document); err != nil || len(document.Content) == 0 { + kept = append(kept, entry) + continue + } + + root := document.Content[0] + if !PhoneHomeSupportsUserDataRoot(root) { + // e.g. a script whose content parses as a map; leave it untouched. + kept = append(kept, entry) + continue + } + + removed, err := RemovePhoneHomeFromUserData(root, url) + if err != nil { + return false, err + } + + switch { + case !removed: + // Nothing removed here; keep the entry as authored. + kept = append(kept, entry) + case len(root.Content) == 0: + // The entry held only phone-home, so drop it entirely. + archiveRemoved = true + default: + // A nested block was removed (e.g. under autoinstall.user-data); + // re-render so the change is persisted. + rendered, err := yaml.Marshal(document) + if err != nil { + return false, errors.New("failed to re-render archive entry after removing phone-home") + } + content.SetString(string(rendered)) + content.Style = yaml.LiteralStyle + kept = append(kept, entry) + archiveRemoved = true + } + } + archiveRoot.Content = kept + + // Restore the header onto whichever node now carries it: the sequence node + // itself once the last entry is removed, otherwise the first remaining + // entry - prepending it when that entry already has its own comment so the + // header is not lost. + switch { + case header == "": + case len(archiveRoot.Content) == 0: + archiveRoot.HeadComment = header + case userDataHeader(archiveRoot.Content[0]) == SiteCloudConfigArchive: + // already carries the archive header + case archiveRoot.Content[0].HeadComment == "": + archiveRoot.Content[0].HeadComment = header + default: + archiveRoot.Content[0].HeadComment = header + "\n" + archiveRoot.Content[0].HeadComment + } + + return archiveRemoved, nil +} + +// newCloudConfigArchiveEntry builds a {type: text/cloud-config, content: ...} +// mapping node for inclusion in a #cloud-config-archive sequence. +func newCloudConfigArchiveEntry(content string) *yaml.Node { + typeKey := &yaml.Node{} + typeKey.SetString(archiveEntryType) + typeValue := &yaml.Node{} + typeValue.SetString(archiveContentType) + + contentKey := &yaml.Node{} + contentKey.SetString(archiveEntryContent) + contentValue := &yaml.Node{} + contentValue.SetString(content) + contentValue.Style = yaml.LiteralStyle + + return &yaml.Node{ + Kind: yaml.MappingNode, + Tag: "!!map", + Content: []*yaml.Node{typeKey, typeValue, contentKey, contentValue}, + } +} + +// cloudConfigArchiveContent returns the content scalar of a cloud-config archive +// entry, or nil if the entry is not a cloud-config part (e.g. a shell script) or +// carries no string content. An entry with no explicit type is treated as +// cloud-config, matching cloud-init's default handling. +func cloudConfigArchiveContent(entry *yaml.Node) *yaml.Node { + if entry == nil || entry.Kind != yaml.MappingNode { + return nil + } + + if typeNode := mappingValue(entry, archiveEntryType); typeNode != nil && + typeNode.Value != "" && typeNode.Value != archiveContentType { + return nil + } + + contentNode := mappingValue(entry, archiveEntryContent) + if contentNode == nil || contentNode.Kind != yaml.ScalarNode { + return nil + } + + return contentNode +} + func mappingValue(mappingNode *yaml.Node, key string) *yaml.Node { for i := 0; i+1 < len(mappingNode.Content); i += 2 { keyNode := mappingNode.Content[i] diff --git a/rest-api/api/pkg/api/model/util/util_test.go b/rest-api/api/pkg/api/model/util/util_test.go index ba5296281a..881bd7632c 100644 --- a/rest-api/api/pkg/api/model/util/util_test.go +++ b/rest-api/api/pkg/api/model/util/util_test.go @@ -4,6 +4,7 @@ package util import ( + "strings" "testing" "github.com/santhosh-tekuri/jsonschema/v6" @@ -121,12 +122,12 @@ func TestRemovePhoneHomeFromUserData(t *testing.T) { }, { name: "removes matching phone-home blocks from both locations", - url: stringPointer(phoneHomeURL), + url: new(phoneHomeURL), wantRemoved: true, }, { name: "preserves non-matching phone-home blocks in both locations", - url: stringPointer("http://different"), + url: new("http://different"), }, } @@ -141,7 +142,8 @@ autoinstall: url: http://169.254.169.254/phone-home `) - require.NoError(t, RemovePhoneHomeFromUserData(documentRoot, tt.url)) + _, err := RemovePhoneHomeFromUserData(documentRoot, tt.url) + require.NoError(t, err) rootPhoneHome := mappingNodeValue(documentRoot, SitePhoneHomeName) autoinstallNode := mappingNodeValue(documentRoot, "autoinstall") @@ -158,19 +160,299 @@ autoinstall: } } -func unmarshalDocumentRoot(t *testing.T, userData string) *yaml.Node { +func TestInsertPhoneHomeIntoArchive(t *testing.T) { + const phoneHomeURL = "http://169.254.169.254/phone-home" + + const archive = `#cloud-config-archive +- type: text/cloud-config + content: | + #cloud-config + packages: + - curl +- type: text/x-shellscript + content: | + #!/bin/sh + echo hi +` + + t.Run("appends a phone-home cloud-config entry and preserves the archive", func(t *testing.T) { + documentRoot := unmarshalArchiveRoot(t, archive) + + require.NoError(t, InsertPhoneHomeIntoUserData(documentRoot, phoneHomeURL)) + + // The original two entries survive and a third is appended. + require.Len(t, documentRoot.Content, 3) + + rendered := marshalDocument(t, documentRoot) + assert.True(t, strings.HasPrefix(rendered, "#cloud-config-archive\n"), + "archive header must be preserved: %s", rendered) + assert.Contains(t, rendered, "echo hi", "existing entries must be preserved") + + // The appended entry must be a text/cloud-config part whose content is a + // valid #cloud-config carrying the phone-home block. + appended := documentRoot.Content[2] + assert.Equal(t, archiveContentType, mappingNodeValue(appended, archiveEntryType).Value) + + content := mappingNodeValue(appended, archiveEntryContent).Value + phoneHome := phoneHomeFromContent(t, content) + require.NotNil(t, phoneHome) + assert.Equal(t, phoneHomeURL, mappingNodeValue(phoneHome, SitePhoneHomeUrl).Value) + assert.Equal(t, SitePhoneHomePostAll, mappingNodeValue(phoneHome, SitePhoneHomePost).Value) + }) + + t.Run("replaces a standalone phone-home entry instead of duplicating it", func(t *testing.T) { + withPhoneHome := archive + `- type: text/cloud-config + content: | + #cloud-config + phone_home: + url: http://existing +` + documentRoot := unmarshalArchiveRoot(t, withPhoneHome) + + require.NoError(t, InsertPhoneHomeIntoUserData(documentRoot, phoneHomeURL)) + + rendered := marshalDocument(t, documentRoot) + assert.Contains(t, rendered, phoneHomeURL) + assert.NotContains(t, rendered, "http://existing", "the stale phone-home entry must be replaced") + assert.Equal(t, 1, strings.Count(rendered, "phone_home:"), "exactly one phone-home entry must remain") + }) + + t.Run("does not treat a header-less list as a cloud-config-archive", func(t *testing.T) { + // A YAML list with no #cloud-config-archive header is not valid cloud-init + // user-data, so phone-home must not be enabled on it. + headerless := `- type: text/cloud-config + content: | + #cloud-config + packages: + - curl +` + documentRoot := unmarshalArchiveRoot(t, headerless) + + assert.False(t, PhoneHomeSupportsUserDataRoot(documentRoot)) + assert.Error(t, InsertPhoneHomeIntoUserData(documentRoot, phoneHomeURL)) + }) +} + +func TestRemovePhoneHomeFromArchive(t *testing.T) { + const phoneHomeURL = "http://169.254.169.254/phone-home" + + archive := func() string { + return `#cloud-config-archive +- type: text/cloud-config + content: | + #cloud-config + phone_home: + url: ` + phoneHomeURL + ` +- type: text/x-shellscript + content: | + #!/bin/sh + echo hi +` + } + + tests := []struct { + name string + url *string + wantRemoved bool + }{ + {name: "removes any phone-home entry when url is nil", wantRemoved: true}, + {name: "removes the matching phone-home entry", url: new(phoneHomeURL), wantRemoved: true}, + {name: "keeps a non-matching phone-home entry", url: new("http://different")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + documentRoot := unmarshalArchiveRoot(t, archive()) + + _, err := RemovePhoneHomeFromUserData(documentRoot, tt.url) + require.NoError(t, err) + + rendered := marshalDocument(t, documentRoot) + assert.True(t, strings.HasPrefix(rendered, "#cloud-config-archive\n"), + "archive header must survive removal: %s", rendered) + assert.Contains(t, rendered, "echo hi", "unrelated entries must be kept") + + if tt.wantRemoved { + assert.NotContains(t, rendered, "phone_home") + } else { + assert.Contains(t, rendered, "phone_home") + } + }) + } +} + +func TestRemovePhoneHomeFromArchivePreservesHeaderWhenEmptied(t *testing.T) { + // An archive whose only entry is phone-home becomes empty on removal, but + // must keep its #cloud-config-archive header so it stays valid cloud-init. + documentRoot := unmarshalArchiveRoot(t, `#cloud-config-archive +- type: text/cloud-config + content: | + #cloud-config + phone_home: + url: http://169.254.169.254/phone-home +`) + + _, err := RemovePhoneHomeFromUserData(documentRoot, nil) + require.NoError(t, err) + + assert.Empty(t, documentRoot.Content, "the only entry must be removed") + + rendered := marshalDocument(t, documentRoot) + assert.True(t, strings.HasPrefix(rendered, "#cloud-config-archive\n"), + "header must be preserved on the emptied archive: %s", rendered) + assert.NotContains(t, rendered, "phone_home") +} + +func TestRemovePhoneHomeFromArchivePreservesUnsupportedEntry(t *testing.T) { + // A text/cloud-config entry whose content is really a script (its header + // conflicts) cannot carry phone-home. Disabling must skip it without error + // and leave it unchanged, while still removing the genuine phone-home entry. + const script = "#!/bin/bash\nexport FOO: bar\n" + + documentRoot := unmarshalArchiveRoot(t, `#cloud-config-archive +- type: text/cloud-config + content: | + #!/bin/bash + export FOO: bar +- type: text/cloud-config + content: | + #cloud-config + phone_home: + url: http://169.254.169.254/phone-home +`) + + _, err := RemovePhoneHomeFromUserData(documentRoot, nil) + require.NoError(t, err) + + require.Len(t, documentRoot.Content, 1, "only the phone-home entry must be removed") + assert.Equal(t, script, mappingNodeValue(documentRoot.Content[0], archiveEntryContent).Value, + "the script entry must be left unchanged") + assert.NotContains(t, marshalDocument(t, documentRoot), "phone_home") +} + +func TestRemovePhoneHomeFromArchivePreservesHeaderOnCommentedEntry(t *testing.T) { + // The header-carrying first entry is removed (it was phone-home only) and the + // new first entry already has its own comment; the archive header must still + // be restored so the document stays a valid #cloud-config-archive. + documentRoot := unmarshalArchiveRoot(t, `#cloud-config-archive +- type: text/cloud-config + content: | + #cloud-config + phone_home: + url: http://169.254.169.254/phone-home +# user note +- type: text/cloud-config + content: | + #cloud-config + packages: + - curl +`) + + _, err := RemovePhoneHomeFromUserData(documentRoot, nil) + require.NoError(t, err) + + require.Len(t, documentRoot.Content, 1) + rendered := marshalDocument(t, documentRoot) + assert.True(t, strings.HasPrefix(rendered, "#cloud-config-archive\n"), + "header must survive removal of the first entry: %s", rendered) + assert.Contains(t, rendered, "user note", "the entry's own comment must be kept") + assert.NotContains(t, rendered, "phone_home") +} + +func TestRemovePhoneHomeFromArchiveRemovesNestedAutoinstall(t *testing.T) { + // phone-home nested under autoinstall.user-data inside an archive entry must + // be detected and re-rendered out, even though the entry's top-level keys + // are unchanged (so a len(root.Content) comparison would miss it). + documentRoot := unmarshalArchiveRoot(t, `#cloud-config-archive +- type: text/cloud-config + content: | + #cloud-config + autoinstall: + version: 1 + user-data: + phone_home: + url: http://169.254.169.254/phone-home +`) + + _, err := RemovePhoneHomeFromUserData(documentRoot, nil) + require.NoError(t, err) + + require.Len(t, documentRoot.Content, 1, "the entry must be kept - autoinstall remains") + rendered := marshalDocument(t, documentRoot) + assert.NotContains(t, rendered, "phone_home", "nested phone-home must be removed") + assert.Contains(t, rendered, "autoinstall", "the rest of the entry must be preserved") +} + +func TestPhoneHomeSupportsUserDataRoot(t *testing.T) { + tests := []struct { + name string + userData string + want bool + }{ + {"#cloud-config mapping", "#cloud-config\npackages:\n- curl\n", true}, + {"header-less mapping is auto-corrected", "packages:\n- curl\n", true}, + {"empty mapping", "{}\n", true}, + {"#cloud-config-archive", "#cloud-config-archive\n- type: text/cloud-config\n content: x\n", true}, + {"empty #cloud-config-archive", "#cloud-config-archive\n[]\n", true}, + {"header-less list is not an archive", "- type: text/cloud-config\n content: x\n", false}, + {"#!/bin/bash script parsed as a scalar", "#!/bin/bash\necho hello\nls -la\n", false}, + {"#!/bin/bash script parsed as a mapping", "#!/bin/bash\nexport FOO: bar\n", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + document := &yaml.Node{} + require.NoError(t, yaml.Unmarshal([]byte(tt.userData), document)) + + var root *yaml.Node + if len(document.Content) > 0 { + root = document.Content[0] + } + + assert.Equal(t, tt.want, PhoneHomeSupportsUserDataRoot(root)) + }) + } +} + +func unmarshalArchiveRoot(t *testing.T, userData string) *yaml.Node { t.Helper() document := &yaml.Node{} require.NoError(t, yaml.Unmarshal([]byte(userData), document)) require.Len(t, document.Content, 1) - require.Equal(t, yaml.MappingNode, document.Content[0].Kind) + require.Equal(t, yaml.SequenceNode, document.Content[0].Kind) return document.Content[0] } -func stringPointer(value string) *string { - return &value +func marshalDocument(t *testing.T, documentRoot *yaml.Node) string { + t.Helper() + + out, err := yaml.Marshal(&yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{documentRoot}}) + require.NoError(t, err) + + return string(out) +} + +func phoneHomeFromContent(t *testing.T, content string) *yaml.Node { + t.Helper() + + inner := &yaml.Node{} + require.NoError(t, yaml.Unmarshal([]byte(content), inner)) + require.Len(t, inner.Content, 1) + + return mappingNodeValue(inner.Content[0], SitePhoneHomeName) +} + +func unmarshalDocumentRoot(t *testing.T, userData string) *yaml.Node { + t.Helper() + + document := &yaml.Node{} + require.NoError(t, yaml.Unmarshal([]byte(userData), document)) + require.Len(t, document.Content, 1) + require.Equal(t, yaml.MappingNode, document.Content[0].Kind) + + return document.Content[0] } func mappingNodeValue(mappingNode *yaml.Node, key string) *yaml.Node {