Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/ignition-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +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 for ovf-env.xml and CustomData.bin"]
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
Expand Down
1 change: 1 addition & 0 deletions docs/release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ nav_order: 9

### Bug fixes

- Read Azure custom data from the base64 `CustomData` in `ovf-env.xml`, so Confidential VMs pick up their Ignition config
- Resolve intermediate symlinks in relabel paths, fixing SELinux relabeling failures for users with `home_dir` on OSTree platforms after policycoreutils 3.11 ([#2316](https://github.com/coreos/ignition/pull/2316))

## Ignition 2.27.0 (2026-08-26)
Expand Down
65 changes: 53 additions & 12 deletions internal/providers/azure/azure.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,28 +18,36 @@ package azure

import (
"encoding/base64"
"encoding/xml"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"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"
"github.com/coreos/ignition/v2/internal/platform"
"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"
)

const (
configPath = "/CustomData.bin"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we dropping CustomData.bin for ovf-env.xml, rather than checking for both ?

Would there ever be cases where the old /CustomData.bin is populated and the correct place from which to read?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question!

To make a long answer short, there's really never a scenario where /CustomData.bin is populated and the ovf isn't.
Azure almost exclusively uses the OVF at this point, and all of the custom data that's exposed to the guest is already available in ovf-env.xml.

That's also backed up by the fact that /CustomData.bin was broken for CVMs for a bit and nobody noticed until it came up in the Fedora thread!

That said, I'm not strongly opposed to keeping /CustomData.bin as a fallback if it makes people more comfortable. I just haven't found an Azure scenario where it would buy us anything, since the same data should already be available in ovf-env.xml.

ovfEnvPath = "ovf-env.xml"
)

var (
errParseOvfEnv = errors.New("parsing ovf-env.xml")
errDecodeCustomData = errors.New("decoding CustomData")
)

// These constants come from <cdrom.h>.
Expand Down Expand Up @@ -117,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
}

Expand Down Expand Up @@ -147,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
Expand Down Expand Up @@ -224,20 +232,53 @@ 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)
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.
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("%w: %w", errParseOvfEnv, 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("%w: %w", errDecodeCustomData, 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)
Expand Down
116 changes: 116 additions & 0 deletions internal/providers/azure/azure_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// 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"
"errors"
"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(`<?xml version="1.0" encoding="utf-8"?>
<ns0:Environment xmlns:ns0="http://schemas.dmtf.org/ovf/environment/1"
xmlns:ns1="http://schemas.microsoft.com/windowsazure"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<ns1:ProvisioningSection>
<ns1:Version>1.0</ns1:Version>
<ns1:LinuxProvisioningConfigurationSet>
<ns1:ConfigurationSetType>LinuxProvisioningConfiguration</ns1:ConfigurationSetType>
<ns1:HostName>host</ns1:HostName>
<ns1:UserName>core</ns1:UserName>
%s
<ns1:DisableSshPasswordAuthentication>true</ns1:DisableSshPasswordAuthentication>
</ns1:LinuxProvisioningConfigurationSet>
</ns1:ProvisioningSection>
<ns1:PlatformSettingsSection>
<ns1:Version>1.0</ns1:Version>
<ns1:PlatformSettings>
<ns1:ProvisionGuestAgent>true</ns1:ProvisionGuestAgent>
</ns1:PlatformSettings>
</ns1:PlatformSettingsSection>
</ns0:Environment>`, 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 error
}{
{
name: "custom data present",
xml: ovfEnvWithCustomData(fmt.Sprintf("<ns1:CustomData>%s</ns1:CustomData>", encoded)),
out: config,
},
{
name: "custom data split across lines",
xml: ovfEnvWithCustomData(fmt.Sprintf("<ns1:CustomData>%s\n %s</ns1:CustomData>", encoded[:8], encoded[8:])),
out: config,
},
{
name: "no custom data element",
xml: ovfEnvWithCustomData(""),
nilOut: true,
},
{
name: "empty custom data element",
xml: ovfEnvWithCustomData("<ns1:CustomData></ns1:CustomData>"),
nilOut: true,
},
{
name: "malformed xml",
xml: "<ns0:Environment>not closed",
wantErr: errParseOvfEnv,
},
{
name: "invalid base64",
xml: ovfEnvWithCustomData("<ns1:CustomData>!!! not base64 !!!</ns1:CustomData>"),
wantErr: errDecodeCustomData,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := customDataFromOvfEnv([]byte(tt.xml))
if tt.wantErr != nil {
if !errors.Is(err, tt.wantErr) {
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
}
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)
}
})
}
}
Loading