Skip to content

Conversation

@peytonr18
Copy link

Summary

The PR is part of a larger effort to modernize Azure provisioning for Flatcar by moving away from the WALinuxAgent provisioning agent (PA) and leveraging Ignition as the execution engine for early-boot configuration.
The idea is to emit an Ignition config from Azure metadata and let Ignition apply it. This leverages Ignition’s existing capabilities for:

  • User creation
  • SSH key injection
  • Filesystem and disk setup
  • Systemd unit configuration

This RFC seeks feedback on the proposed approach.

Overview

The --generate-cloud-config flag enables Ignition to dynamically synthesize an Ignition configuration from cloud provider metadata at boot time, rather than requiring a pre-baked Ignition config in user data.

Architecture

Flag Integration

There are two potential approaches for enabling cloud config generation:

Option A: Explicit Flag

ignition --platform=azure --stage=fetch --generate-cloud-config=true

The flag would be passed via the systemd service or kernel command line, allowing operators to explicitly opt-in or opt-out of cloud config generation.

Option B: Auto-Enable by Platform (Current Implementation)

// Auto-enable cloud config generation for Azure during fetch stage
if flags.stage == "fetch" && flags.platform == "azure" {
    flags.generateCloudConfig = true
}

This approach automatically enables generation for Azure during the fetch stage without requiring explicit flag passing.

The current implementation uses Option B due to challenges passing the flag through the boot process. Feedback is welcome on which approach is preferred - explicit flag control offers more flexibility, while auto-enable simplifies configuration for supported platforms.

The flag flows through the execution engine:

  1. Flag handling (internal/main.go): Determines whether GenerateCloudConfig is enabled (via flag or auto-detection)
  2. Engine initialization: The GenerateCloudConfig bool is set on the exec.Engine struct
  3. Config fetching (internal/exec/engine.go): During fetchProviderConfig(), if GenerateCloudConfig is true, it calls fetchGeneratedConfig() instead of the normal fetch path
  4. Platform dispatch: The engine calls PlatformConfig.GenerateConfig() which invokes the platform-specific generator

Azure Provider Implementation

The Azure provider registers a GenerateCloudConfig function:

platform.Register(platform.Provider{
    Name:                "azure",
    NewFetcher:          newFetcher,
    Fetch:               fetchConfig,
    GenerateCloudConfig: generateCloudConfig,
})

Data Sources

The generator collects metadata from two sources:

  1. Azure Instance Metadata Service (IMDS) - HTTP endpoint at 169.254.169.254

    • Admin username (compute.osProfile.adminUsername)
    • SSH public keys (compute.publicKeys[].keyData)
  2. OVF Provisioning Data - XML from attached CD-ROM (ovf-env.xml)

    • Username (LinuxProvisioningConfigurationSet.UserName)
    • Password (LinuxProvisioningConfigurationSet.UserPassword)
    • SSH keys (LinuxProvisioningConfigurationSet.SSH.PublicKeys)
    • Password auth setting (DisableSshPasswordAuthentication)

Execution Flow

Boot
  │
  ▼
ignition-fetch.service
  │
  ├─► --generate-cloud-config=true (Azure)
  │
  ▼
Engine.fetchProviderConfig()
  │
  ├─► GenerateCloudConfig == true?
  │       │
  │       ▼
  │   fetchGeneratedConfig()
  │       │
  │       ▼
  │   PlatformConfig.GenerateConfig()
  │       │
  │       ▼
  │   azure.generateCloudConfig()
  │       │
  │       ├─► Fetch IMDS metadata
  │       ├─► Read OVF from CD-ROM
  │       ├─► Parse & merge data
  │       └─► Build types.Config{}
  │
  ▼
Engine applies config (users, files, etc.)

Systemd Integration

The dracut module's ignition-fetch.service is modified to pass the flag:

ExecStart=/usr/bin/ignition --root=/sysroot --platform=${PLATFORM_ID} --stage=fetch --generate-cloud-config=true ${IGNITION_ARGS}

Benefits

  1. No WALinuxAgent dependency - Eliminates the need for a separate provisioning agent
  2. Leverages existing Ignition capabilities - User creation, file writes, permissions all handled by proven code
  3. Single execution engine - All early-boot config goes through Ignition
  4. Extensible - Other platforms can implement GenerateCloudConfig to support similar functionality

Generated Config Example

