Skip to content
Merged
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
88 changes: 88 additions & 0 deletions .web-docs/components/builder/chroot/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,32 @@ subscription ID from the image resource ID rather than the host VM's
subscription. Ensure the identity used by the builder has the appropriate
read permissions on the source gallery.

### LVM Support

The `azure-chroot` builder has built-in support for source disks that use LVM
(Logical Volume Manager). After the source disk is attached, the builder
automatically scans for LVM physical volumes, activates any volume groups found
on the disk, and locates the root logical volume to use as the mount target.

In most cases **no configuration is required** — LVM detection and activation
happen transparently. The builder uses a multi-stage heuristic to find the root
logical volume:

1. Examines LV attributes (skipping snapshots, thin pools, and virtual LVs).
2. Excludes swap volumes.
3. Prefers LVs whose names contain `root` (exact match first, then partial).
4. Falls back to the first remaining candidate.

If auto-detection picks the wrong logical volume, you can set `lvm_root_device`
to the exact device path (e.g., `/dev/mapper/rhel-root`).

During cleanup the builder deactivates the volume groups before detaching the
disk, ensuring no device-mapper references remain on the host.

~> **Note:** LVM support requires the `lvm2` package (providing `pvscan`,
`vgscan`, `vgchange`, `lvs`) to be installed on the host VM. The `partprobe`
and `udevadm` utilities are also used for device discovery.
Comment thread
MrCaedes marked this conversation as resolved.

## Configuration Reference

There are many configuration options available for the builder. We'll start
Expand Down Expand Up @@ -193,6 +219,18 @@ information.

- `temporary_data_disk_snapshot_id` (string) - The prefix for the resource ids of the temporary data disk snapshots that will be created. The snapshots will be suffixed with a number. Will be generated if not set.

- `lvm_root_device` (string) - Explicitly specify the LVM root device path to mount (e.g., `/dev/mapper/rhel-root`).
When set, LVM volume groups are activated and this device is used as the mount target
instead of a partition on the raw disk. Normally, LVM is auto-detected and does not
require any configuration. Use this only when auto-detection picks the wrong logical volume.

- `pre_unmount_commands` ([]string) - A series of commands to execute on the **host** after provisioning but before unmounting
the chroot and deactivating LVM. Useful for host-side operations on the still-mounted
filesystem such as `fstrim` or `sync`. These commands do **not** run inside the chroot;
to run a command inside the chroot, use a shell provisioner or prefix with
`chroot {{.MountPath}}`. The device and mount path are provided by `{{.Device}}` and
`{{.MountPath}}`.

- `skip_cleanup` (bool) - If set to `true`, leaves the temporary disks and snapshots behind in the Packer VM resource group. Defaults to `false`

- `image_resource_id` (string) - The managed image to create using this build.
Expand Down Expand Up @@ -504,3 +542,53 @@ build {
]
}
```


### Using an LVM-based Source Image

When the source disk uses LVM, the builder automatically detects and activates
the volume groups. No additional configuration is needed in most cases:

**HCL2**

```hcl
source "azure-chroot" "lvm-example" {
image_resource_id = "/subscriptions/{{vm `subscription_id`}}/resourceGroups/{{vm `resource_group`}}/providers/Microsoft.Compute/images/MyRHELImage-{{timestamp}}"
source = "/subscriptions/.../resourceGroups/.../providers/Microsoft.Compute/disks/rhel-osdisk"
}

