From 514165194fb80fee60f81bb2bbde6f4e3fcdd9ce Mon Sep 17 00:00:00 2001 From: peytonr18 Date: Mon, 24 Aug 2026 16:48:23 -0600 Subject: [PATCH 1/3] azure: read custom data from ovf-env.xml --- docs/ignition-flow.md | 3 +- docs/release-notes.md | 2 + internal/providers/azure/azure.go | 48 +++++++++++ internal/providers/azure/azure_test.go | 115 +++++++++++++++++++++++++ 4 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 internal/providers/azure/azure_test.go diff --git a/docs/ignition-flow.md b/docs/ignition-flow.md index 29138e4cc..1e54b6d45 100644 --- a/docs/ignition-flow.md +++ b/docs/ignition-flow.md @@ -157,7 +157,8 @@ flowchart TB fallback_ovf["Fallback: read OVF custom data from CD-ROM device"] fallback_ovf --> scan["Scan for UDF CD-ROM (often /dev/sr0)"] scan --> mount["Mount device"] - mount --> read["Read for ovf-env.xml and CustomData.bin"] + mount --> read["Read CustomData.bin; + if empty, decode CustomData from ovf-env.xml"] read --> available{"Config available?"} available -->|Yes| write_device["Write config to /run/ignition.json"] write_device --> done diff --git a/docs/release-notes.md b/docs/release-notes.md index b9020a1e8..3830058cd 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -19,6 +19,8 @@ nav_order: 9 ### Bug fixes +- Read Azure custom data from `CustomData.bin`, falling back to the base64 `CustomData` in `ovf-env.xml`, so Confidential VMs pick up their Ignition config + ## Upcoming Ignition 2.27.0 (unreleased) diff --git a/internal/providers/azure/azure.go b/internal/providers/azure/azure.go index 610db4e06..af399bcf1 100644 --- a/internal/providers/azure/azure.go +++ b/internal/providers/azure/azure.go @@ -18,11 +18,13 @@ package azure import ( "encoding/base64" + "encoding/xml" "fmt" "net/http" "net/url" "os" "path/filepath" + "strings" "time" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" @@ -235,9 +237,55 @@ func getRawConfig(f *resource.Fetcher, devicePath string, fstype string) ([]byte if err != nil && !os.IsNotExist(err) { return nil, fmt.Errorf("failed to read config from device %q: %v", devicePath, err) } + + // Confidential VMs don't populate CustomData.bin; fall back to the base64 + // CustomData embedded in ovf-env.xml. + if len(rawConfig) == 0 { + ovfEnvContents, err := os.ReadFile(filepath.Join(mnt, "ovf-env.xml")) + if err != nil { + return nil, fmt.Errorf("failed to read ovf-env.xml from device %q: %v", devicePath, err) + } + rawConfig, err = customDataFromOvfEnv(ovfEnvContents) + if err != nil { + return nil, fmt.Errorf("failed to read custom data from ovf-env.xml on device %q: %v", devicePath, err) + } + } return rawConfig, nil } +// ovfEnv is the subset of Azure's ovf-env.xml that carries the custom data. +// Elements are matched by local name to ignore the OVF/windowsazure namespaces. +type ovfEnv struct { + XMLName xml.Name `xml:"Environment"` + ProvisioningSection struct { + LinuxProvisioningConfigurationSet struct { + // CustomData is base64-encoded, unlike the raw CustomData.bin file. + CustomData string `xml:"CustomData"` + } `xml:"LinuxProvisioningConfigurationSet"` + } `xml:"ProvisioningSection"` +} + +// customDataFromOvfEnv extracts and base64-decodes the CustomData element from +// Azure's ovf-env.xml, returning nil when no custom data is present. +func customDataFromOvfEnv(ovfEnvContents []byte) ([]byte, error) { + var env ovfEnv + if err := xml.Unmarshal(ovfEnvContents, &env); err != nil { + return nil, fmt.Errorf("parsing ovf-env.xml: %w", err) + } + + // Azure may split the base64 payload across lines. + encoded := strings.Join(strings.Fields(env.ProvisioningSection.LinuxProvisioningConfigurationSet.CustomData), "") + if encoded == "" { + return nil, nil + } + + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return nil, fmt.Errorf("decoding CustomData: %w", err) + } + return decoded, nil +} + // isCdromPresent verifies if the given config drive is CD-ROM func isCdromPresent(logger *log.Logger, devicePath string) bool { logger.Debug("opening config device: %q", devicePath) diff --git a/internal/providers/azure/azure_test.go b/internal/providers/azure/azure_test.go new file mode 100644 index 000000000..c5a74fe19 --- /dev/null +++ b/internal/providers/azure/azure_test.go @@ -0,0 +1,115 @@ +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package azure + +import ( + "encoding/base64" + "fmt" + "testing" +) + +// ovfEnvWithCustomData returns an Azure ovf-env.xml with the given CustomData +// element (which may be empty) spliced into the provisioning section. +func ovfEnvWithCustomData(customDataElement string) string { + return fmt.Sprintf(` + + + 1.0 + + LinuxProvisioningConfiguration + host + core + %s + true + + + + 1.0 + + true + + +`, customDataElement) +} + +func TestCustomDataFromOvfEnv(t *testing.T) { + config := `{"ignition":{"version":"3.4.0"}}` + encoded := base64.StdEncoding.EncodeToString([]byte(config)) + + tests := []struct { + name string + xml string + out string + nilOut bool + wantErr bool + }{ + { + name: "custom data present", + xml: ovfEnvWithCustomData(fmt.Sprintf("%s", encoded)), + out: config, + }, + { + name: "custom data split across lines", + xml: ovfEnvWithCustomData(fmt.Sprintf("%s\n %s", encoded[:8], encoded[8:])), + out: config, + }, + { + name: "no custom data element", + xml: ovfEnvWithCustomData(""), + nilOut: true, + }, + { + name: "empty custom data element", + xml: ovfEnvWithCustomData(""), + nilOut: true, + }, + { + name: "malformed xml", + xml: "not closed", + wantErr: true, + }, + { + name: "invalid base64", + xml: ovfEnvWithCustomData("!!! not base64 !!!"), + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := customDataFromOvfEnv([]byte(tt.xml)) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if tt.nilOut { + if got != nil { + t.Fatalf("expected nil, got %q", got) + } + return + } + if string(got) != tt.out { + t.Fatalf("expected %q, got %q", tt.out, got) + } + }) + } +} From 418823511ce430b07a46f2a213b985159f7bbf4d Mon Sep 17 00:00:00 2001 From: peytonr18 Date: Thu, 27 Aug 2026 12:07:37 -0600 Subject: [PATCH 2/3] azure: drop CustomData.bin in favor of ovf-env.xml --- docs/ignition-flow.md | 3 +- docs/release-notes.md | 2 +- internal/providers/azure/azure.go | 47 +++++++++++--------------- internal/providers/azure/azure_test.go | 13 +++---- 4 files changed, 29 insertions(+), 36 deletions(-) diff --git a/docs/ignition-flow.md b/docs/ignition-flow.md index 1e54b6d45..dceea5ea0 100644 --- a/docs/ignition-flow.md +++ b/docs/ignition-flow.md @@ -157,8 +157,7 @@ flowchart TB fallback_ovf["Fallback: read OVF custom data from CD-ROM device"] fallback_ovf --> scan["Scan for UDF CD-ROM (often /dev/sr0)"] scan --> mount["Mount device"] - mount --> read["Read CustomData.bin; - if empty, decode CustomData from ovf-env.xml"] + mount --> read["Decode CustomData from ovf-env.xml"] read --> available{"Config available?"} available -->|Yes| write_device["Write config to /run/ignition.json"] write_device --> done diff --git a/docs/release-notes.md b/docs/release-notes.md index 3830058cd..0bc785661 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -19,7 +19,7 @@ nav_order: 9 ### Bug fixes -- Read Azure custom data from `CustomData.bin`, falling back to the base64 `CustomData` in `ovf-env.xml`, so Confidential VMs pick up their Ignition config +- Read Azure custom data from the base64 `CustomData` in `ovf-env.xml`, so Confidential VMs pick up their Ignition config ## Upcoming Ignition 2.27.0 (unreleased) diff --git a/internal/providers/azure/azure.go b/internal/providers/azure/azure.go index af399bcf1..59fea57ea 100644 --- a/internal/providers/azure/azure.go +++ b/internal/providers/azure/azure.go @@ -19,6 +19,7 @@ package azure import ( "encoding/base64" "encoding/xml" + "errors" "fmt" "net/http" "net/url" @@ -28,7 +29,7 @@ import ( "time" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" - "github.com/coreos/ignition/v2/config/shared/errors" + ignerrors "github.com/coreos/ignition/v2/config/shared/errors" "github.com/coreos/ignition/v2/config/v3_7_experimental/types" execUtil "github.com/coreos/ignition/v2/internal/exec/util" "github.com/coreos/ignition/v2/internal/log" @@ -41,7 +42,12 @@ import ( ) const ( - configPath = "/CustomData.bin" + ovfEnvPath = "ovf-env.xml" +) + +var ( + errParseOvfEnv = errors.New("parsing ovf-env.xml") + errDecodeCustomData = errors.New("decoding CustomData") ) // These constants come from . @@ -119,7 +125,7 @@ func fetchFromAzureMetadata(f *resource.Fetcher) (types.Config, report.Report, e return util.ParseConfig(logger, userData) } - if err != errors.ErrEmpty { + if err != ignerrors.ErrEmpty { return types.Config{}, report.Report{}, err } @@ -149,7 +155,7 @@ func fetchFromIMDS(f *resource.Fetcher) ([]byte, error) { n := len(data) if n == 0 { - return nil, errors.ErrEmpty + return nil, ignerrors.ErrEmpty } // data is base64 encoded by the IMDS @@ -226,29 +232,16 @@ func getRawConfig(f *resource.Fetcher, devicePath string, fstype string) ([]byte ) }() - // detect the config drive by looking for a file which is always present - logger.Debug("checking for config drive") - if _, err := os.Stat(filepath.Join(mnt, "ovf-env.xml")); err != nil { + // The presence of ovf-env.xml identifies this as the Azure config drive. + logger.Debug("reading ovf-env.xml") + ovfEnvContents, err := os.ReadFile(filepath.Join(mnt, ovfEnvPath)) + if err != nil { return nil, fmt.Errorf("device %q does not appear to be a config drive: %v", devicePath, err) } - logger.Debug("reading config") - rawConfig, err := os.ReadFile(filepath.Join(mnt, configPath)) - if err != nil && !os.IsNotExist(err) { - return nil, fmt.Errorf("failed to read config from device %q: %v", devicePath, err) - } - - // Confidential VMs don't populate CustomData.bin; fall back to the base64 - // CustomData embedded in ovf-env.xml. - if len(rawConfig) == 0 { - ovfEnvContents, err := os.ReadFile(filepath.Join(mnt, "ovf-env.xml")) - if err != nil { - return nil, fmt.Errorf("failed to read ovf-env.xml from device %q: %v", devicePath, err) - } - rawConfig, err = customDataFromOvfEnv(ovfEnvContents) - if err != nil { - return nil, fmt.Errorf("failed to read custom data from ovf-env.xml on device %q: %v", devicePath, err) - } + rawConfig, err := customDataFromOvfEnv(ovfEnvContents) + if err != nil { + return nil, fmt.Errorf("failed to read custom data from ovf-env.xml on device %q: %v", devicePath, err) } return rawConfig, nil } @@ -259,7 +252,7 @@ type ovfEnv struct { XMLName xml.Name `xml:"Environment"` ProvisioningSection struct { LinuxProvisioningConfigurationSet struct { - // CustomData is base64-encoded, unlike the raw CustomData.bin file. + // CustomData is base64-encoded. CustomData string `xml:"CustomData"` } `xml:"LinuxProvisioningConfigurationSet"` } `xml:"ProvisioningSection"` @@ -270,7 +263,7 @@ type ovfEnv struct { func customDataFromOvfEnv(ovfEnvContents []byte) ([]byte, error) { var env ovfEnv if err := xml.Unmarshal(ovfEnvContents, &env); err != nil { - return nil, fmt.Errorf("parsing ovf-env.xml: %w", err) + return nil, fmt.Errorf("%w: %w", errParseOvfEnv, err) } // Azure may split the base64 payload across lines. @@ -281,7 +274,7 @@ func customDataFromOvfEnv(ovfEnvContents []byte) ([]byte, error) { decoded, err := base64.StdEncoding.DecodeString(encoded) if err != nil { - return nil, fmt.Errorf("decoding CustomData: %w", err) + return nil, fmt.Errorf("%w: %w", errDecodeCustomData, err) } return decoded, nil } diff --git a/internal/providers/azure/azure_test.go b/internal/providers/azure/azure_test.go index c5a74fe19..7f6150246 100644 --- a/internal/providers/azure/azure_test.go +++ b/internal/providers/azure/azure_test.go @@ -16,6 +16,7 @@ package azure import ( "encoding/base64" + "errors" "fmt" "testing" ) @@ -55,7 +56,7 @@ func TestCustomDataFromOvfEnv(t *testing.T) { xml string out string nilOut bool - wantErr bool + wantErr error }{ { name: "custom data present", @@ -80,21 +81,21 @@ func TestCustomDataFromOvfEnv(t *testing.T) { { name: "malformed xml", xml: "not closed", - wantErr: true, + wantErr: errParseOvfEnv, }, { name: "invalid base64", xml: ovfEnvWithCustomData("!!! not base64 !!!"), - wantErr: true, + wantErr: errDecodeCustomData, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := customDataFromOvfEnv([]byte(tt.xml)) - if tt.wantErr { - if err == nil { - t.Fatal("expected error, got nil") + if tt.wantErr != nil { + if !errors.Is(err, tt.wantErr) { + t.Fatalf("expected error %v, got %v", tt.wantErr, err) } return } From 39e8249a6169c0557c5f6ef68305ec85796ca5e8 Mon Sep 17 00:00:00 2001 From: peytonr18 Date: Fri, 4 Sep 2026 11:27:02 -0700 Subject: [PATCH 3/3] providers/azure: separate import groups --- internal/providers/azure/azure.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/providers/azure/azure.go b/internal/providers/azure/azure.go index 59fea57ea..5ace67c27 100644 --- a/internal/providers/azure/azure.go +++ b/internal/providers/azure/azure.go @@ -28,7 +28,6 @@ import ( "strings" "time" - "github.com/Azure/azure-sdk-for-go/sdk/azidentity" ignerrors "github.com/coreos/ignition/v2/config/shared/errors" "github.com/coreos/ignition/v2/config/v3_7_experimental/types" execUtil "github.com/coreos/ignition/v2/internal/exec/util" @@ -37,6 +36,7 @@ import ( "github.com/coreos/ignition/v2/internal/providers/util" "github.com/coreos/ignition/v2/internal/resource" + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/coreos/vcontext/report" "golang.org/x/sys/unix" )