{
  "ignition": { "version": "3.6.0" },
  "passwd": {
    "users": [{
      "name": "azureuser",
      "groups": ["wheel"],
      "homeDir": "/home/azureuser",
      "shell": "/bin/bash",
      "passwordHash": "$6$...",
      "sshAuthorizedKeys": ["ssh-rsa AAAA...", "ssh-ed25519 AAAA..."]
    }]
  },
  "storage": {
    "files": [
      {
        "path": "/etc/sudoers.d/azure-cloud-config",
        "mode": 288,
        "contents": { "source": "data:,%25wheel%20ALL%3D(ALL)%20NOPASSWD%3A%20ALL%0A" }
      },
      {
        "path": "/etc/ssh/sshd_config.d/50-azure-cloud-config.conf",
        "mode": 420,
        "contents": { "source": "data:,%23%20Custom%20SSHD%20settings%0APasswordAuthentication%20no%0APermitRootLogin%20no%0AAllowUsers%20azureuser%0A" }
      }
    ]
  }
}

Demo Output

The following logs demonstrate the cloud config generation in action on an Azure VM.

Boot Logs (journalctl)
[    7.361251] localhost ignition[894]: Platform: azure
[    7.361490] localhost ignition[894]: no config dir at "/usr/lib/ignition/base.platform.d/azure"
[    7.361558] localhost ignition[894]: using generated cloud config for platform "azure"
[    7.361562] localhost ignition[894]: azure: [1/4] generating cloud config via IMDS + OVF metadata
[    7.361565] localhost ignition[894]: azure: [2/4] requesting instance metadata from IMDS
[    7.432541] localhost ignition[894]: azure: fetched instance metadata from IMDS: &{Compute:{Hostname: OSProfile:{AdminUsername:azureuser} PublicKeys:[{KeyData:ssh-rsa AAAA...}]}}
[    7.432544] localhost ignition[894]: azure: [3/4] reading OVF provisioning metadata from attached media
[    7.528936] localhost ignition[894]: op(1): [started]  mounting "/dev/sr0" at "/tmp/ignition-azure2136072767"
[    7.572243] localhost ignition[894]: op(1): [finished] mounting "/dev/sr0" at "/tmp/ignition-azure2136072767"
[    7.578152] localhost ignition[894]: op(2): [started]  unmounting "/dev/sr0" at "/tmp/ignition-azure2136072767"
[    7.581937] localhost ignition[894]: op(2): [finished] unmounting "/dev/sr0" at "/tmp/ignition-azure2136072767"
[    7.581950] localhost ignition[894]: azure: read provisioning metadata from OVF (bytes=2317)
[    7.581963] localhost ignition[894]: azure: [4/4] parsing provisioning metadata and synthesizing Ignition config
[    7.582076] localhost ignition[894]: azure: successfully parsed provisioning metadata from ovfRaw
[    7.582079] localhost ignition[894]: azure: data summary before config generation:
[    7.582082] localhost ignition[894]: azure:   IMDS metadata available: true
[    7.582086] localhost ignition[894]: azure:   OVF provisioning available: true
[    7.582090] localhost ignition[894]: azure:   IMDS username: "azureuser"
[    7.582092] localhost ignition[894]: azure:   IMDS SSH keys count: 1
[    7.582094] localhost ignition[894]: azure:   OVF username: ""
[    7.582096] localhost ignition[894]: azure:   OVF has password: false
[    7.582098] localhost ignition[894]: azure:   OVF SSH keys count: 0
[    7.582121] localhost ignition[894]: azure: generated cloud config successfully
[    7.582124] localhost ignition[894]: azure: config includes user "azureuser" with 1 SSH keys
[    7.582739] localhost ignition[894]: fetched user config from "azure-generator"
[    9.062611] localhost ignition[1071]: INFO     : files: ensureUsers: op(1): [started]  creating or modifying user "azureuser"
[    9.062611] localhost ignition[1071]: DEBUG    : files: ensureUsers: op(1): executing: "useradd" "--root" "/sysroot" "--home-dir" "/home/azureuser" "--create-home" "--password" "*" "--groups" "wheel" "--shell" "/bin/bash" "azureuser"
[    9.461579] localhost ignition[1071]: INFO     : files: ensureUsers: op(1): [finished] creating or modifying user "azureuser"
[    9.471981] localhost ignition[1071]: INFO     : files: ensureUsers: op(2): [started]  adding ssh keys to user "azureuser"
[    9.471981] localhost ignition[1071]: INFO     : files: ensureUsers: op(2): [finished] adding ssh keys to user "azureuser"
[    9.463064] localhost ignition[1071]: wrote ssh authorized keys file for user: azureuser
Resulting Files on Disk