build {
sources = ["source.azure-chroot.lvm-example"]

provisioner "shell" {
inline = ["yum update -y"]
inline_shebang = "/bin/sh -x"
}
}
```

If auto-detection picks the wrong logical volume, set `lvm_root_device` to the
exact path:

```hcl
source "azure-chroot" "lvm-explicit" {
image_resource_id = "/subscriptions/{{vm `subscription_id`}}/resourceGroups/{{vm `resource_group`}}/providers/Microsoft.Compute/images/MyRHELImage-{{timestamp}}"
source = "/subscriptions/.../resourceGroups/.../providers/Microsoft.Compute/disks/rhel-osdisk"
lvm_root_device = "/dev/mapper/rhel-root"
}
```

The `pre_unmount_commands` option lets you run commands after provisioning
but before the filesystem is unmounted and LVM is deactivated:

```hcl
source "azure-chroot" "lvm-pre-unmount" {
image_resource_id = "/subscriptions/{{vm `subscription_id`}}/resourceGroups/{{vm `resource_group`}}/providers/Microsoft.Compute/images/MyRHELImage-{{timestamp}}"
source = "/subscriptions/.../resourceGroups/.../providers/Microsoft.Compute/disks/rhel-osdisk"

pre_unmount_commands = [
"sync",
"fstrim {{.MountPath}}"
]
}
```
69 changes: 68 additions & 1 deletion builder/azure/chroot/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ import (
"context"
"errors"
"fmt"
posixpath "path"
"runtime"
"slices"
"strings"
"unicode"

"github.com/hashicorp/packer-plugin-azure/builder/azure/common/log"

Expand Down Expand Up @@ -125,6 +127,20 @@ type Config struct {
// The prefix for the resource ids of the temporary data disk snapshots that will be created. The snapshots will be suffixed with a number. Will be generated if not set.
TemporaryDataDiskSnapshotIDPrefix string `mapstructure:"temporary_data_disk_snapshot_id"`

// Explicitly specify the LVM root device path to mount (e.g., `/dev/mapper/rhel-root`).
// When set, LVM volume groups are activated and this device is used as the mount target
// instead of a partition on the raw disk. Normally, LVM is auto-detected and does not
// require any configuration. Use this only when auto-detection picks the wrong logical volume.
LVMRootDevice string `mapstructure:"lvm_root_device"`

// A series of commands to execute on the **host** after provisioning but before unmounting
// the chroot and deactivating LVM. Useful for host-side operations on the still-mounted
// filesystem such as `fstrim` or `sync`. These commands do **not** run inside the chroot;
// to run a command inside the chroot, use a shell provisioner or prefix with
// `chroot {{.MountPath}}`. The device and mount path are provided by `{{.Device}}` and
// `{{.MountPath}}`.
PreUnmountCommands []string `mapstructure:"pre_unmount_commands"`

// If set to `true`, leaves the temporary disks and snapshots behind in the Packer VM resource group. Defaults to `false`
SkipCleanup bool `mapstructure:"skip_cleanup"`

Expand Down Expand Up @@ -174,6 +190,7 @@ func (b *Builder) Prepare(raws ...interface{}) ([]string, []string, error) {
"command_wrapper",
"post_mount_commands",
"pre_mount_commands",
"pre_unmount_commands",
"manual_mount_command",
"mount_path",
},
Expand Down Expand Up @@ -291,6 +308,10 @@ func (b *Builder) Prepare(raws ...interface{}) ([]string, []string, error) {
// checks, accumulate any errors or warnings

if b.config.FromScratch {
if b.config.LVMRootDevice != "" {
errs = packersdk.MultiErrorAppend(
errs, errors.New("lvm_root_device cannot be specified when building from_scratch"))
}
if b.config.Source != "" {
errs = packersdk.MultiErrorAppend(
errs, errors.New("source cannot be specified when building from_scratch"))
Expand Down Expand Up @@ -367,6 +388,12 @@ func (b *Builder) Prepare(raws ...interface{}) ([]string, []string, error) {
errs = packersdk.MultiErrorAppend(errs, fmt.Errorf("image_hyperv_generation: %v", err))
}

if b.config.LVMRootDevice != "" {
if err := validateLVMRootDevice(b.config.LVMRootDevice); err != nil {
errs = packersdk.MultiErrorAppend(errs, fmt.Errorf("lvm_root_device: %v", err))
}
}

if errs != nil {
return nil, warns, errs
}
Expand Down Expand Up @@ -401,6 +428,32 @@ func checkHyperVGeneration(s string) interface{} {
s, virtualmachines.PossibleValuesForHyperVGenerationType())
}

// validateLVMRootDevice validates that a user-supplied lvm_root_device value is
// a clean, safe absolute device path under /dev/.
func validateLVMRootDevice(device string) error {
// Reject control characters, etc
for _, r := range device {
if unicode.IsControl(r) || (unicode.IsSpace(r) && r != ' ') {
return fmt.Errorf("%q contains invalid whitespace or control characters", device)
}
}

// Use POSIX path (not filepath) since device paths are always Linux/FreeBSD; LVM
// not a Windows concept.
cleaned := posixpath.Clean(device)

// Check for path traversal: reject if any component is ".."
if slices.Contains(strings.Split(cleaned, "/"), "..") {
return fmt.Errorf("%q must not contain path traversal (..)", device)
}

if !strings.HasPrefix(cleaned, "/dev/") {
return fmt.Errorf("%q must be an absolute device path starting with /dev/ (resolved to %q)", device, cleaned)
}

return nil
}

func (b *Builder) Run(ctx context.Context, ui packersdk.Ui, hook packersdk.Hook) (packersdk.Artifact, error) {
switch runtime.GOOS {
case "linux", "freebsd":
Expand Down Expand Up @@ -611,6 +664,15 @@ func buildsteps(

addSteps(
&StepAttachDisk{}, // uses os_disk_resource_id and sets 'device' in stateBag
// StepSetupLVM always runs: it auto-detects LVM on the attached disk.
// If LVM is found, it activates volume groups and replaces 'device' in
// the state bag with the root LV path. If not, it's a no-op.
&StepSetupLVM{
LVMRootDevice: config.LVMRootDevice,
},
)

addSteps(
&chroot.StepPreMountCommands{
Commands: config.PreMountCommands,
},
Expand All @@ -630,7 +692,12 @@ func buildsteps(
Files: config.CopyFiles,
},
&chroot.StepChrootProvision{},
&chroot.StepEarlyCleanup{},
&StepPreUnmountCommands{
Commands: config.PreUnmountCommands,
},
// Custom StepEarlyCleanup that includes LVM deactivation between
// unmount and disk detach (the SDK's version lacks "lvm_cleanup").
&StepEarlyCleanup{},
)

var captureSteps []multistep.Step
Expand Down
4 changes: 4 additions & 0 deletions builder/azure/chroot/builder.hcl2spec.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

81 changes: 81 additions & 0 deletions builder/azure/chroot/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,36 @@ import (
"github.com/hashicorp/packer-plugin-sdk/packerbuilderdata"
)

func Test_validateLVMRootDevice(t *testing.T) {
tests := []struct {
name string
device string
wantErr bool
}{
{"valid mapper path", "/dev/mapper/rhel-root", false},
{"valid vg/lv path", "/dev/rhel/root", false},
{"valid sda path", "/dev/sda1", false},
{"missing /dev/ prefix", "/mapper/rhel-root", true},
{"relative path", "dev/mapper/rhel-root", true},
{"path traversal with ..", "/dev/../tmp/foo", true},
{"path traversal resolving outside dev", "/dev/mapper/../../etc/passwd", true},
{"contains newline", "/dev/mapper/rhel-root\n", true},
{"contains tab", "/dev/mapper/rhel\troot", true},
{"contains carriage return", "/dev/mapper/rhel-root\r", true},
{"empty string", "", true},
{"just /dev/", "/dev/", true},
{"double dot in middle", "/dev/mapper/a..b", false}, // not a path traversal, just dots in name
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateLVMRootDevice(tt.device)
if (err != nil) != tt.wantErr {
t.Errorf("validateLVMRootDevice(%q) error = %v, wantErr %v", tt.device, err, tt.wantErr)
}
})
}
}

func TestBuilder_Prepare(t *testing.T) {
type config map[string]interface{}

Expand Down Expand Up @@ -123,6 +153,57 @@ func TestBuilder_Prepare(t *testing.T) {
},
wantErr: false,
},
{
name: "valid lvm_root_device accepted",
config: config{
"source": "/subscriptions/789/resourceGroups/testrg/providers/Microsoft.Compute/disks/diskname",
"image_resource_id": "/subscriptions/789/resourceGroups/otherrgname/providers/Microsoft.Compute/images/MyDebianOSImage-{{timestamp}}",
"lvm_root_device": "/dev/mapper/rhel-root",
},
validate: func(c Config) {
if c.LVMRootDevice != "/dev/mapper/rhel-root" {
t.Errorf("Expected LVMRootDevice %q, got %q", "/dev/mapper/rhel-root", c.LVMRootDevice)
}
},
},
{
name: "lvm_root_device with path traversal rejected",
config: config{
"source": "/subscriptions/789/resourceGroups/testrg/providers/Microsoft.Compute/disks/diskname",
"image_resource_id": "/subscriptions/789/resourceGroups/otherrgname/providers/Microsoft.Compute/images/MyDebianOSImage-{{timestamp}}",
"lvm_root_device": "/dev/../tmp/evil",
},
wantErr: true,
},
{
name: "lvm_root_device with newline rejected",
config: config{
"source": "/subscriptions/789/resourceGroups/testrg/providers/Microsoft.Compute/disks/diskname",
"image_resource_id": "/subscriptions/789/resourceGroups/otherrgname/providers/Microsoft.Compute/images/MyDebianOSImage-{{timestamp}}",
"lvm_root_device": "/dev/mapper/rhel-root\n",
},
wantErr: true,
},
{
name: "lvm_root_device without /dev/ prefix rejected",
config: config{
"source": "/subscriptions/789/resourceGroups/testrg/providers/Microsoft.Compute/disks/diskname",
"image_resource_id": "/subscriptions/789/resourceGroups/otherrgname/providers/Microsoft.Compute/images/MyDebianOSImage-{{timestamp}}",
"lvm_root_device": "/mapper/rhel-root",
},
wantErr: true,
},
{
name: "from_scratch with lvm_root_device rejected",
config: config{
"from_scratch": true,
"os_disk_size_gb": 30,
"pre_mount_commands": []string{"sgdisk ..."},
"image_resource_id": "/subscriptions/789/resourceGroups/otherrgname/providers/Microsoft.Compute/images/MyDebianOSImage-{{timestamp}}",
"lvm_root_device": "/dev/mapper/rhel-root",
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
Expand Down
Loading