Sudoers config (/etc/sudoers.d/azure-cloud-config):

%wheel ALL=(ALL) NOPASSWD: ALL

SSHD config (/etc/ssh/sshd_config.d/50-azure-cloud-config.conf):

# Custom SSHD settings
PasswordAuthentication no
PermitRootLogin no
AllowUsers azureuser

Via the commands:

  go get github.com/GehirnInc/crypt
  go mod vendor
Add the ability to synthesize Ignition configurations dynamically from
Azure Instance Metadata Service (IMDS) and OVF provisioning data.
@peytonr18 peytonr18 marked this pull request as ready for review January 7, 2026 17:58
Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a significant and valuable feature for Azure provisioning by allowing Ignition to generate its configuration directly from cloud metadata. The overall approach is well-structured, and the inclusion of comprehensive tests is excellent. I have a few suggestions to enhance robustness, improve maintainability, and address a critical issue in the password hashing implementation. My feedback also touches on a key design decision presented in the RFC regarding flag handling.

Comment on lines +27 to +35
func HashPassword(password string) (string, error) {
salt, err := generateSalt(16)
if err != nil {
return "", err
}

crypt := sha512_crypt.New()
return crypt.Generate([]byte(password), []byte("$6$"+salt))
}

Choose a reason for hiding this comment

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

critical

The HashPassword function unnecessarily calls a custom generateSalt helper. The sha512_crypt library can correctly generate a random salt if you pass nil as the salt argument to its Generate method. This avoids potential bugs from a custom implementation and simplifies the code.

func HashPassword(password string) (string, error) {
	crypt := sha512_crypt.New()
	return crypt.Generate([]byte(password), nil)
}

Comment on lines +69 to +84
// generateSalt generates a random salt of the specified length.
func generateSalt(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
// Use base64 encoding and trim to desired length
salt := base64.StdEncoding.EncodeToString(bytes)
// Remove any characters that might cause issues in crypt salt
salt = strings.ReplaceAll(salt, "+", ".")
salt = strings.ReplaceAll(salt, "=", "")
if len(salt) > length {
salt = salt[:length]
}
return salt, nil
}

Choose a reason for hiding this comment

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

critical

This generateSalt function should be removed. It re-implements functionality already present in the sha512_crypt library and is subtly buggy as it doesn't produce a salt fully conforming to the crypt(3) alphabet. The sha512_crypt library's Generate method handles random salt generation correctly when passed a nil salt, making this helper unnecessary.

Comment on lines +76 to +79
// Never allow cloud config generation during fetch-offline stage (no networking)
if flags.stage == "fetch" && flags.platform == "azure" {
flags.generateCloudConfig = true
}

Choose a reason for hiding this comment

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

high

The comment on line 76 is misleading. It states "Never allow cloud config generation during fetch-offline stage", but the code block enables generateCloudConfig for the fetch stage on Azure. This can cause confusion for future developers. The comment should be updated to accurately reflect that this block auto-enables the feature for the Azure platform.

Suggested change
// Never allow cloud config generation during fetch-offline stage (no networking)
if flags.stage == "fetch" && flags.platform == "azure" {
flags.generateCloudConfig = true
}
// Auto-enable cloud config generation for Azure during the fetch stage.
// This is part of the "Option B" approach described in the RFC.
if flags.stage == "fetch" && flags.platform == "azure" {
flags.generateCloudConfig = true
}

RemainAfterExit=yes
EnvironmentFile=/run/ignition.env
ExecStart=/usr/bin/ignition --root=/sysroot --platform=${PLATFORM_ID} --stage=fetch ${IGNITION_ARGS}
ExecStart=/usr/bin/ignition --root=/sysroot --platform=${PLATFORM_ID} --stage=fetch --generate-cloud-config=true ${IGNITION_ARGS}

Choose a reason for hiding this comment

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

medium

This change hardcodes --generate-cloud-config=true for the fetch stage. As noted in the RFC description, this is "Option B" which auto-enables the feature. While this simplifies the default case, it removes the flexibility for an Azure user to provide a standard Ignition config via user data if they wished. Have you considered making this configurable via a kernel argument, allowing users to opt-out of this new behavior if needed? This would align more with "Option A" and provide greater control.

Comment on lines +339 to +359
func (e *Engine) fetchGeneratedConfig() (types.Config, error) {
e.Logger.Info("using generated cloud config for platform %q", e.PlatformConfig.Name())
cfg, err := e.PlatformConfig.GenerateConfig(e.Fetcher)
if err != nil {
return types.Config{}, err
}

e.State.FetchedConfigs = append(e.State.FetchedConfigs, state.FetchedConfig{
Kind: "user",
Source: fmt.Sprintf("%s-generator", e.PlatformConfig.Name()),
Referenced: false,
})

configFetcher := ConfigFetcher{
Logger: e.Logger,
Fetcher: e.Fetcher,
State: e.State,
}

return configFetcher.RenderConfig(cfg)
}

Choose a reason for hiding this comment

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

medium

This new fetchGeneratedConfig function is missing a call to e.Fetcher.UpdateHttpTimeoutsAndCAs before configFetcher.RenderConfig is called. The original fetchProviderConfig flow includes this step to ensure that any timeouts or CAs specified in the just-fetched config are used for subsequent resource fetches (e.g., remote files referenced in the config). Without this, Ignition will use the default timeouts, which might not be what the user intended.

Additionally, there is significant code duplication between fetchGeneratedConfig and the end of fetchProviderConfig. Consider refactoring to have a single ConfigFetcher creation and RenderConfig call to improve maintainability.

func (e *Engine) fetchGeneratedConfig() (types.Config, error) {
	e.Logger.Info("using generated cloud config for platform %q", e.PlatformConfig.Name())
	cfg, err := e.PlatformConfig.GenerateConfig(e.Fetcher)
	if err != nil {
		return types.Config{}, err
	}

	e.State.FetchedConfigs = append(e.State.FetchedConfigs, state.FetchedConfig{
		Kind:       "user",
		Source:     fmt.Sprintf("%s-generator", e.PlatformConfig.Name()),
		Referenced: false,
	})

	// Update the http client to use the timeouts and CAs from the newly fetched
	// config, before rendering and fetching remote resources.
	if err := e.Fetcher.UpdateHttpTimeoutsAndCAs(cfg.Ignition.Timeouts, cfg.Ignition.Security.TLS.CertificateAuthorities, cfg.Ignition.Proxy); err != nil {
		return types.Config{}, err
	}

	configFetcher := ConfigFetcher{
		Logger:  e.Logger,
		Fetcher: e.Fetcher,
		State:   e.State,
	}

	return configFetcher.RenderConfig(cfg)
}

Comment on lines +438 to +445
switch {
case metaErr != nil:
return types.Config{}, metaErr
case provisioningErr != nil:
return types.Config{}, provisioningErr
default:
return types.Config{}, fmt.Errorf("azure: no instance metadata or provisioning data available")
}

Choose a reason for hiding this comment

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

medium

In the case where both IMDS and OVF metadata fetching fail, this logic correctly returns an error. However, it only returns the metaErr, even if provisioningErr also occurred. To provide better diagnostics to the user, it would be more helpful to return both errors. You can use errors.Join (available in Go 1.20+) to combine them.

		var allErrors []error
		if metaErr != nil {
			allErrors = append(allErrors, metaErr)
		}
		if provisioningErr != nil {
			allErrors = append(allErrors, provisioningErr)
		}
		if len(allErrors) > 0 {
			return types.Config{}, errors.Join(allErrors...)
		}
		return types.Config{}, fmt.Errorf("azure: no instance metadata or provisioning data available")

user.PasswordHash = cfgutil.StrToPtr(passwordHash)
}

sudoersFile := newDataFile("/etc/sudoers.d/50-azure-cloud-config", 0440, "%wheel ALL=(ALL) NOPASSWD: ALL\n")

Choose a reason for hiding this comment

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

medium

There's an inconsistency in the naming of the generated sudoers file. The code creates /etc/sudoers.d/50-azure-cloud-config, but the PR description and demo output refer to it as /etc/sudoers.d/azure-cloud-config. To avoid confusion for users and maintainers, it would be best to make the code and documentation consistent. The 50- prefix is good practice for ordering, so I'd recommend updating the documentation to match the code.

@travier
Copy link
Member

travier commented Jan 14, 2026

Let's start with a discussion about the design and the goals first before we look at code. Can you open an issue with your goals and what this would achieve (i.e. most of this PR text, but in an issue)? Thanks

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants