diff --git a/.github/workflows/ensure-docs-compiled.yaml b/.github/workflows/ensure-docs-compiled.yaml index c6bbc2e0..4050a5fa 100644 --- a/.github/workflows/ensure-docs-compiled.yaml +++ b/.github/workflows/ensure-docs-compiled.yaml @@ -9,6 +9,8 @@ jobs: - name: Checkout 🛎 uses: actions/checkout@v6 - uses: actions/setup-go@v6 + with: + go-version: stable - shell: bash run: make generate - shell: bash diff --git a/.web-docs/components/builder/linode/README.md b/.web-docs/components/builder/linode/README.md index 3728ccc7..0b562880 100644 --- a/.web-docs/components/builder/linode/README.md +++ b/.web-docs/components/builder/linode/README.md @@ -63,11 +63,6 @@ can also be supplied to override the typical auto-generated key: available Linode instance types. Examples are `g6-nanode-1`, `g6-standard-2`, `g6-highmem-16`, and `g6-dedicated-16`. -- `image` (string) - An Image ID to deploy the Disk from. Official Linode Images start with `linode/`, - while user Images start with `private/`. See [images](https://api.linode.com/v4/images) - for more information on the Images available for use. Examples are `linode/debian9`, - `linode/fedora28`, `linode/ubuntu18.04`, `linode/arch`, and `private/12345`. - @@ -88,6 +83,11 @@ can also be supplied to override the typical auto-generated key: - `instance_tags` ([]string) - Tags to apply to the instance when it is created. +- `image` (string) - An Image ID to deploy the Disk from. Official Linode Images start with `linode/`, + while user Images start with `private/`. See [images](https://api.linode.com/v4/images) + for more information on the Images available for use. Examples are `linode/debian12`, + `linode/debian13`, `linode/ubuntu24.04`, `linode/arch`, and `private/12345`. + - `swap_size` (int) - The disk size (MiB) allocated for swap space. - `private_ip` (bool) - If true, the created Linode will have private networking enabled and assigned @@ -134,6 +134,14 @@ can also be supplied to override the typical auto-generated key: `legacy_config` or `linode`. The default value is determined by the `interfaces_for_new_linodes` setting in the account settings. +- `disk` ([]Disk) - Custom disks to create for this Linode. When specified, you are responsible + for creating all disks including the boot disk. See the `disk` block + documentation for available options. + +- `config` ([]InstanceConfig) - Custom configuration profiles to create for this Linode. When specified, + you are responsible for creating all configuration profiles. + See the `config` block documentation for available options. + @@ -410,6 +418,266 @@ This section outlines the fields configurable for a single metadata object. +#### Custom Disks and Configuration Profiles + +When you specify custom `disk` and `config` blocks, you take full control over the Linode's disk layout and boot configuration. This is useful for advanced scenarios like: +- Creating multiple disks (boot, data, swap) +- Configuring specific filesystems +- Setting up custom device mappings +- Deploying from custom or multiple images + +**Important:** When using custom disks, the following top-level attributes are **not compatible** and must not be specified: +- `image` - Specify images at the disk level instead +- `authorized_keys` - Specify in disk blocks instead +- `authorized_users` - Specify in disk blocks instead +- `swap_size` - Create a swap disk instead +- `stackscript_id` - Specify in disk blocks instead +- `stackscript_data` - Specify in disk blocks instead +- `interface` - Specify in config blocks instead + +**Note:** The newer `linode_interface` blocks CAN be used with custom disks as they are specified at the instance level and work independently of the disk/config provisioning. + +The SSH public key from the communicator configuration will be automatically added to the disk specified by the `root_device` in the booted configuration profile. The disk at the root device slot (identified via the `devices` mapping) will also be used to create the final image. + +**Important:** The `root_device` must point to a device slot (e.g., `/dev/sda`) that has a disk assigned in the `devices` block. The disk at that slot will be used for both SSH key injection and image creation. + +**Note:** Deploying an image to and booting from a volume are currently unsupported. Therefore, the `root_device` cannot point to a volume; it must reference a disk. + +##### Disk Block + + + +- `label` (string) - The label for this disk. + +- `size` (int) - The size of the disk in MB. NOTE: Resizing a disk can only be done + when the Linode is offline and may take some time. + + + + + +- `image` (string) - An Image ID to deploy the Linode Disk from. If provided, root_pass is required. + +- `filesystem` (string) - The filesystem for the disk. Valid values are raw, swap, ext3, ext4, initrd. + Defaults to ext4. + +- `authorized_keys` ([]string) - A list of public SSH keys to be installed on the disk as the root user's + ~/.ssh/authorized_keys file. + +- `authorized_users` ([]string) - A list of usernames that will have their SSH keys installed as the root + user's ~/.ssh/authorized_keys file. + +- `stackscript_id` (int) - A StackScript ID to deploy to this disk. Only applies to Image-based disks. + +- `stackscript_data` (map[string]string) - UDF data to pass to the StackScript. + + + + +##### Configuration Profile Block (config) + + + +- `label` (string) - The label for this configuration profile. + +- `devices` (\*InstanceConfigDevices) - Device assignments for this configuration profile. + + + + + +- `booted` (bool) - Whether to boot the Linode with this configuration profile. + Only one configuration profile can have this set to true. + If not specified, the first configuration profile will be used for booting. + +- `comments` (string) - Optional comments about this configuration profile. + +- `helpers` (\*InstanceConfigHelpers) - Helper options for this configuration profile. + +- `interface` ([]Interface) - Legacy config interfaces for this configuration profile. + Conflicts with the top-level interface and linode_interface blocks. + +- `memory_limit` (int) - Limits the amount of RAM the Linode can use. 0 (default) means no limit. + +- `kernel` (string) - The kernel to boot with. Use "linode/latest-64bit" or "linode/grub2". + See https://api.linode.com/v4/linode/kernels for available kernels. + +- `init_rd` (int) - The init RAM disk to use. This is optional and typically not needed. + +- `root_device` (string) - The root device to boot from, e.g., "/dev/sda". When using custom disks, + the disk at this device slot in the booted configuration profile will be imaged. + +- `run_level` (string) - The run level to boot into. Valid values are "default", "single", "binbash". + +- `virt_mode` (string) - The virtualization mode. Valid values are "paravirt" or "fullvirt". + + + + +###### Configuration Helpers (helpers) + + + +- `updatedb_disabled` (\*bool) - Disables updatedb cron job to avoid disk thrashing. + +- `distro` (\*bool) - Enables the Distro filesystem helper. + +- `modules_dep` (\*bool) - Creates a modules dependency file for the Kernel. + +- `network` (\*bool) - Configures network services. + +- `devtmpfs_automount` (\*bool) - Automatically mounts devtmpfs. + + + + +###### Device Mappings (devices) + + + +- `sda` (\*InstanceConfigDevice) - Device assignments for slots sda through sdz. + +- `sdb` (\*InstanceConfigDevice) - SDB + +- `sdc` (\*InstanceConfigDevice) - SDC + +- `sdd` (\*InstanceConfigDevice) - SDD + +- `sde` (\*InstanceConfigDevice) - SDE + +- `sdf` (\*InstanceConfigDevice) - SDF + +- `sdg` (\*InstanceConfigDevice) - SDG + +- `sdh` (\*InstanceConfigDevice) - SDH + +- `sdi` (\*InstanceConfigDevice) - SDI + +- `sdj` (\*InstanceConfigDevice) - SDJ + +- `sdk` (\*InstanceConfigDevice) - SDK + +- `sdl` (\*InstanceConfigDevice) - SDL + +- `sdm` (\*InstanceConfigDevice) - SDM + +- `sdn` (\*InstanceConfigDevice) - SDN + +- `sdo` (\*InstanceConfigDevice) - SDO + +- `sdp` (\*InstanceConfigDevice) - SDP + +- `sdq` (\*InstanceConfigDevice) - SDQ + +- `sdr` (\*InstanceConfigDevice) - SDR + +- `sds` (\*InstanceConfigDevice) - SDS + +- `sdt` (\*InstanceConfigDevice) - SDT + +- `sdu` (\*InstanceConfigDevice) - SDU + +- `sdv` (\*InstanceConfigDevice) - SDV + +- `sdw` (\*InstanceConfigDevice) - SDW + +- `sdx` (\*InstanceConfigDevice) - SDX + +- `sdy` (\*InstanceConfigDevice) - SDY + +- `sdz` (\*InstanceConfigDevice) - SDZ + +- `sdaa` (\*InstanceConfigDevice) - Device assignments for slots sdaa through sdaz. + +- `sdab` (\*InstanceConfigDevice) - SDAB + +- `sdac` (\*InstanceConfigDevice) - SDAC + +- `sdad` (\*InstanceConfigDevice) - SDAD + +- `sdae` (\*InstanceConfigDevice) - SDAE + +- `sdaf` (\*InstanceConfigDevice) - SDAF + +- `sdag` (\*InstanceConfigDevice) - SDAG + +- `sdah` (\*InstanceConfigDevice) - SDAH + +- `sdai` (\*InstanceConfigDevice) - SDAI + +- `sdaj` (\*InstanceConfigDevice) - SDAJ + +- `sdak` (\*InstanceConfigDevice) - SDAK + +- `sdal` (\*InstanceConfigDevice) - SDAL + +- `sdam` (\*InstanceConfigDevice) - SDAM + +- `sdan` (\*InstanceConfigDevice) - SDAN + +- `sdao` (\*InstanceConfigDevice) - SDAO + +- `sdap` (\*InstanceConfigDevice) - SDAP + +- `sdaq` (\*InstanceConfigDevice) - SDAQ + +- `sdar` (\*InstanceConfigDevice) - SDAR + +- `sdas` (\*InstanceConfigDevice) - SDAS + +- `sdat` (\*InstanceConfigDevice) - SDAT + +- `sdau` (\*InstanceConfigDevice) - SDAU + +- `sdav` (\*InstanceConfigDevice) - SDAV + +- `sdaw` (\*InstanceConfigDevice) - SDAW + +- `sdax` (\*InstanceConfigDevice) - SDAX + +- `sday` (\*InstanceConfigDevice) - SDAY + +- `sdaz` (\*InstanceConfigDevice) - SDAZ + +- `sdba` (\*InstanceConfigDevice) - Device assignments for slots sdba through sdbl. + +- `sdbb` (\*InstanceConfigDevice) - SDBB + +- `sdbc` (\*InstanceConfigDevice) - SDBC + +- `sdbd` (\*InstanceConfigDevice) - SDBD + +- `sdbe` (\*InstanceConfigDevice) - SDBE + +- `sdbf` (\*InstanceConfigDevice) - SDBF + +- `sdbg` (\*InstanceConfigDevice) - SDBG + +- `sdbh` (\*InstanceConfigDevice) - SDBH + +- `sdbi` (\*InstanceConfigDevice) - SDBI + +- `sdbj` (\*InstanceConfigDevice) - SDBJ + +- `sdbk` (\*InstanceConfigDevice) - SDBK + +- `sdbl` (\*InstanceConfigDevice) - SDBL + + + + +###### Device Configuration (InstanceConfigDevice) + + + +- `disk_label` (string) - The label of the disk to assign to this device slot. + This will be resolved to the disk ID after disks are created. + +- `volume_id` (int) - The ID of the volume to assign to this device slot. + + + + ## Examples ### Basic Example @@ -426,7 +694,7 @@ or in the config file or the environmental variable, `LINODE_TOKEN`. locals { timestamp = regex_replace(timestamp(), "[- TZ:]", "") } source "linode" "example" { - image = "linode/debian11" + image = "linode/debian13" image_description = "My Private Image" image_label = "private-image-${local.timestamp}" image_share_group_ids = [12345] @@ -450,7 +718,7 @@ build { "source": { "linode": { "example": { - "image": "linode/debian11", + "image": "linode/debian13", "linode_token": "YOUR API TOKEN", "region": "us-mia", "instance_type": "g6-nanode-1", @@ -480,7 +748,7 @@ build { locals { timestamp = regex_replace(timestamp(), "[- TZ:]", "") } source "linode" "example" { - image = "linode/debian11" + image = "linode/debian13" image_description = "My Private Image" image_label = "private-image-${local.timestamp}" instance_label = "temporary-linode-${local.timestamp}" @@ -540,7 +808,7 @@ build { "source": { "linode": { "example": { - "image": "linode/debian11", + "image": "linode/debian13", "region": "us-southeast", "instance_type": "g6-nanode-1", "instance_label": "temporary-linode-{{timestamp}}", @@ -594,7 +862,7 @@ build { locals { timestamp = regex_replace(timestamp(), "[- TZ:]", "") } source "linode" "example" { - image = "linode/ubuntu24.04" + image = "linode/debian13" image_description = "My Private Image" image_label = "private-image-${local.timestamp}" instance_label = "temporary-linode-${local.timestamp}" @@ -621,6 +889,134 @@ build { } ``` +## Custom Disk and Configuration Example + +This example demonstrates creating a Linode with custom disks and a configuration profile. This provides full control over disk layout and boot configuration. + +**HCL2** + +```hcl +locals { timestamp = regex_replace(timestamp(), "[- TZ:]", "") } + +source "linode" "custom" { + image_description = "Custom Disk Image" + image_label = "custom-disk-${local.timestamp}" + instance_label = "temporary-linode-${local.timestamp}" + instance_type = "g6-nanode-1" + region = "us-mia" + ssh_username = "root" + interface_generation = "legacy_config" + + # Define custom disks + disk { + label = "boot" + size = 25000 + image = "linode/debian13" + filesystem = "ext4" + } + + disk { + label = "swap" + size = 512 + filesystem = "swap" + } + + # Define configuration profile + config { + label = "my-config" + comments = "Boot configuration" + kernel = "linode/latest-64bit" + root_device = "/dev/sda" + run_level = "default" + + # Map disks to device slots + devices { + sda { disk_label = "boot" } + sdb { disk_label = "swap" } + } + + # Configure helpers + helpers { + updatedb_disabled = true + distro = true + modules_dep = true + network = true + devtmpfs_automount = true + } + + # Define network interfaces + interface { + purpose = "public" + } + } +} + +build { + sources = ["source.linode.custom"] +} +``` + +**JSON** + +```json +{ + "source": { + "linode": { + "custom": { + "image_description": "Custom Disk Image", + "image_label": "custom-disk-{{timestamp}}", + "instance_label": "temporary-linode-{{timestamp}}", + "instance_type": "g6-nanode-1", + "region": "us-mia", + "ssh_username": "root", + "interface_generation": "legacy_config", + "disk": [ + { + "label": "boot", + "size": 25000, + "image": "linode/debian13", + "filesystem": "ext4" + }, + { + "label": "swap", + "size": 512, + "filesystem": "swap" + } + ], + "config": [ + { + "label": "my-config", + "comments": "Boot configuration", + "kernel": "linode/latest-64bit", + "root_device": "/dev/sda", + "run_level": "default", + "devices": { + "sda": { "disk_label": "boot" }, + "sdb": { "disk_label": "swap" } + }, + "helpers": { + "updatedb_disabled": true, + "distro": true, + "modules_dep": true, + "network": true, + "devtmpfs_automount": true + }, + "interface": [ + { + "purpose": "public" + } + ] + } + ] + } + } + }, + "build": { + "sources": ["source.linode.custom"] + } +} +``` + **JSON** ```json @@ -628,7 +1024,7 @@ build { "source": { "linode": { "example": { - "image": "linode/ubuntu24.04", + "image": "linode/debian13", "linode_token": "YOUR API TOKEN", "region": "us-mia", "instance_type": "g6-nanode-1", diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..8f32c660 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,94 @@ +# AI Coding Agent Guidelines for packer-plugin-linode + +## Project Overview + +This is a HashiCorp Packer plugin for creating Linode images. It provides: +- **Builder** (`builder/linode/`): Creates Linode instances, provisions them, then snapshots to reusable images +- **Datasource** (`datasource/image/`): Queries existing Linode images for use in Packer templates + +The plugin uses the [Packer Plugin SDK](https://github.com/hashicorp/packer-plugin-sdk) and [linodego](https://github.com/linode/linodego) client library. + +## Architecture + +### Build Flow +1. `main.go` registers the builder and datasource with the Packer plugin system +2. Builder executes as a series of **steps** (`step_*.go`) via `multistep.Runner`: + - `StepCreateSSHKey` → `stepCreateLinode` → `stepCreateDiskConfig` → `StepConnect` → `StepProvision` → `stepShutdownLinode` → `stepCreateImage` +3. Configuration is defined in `config.go` with HCL2 specs auto-generated via `//go:generate packer-sdc` + +### Instance Creation Modes +The builder supports two modes: + +**Standard Mode** (with `image` specified): +- Linode API automatically creates disk and config from the image +- Instance starts booted and ready for SSH connection +- Used in most scenarios + +**Custom Mode** (with `disk` and `config` blocks): +- `stepCreateLinode` creates unbooted instance without image +- `stepCreateDiskConfig` creates custom disks and configuration profiles +- Disk label → ID resolution: configs reference disks by label, which are resolved after disk creation +- At most one config may have `booted = true`; if none do, the first config is used as the boot configuration +- Instance is manually booted after configuration +- Enables fine-grained control over disk layout, kernel, helpers, and interfaces + +### Key Patterns +- **Configuration structs** use `mapstructure` tags for HCL parsing and embed `helper.LinodeCommon` for shared auth config +- **Step pattern**: Each step implements `multistep.Step` interface with `Run()` and `Cleanup()` methods, storing state in `multistep.StateBag` +- **Flatten functions** in `step_create_linode.go` and `step_create_disk_config.go` convert config structs to linodego API types +- **Disk label resolution**: `resolveDiskLabel()` in `step_create_disk_config.go` maps user-provided disk labels in configs to actual disk IDs after creation +- **Two interface systems**: Legacy `interface` blocks and newer `linode_interface` blocks (see `linode_interfaces.go`) + +### Adding a New Step +1. Create `step_.go` implementing `multistep.Step` with `Run(ctx, state)` and `Cleanup(state)` methods +2. Retrieve config/ui from state: `c := state.Get("config").(*Config)`, `ui := state.Get("ui").(packersdk.Ui)` +3. Use `helper.ErrorHelper(state, ui, "prefix", err)` for error handling (returns `multistep.ActionHalt`) +4. Store results in state for subsequent steps: `state.Put("key", value)` +5. Add step to the `steps` slice in `builder.go` `Run()` method in correct order +6. Implement `Cleanup()` for resource teardown on failure (e.g., delete created resources) + +## Developer Commands + +```bash +make dev # Build plugin and install to Packer plugins dir +make unit-test # Run unit tests with race detection +make acctest # Run acceptance tests (requires PACKER_ACC=1, LINODE_TOKEN) +make generate # Regenerate HCL2 specs and documentation +make lint # Run golangci-lint (install via `make deps`) +make format # Format code with gofumpt +``` + +### Testing Requirements +- **Unit tests**: No external dependencies, run with `make unit-test` +- **Acceptance tests**: Require `LINODE_TOKEN` env var and `PACKER_ACC=1`, create real Linode resources +- Test files follow `*_test.go` naming; acceptance tests use `*_acc_test.go` suffix +- Debug with `PACKER_LOG=1` environment variable to see detailed plugin logs + +## Code Generation + +Files ending in `.hcl2spec.go` are **auto-generated** – do not edit manually. When modifying config structs: +1. Add/update `//go:generate packer-sdc struct-markdown` and `//go:generate packer-sdc mapstructure-to-hcl2` directives +2. Run `make generate` to regenerate specs and docs +3. Documentation partials in `docs-partials/` are generated from struct field comments + +### Adding New Config Fields +1. Add field to struct in `config.go` or `linode_interfaces.go` with `mapstructure` tag +2. Add doc comment above field (becomes auto-generated documentation) +3. Mark required fields with `required:"true"` tag +4. Add validation in `Config.Prepare()` method if needed +5. Run `make generate` to update `.hcl2spec.go` and docs + +## File Structure Reference + +| Path | Purpose | +|------|---------| +| `builder/linode/config.go` | Builder configuration with validation | +| `builder/linode/step_*.go` | Build step implementations | +| `builder/linode/step_create_disk_config.go` | Custom disk and config profile creation step | +| `builder/linode/linode_interfaces.go` | Newer network interface configuration structs | +| `helper/common.go` | Shared config (LinodeCommon embedded struct) | +| `helper/client.go` | Linode API client initialization | +| `helper/error.go` | ErrorHelper for consistent step error handling | +| `datasource/image/data.go` | Image datasource implementation | +| `docs/` | MDX documentation source files | +| `example/` | Sample Packer templates (HCL and JSON) | diff --git a/builder/linode/artifact_test.go b/builder/linode/artifact_test.go index 8a87353b..34054ce6 100644 --- a/builder/linode/artifact_test.go +++ b/builder/linode/artifact_test.go @@ -69,7 +69,7 @@ func TestArtifactState_hcpPackerRegistryMetadata(t *testing.T) { ImageID: "test-image", ImageLabel: "test-image-label", StateData: map[string]interface{}{ - "source_image": "linode/debian9", + "source_image": "linode/arch", "region": region, "linode_type": "g6-nanode-1", }, @@ -92,9 +92,9 @@ func TestArtifactState_hcpPackerRegistryMetadata(t *testing.T) { ImageID: "test-image", ProviderName: "linode", ProviderRegion: "us-ord", - SourceImageID: "linode/debian9", + SourceImageID: "linode/arch", Labels: map[string]string{ - "source_image": "linode/debian9", + "source_image": "linode/arch", "region": region, "linode_type": "g6-nanode-1", }, diff --git a/builder/linode/builder.go b/builder/linode/builder.go index 90d38c7a..7506f1ad 100644 --- a/builder/linode/builder.go +++ b/builder/linode/builder.go @@ -61,6 +61,7 @@ func (b *Builder) Run(ctx context.Context, ui packersdk.Ui, hook packersdk.Hook) DebugKeyPath: fmt.Sprintf("linode_%s.pem", b.config.PackerBuildName), }, &stepCreateLinode{client}, + &stepCreateDiskConfig{client}, &communicator.StepConnect{ Config: &b.config.Comm, Host: commHost(b.config.Comm.Host()), @@ -83,15 +84,15 @@ func (b *Builder) Run(ctx context.Context, ui packersdk.Ui, hook packersdk.Hook) // If we were interrupted or cancelled, then just exit. if _, ok := state.GetOk(multistep.StateCancelled); ok { - return nil, errors.New("Build was cancelled.") + return nil, errors.New("build was cancelled") } if _, ok := state.GetOk(multistep.StateHalted); ok { - return nil, errors.New("Build was halted.") + return nil, errors.New("build was halted") } if _, ok := state.GetOk("image"); !ok { - return nil, errors.New("Cannot find image in state.") + return nil, errors.New("cannot find image in state") } image := state.Get("image").(*linodego.Image) @@ -119,7 +120,7 @@ func commHost(host string) func(multistep.StateBag) (string, error) { instance := state.Get("instance").(*linodego.Instance) if len(instance.IPv4) == 0 { - return "", fmt.Errorf("Linode instance %d has no IPv4 addresses!", instance.ID) + return "", fmt.Errorf("linode instance %d has no IPv4 addresses", instance.ID) } return instance.IPv4[0].String(), nil } diff --git a/builder/linode/builder_acc_test.go b/builder/linode/builder_acc_test.go index 660296fa..b58f67e3 100644 --- a/builder/linode/builder_acc_test.go +++ b/builder/linode/builder_acc_test.go @@ -20,7 +20,7 @@ func TestBuilderAcc_basic(t *testing.T) { const testBuilderAccBasic = ` source "linode" "example" { - image = "linode/ubuntu22.04" + image = "linode/arch" instance_type = "g6-nanode-1" region = "us-mia" ssh_username = "root" @@ -30,3 +30,135 @@ build { sources = ["source.linode.example"] } ` + +func TestBuilderAcc_customDisksAndConfig(t *testing.T) { + if skip := acceptance.TestAccPreCheck(t); skip == true { + return + } + acctest.TestPlugin(t, &acctest.PluginTestCase{ + Name: "test-linode-builder-custom-disks-config", + Type: "linode", + Template: testBuilderAccCustomDisksConfig, + }) +} + +const testBuilderAccCustomDisksConfig = ` +source "linode" "custom" { + instance_type = "g6-nanode-1" + region = "us-mia" + ssh_username = "root" + interface_generation = "legacy_config" + + disk { + label = "boot" + size = 25000 + image = "linode/arch" + filesystem = "ext4" + } + + disk { + label = "swap" + size = 512 + filesystem = "swap" + } + + config { + label = "my-config" + comments = "Boot configuration" + kernel = "linode/grub2" + root_device = "/dev/sda" + run_level = "default" + + devices { + sda { disk_label = "boot" } + sdb { disk_label = "swap" } + } + + helpers { + updatedb_disabled = true + distro = true + modules_dep = true + network = true + devtmpfs_automount = true + } + + interface { + purpose = "public" + } + } +} + +build { + sources = ["source.linode.custom"] +} +` + +func TestBuilderAcc_customDisksWithLinodeInterface(t *testing.T) { + if skip := acceptance.TestAccPreCheck(t); skip == true { + return + } + acctest.TestPlugin(t, &acctest.PluginTestCase{ + Name: "test-linode-builder-custom-disks-linode-interface", + Type: "linode", + Template: testBuilderAccCustomDisksWithLinodeInterface, + }) +} + +const testBuilderAccCustomDisksWithLinodeInterface = ` +source "linode" "custom_linode_interface" { + instance_type = "g6-nanode-1" + region = "us-mia" + ssh_username = "root" + interface_generation = "linode" + + # Newer linode_interface blocks work with custom disks + linode_interface { + public { + ipv4 { + address { + address = "auto" + primary = true + } + } + } + } + + disk { + label = "boot" + size = 25000 + image = "linode/arch" + filesystem = "ext4" + } + + disk { + label = "swap" + size = 512 + filesystem = "swap" + } + + config { + label = "my-config" + comments = "Boot configuration with linode_interface" + kernel = "linode/grub2" + root_device = "/dev/sda" + run_level = "default" + + devices { + sda { disk_label = "boot" } + sdb { disk_label = "swap" } + } + + helpers { + updatedb_disabled = true + distro = true + modules_dep = true + network = true + devtmpfs_automount = true + } + } +} + +build { + sources = ["source.linode.custom_linode_interface"] +} +` diff --git a/builder/linode/builder_test.go b/builder/linode/builder_test.go index 9484e46f..9b310199 100644 --- a/builder/linode/builder_test.go +++ b/builder/linode/builder_test.go @@ -3,6 +3,7 @@ package linode import ( "reflect" "strconv" + "strings" "testing" "time" @@ -16,7 +17,7 @@ func testConfig() map[string]any { "region": "us-ord", "instance_type": "g6-nanode-1", "ssh_username": "root", - "image": "linode/debian11", + "image": "linode/arch", } } @@ -727,3 +728,450 @@ func TestBuilderPrepare_ImageShareGroupIDs(t *testing.T) { t.Errorf("got %v, expected %v", b.config.ImageShareGroupIDs, expected) } } + +func TestBuilderPrepare_CustomDisks(t *testing.T) { + var b Builder + config := testConfig() + + // Test with custom disk + config["disk"] = []map[string]any{ + { + "label": "boot", + "size": 25000, + "image": "linode/arch", + "filesystem": "ext4", + }, + { + "label": "swap", + "size": 512, + "filesystem": "swap", + }, + } + + // Add config block (required when using custom disks) + config["config"] = []map[string]any{ + { + "label": "my-config", + "kernel": "linode/latest-64bit", + "root_device": "/dev/sda", + "devices": map[string]any{ + "sda": map[string]any{ + "disk_label": "boot", + }, + "sdb": map[string]any{ + "disk_label": "swap", + }, + }, + }, + } + + // When using custom disks, image should not be required at top level + delete(config, "image") + + _, warnings, err := b.Prepare(config) + if len(warnings) > 0 { + t.Fatalf("bad: %#v", warnings) + } + if err != nil { + t.Fatalf("should not have error with custom disks: %s", err) + } + + if len(b.config.Disks) != 2 { + t.Errorf("expected 2 disks, got %d", len(b.config.Disks)) + } + + if b.config.Disks[0].Label != "boot" { + t.Errorf("expected first disk label to be 'boot', got %s", b.config.Disks[0].Label) + } + + if b.config.Disks[1].Filesystem != "swap" { + t.Errorf("expected second disk filesystem to be 'swap', got %s", b.config.Disks[1].Filesystem) + } + + if len(b.config.InstanceConfigs) != 1 { + t.Errorf("expected 1 config, got %d", len(b.config.InstanceConfigs)) + } +} + +func TestBuilderPrepare_CustomConfig(t *testing.T) { + var b Builder + config := testConfig() + + // Test with custom config + config["config"] = []map[string]any{ + { + "label": "my-config", + "comments": "boot config", + "kernel": "linode/latest-64bit", + "root_device": "/dev/sda", + "devices": map[string]any{ + "sda": map[string]any{ + "disk_label": "boot", + }, + }, + "helpers": map[string]any{ + "updatedb_disabled": true, + "distro": true, + "modules_dep": true, + "network": true, + "devtmpfs_automount": true, + }, + }, + } + + config["disk"] = []map[string]any{ + { + "label": "boot", + "size": 25000, + "image": "linode/arch", + }, + } + + // When using custom disks, image should not be required at top level + delete(config, "image") + + _, warnings, err := b.Prepare(config) + if len(warnings) > 0 { + t.Fatalf("bad: %#v", warnings) + } + if err != nil { + t.Fatalf("should not have error with custom config: %s", err) + } + + if len(b.config.InstanceConfigs) != 1 { + t.Errorf("expected 1 config, got %d", len(b.config.InstanceConfigs)) + } + + if b.config.InstanceConfigs[0].Label != "my-config" { + t.Errorf("expected config label to be 'my-config', got %s", b.config.InstanceConfigs[0].Label) + } + + if b.config.InstanceConfigs[0].Kernel != "linode/latest-64bit" { + t.Errorf("expected kernel to be 'linode/latest-64bit', got %s", b.config.InstanceConfigs[0].Kernel) + } + + if b.config.InstanceConfigs[0].Devices == nil { + t.Error("expected devices to be set") + } + + if b.config.InstanceConfigs[0].Devices.SDA == nil { + t.Error("expected SDA device to be set") + } + + if b.config.InstanceConfigs[0].Devices.SDA.DiskLabel != "boot" { + t.Errorf("expected SDA disk_label to be 'boot', got %s", b.config.InstanceConfigs[0].Devices.SDA.DiskLabel) + } +} + +func TestBuilderPrepare_CustomDisksValidation(t *testing.T) { + // Test that image and custom disks cannot be specified together + t.Run("ImageAndDisks", func(t *testing.T) { + var b Builder + config := testConfig() + config["image"] = "linode/arch" + config["disk"] = []map[string]any{ + { + "label": "boot", + "size": 25000, + "image": "linode/arch", + "filesystem": "ext4", + }, + } + + _, _, err := b.Prepare(config) + if err == nil { + t.Fatal("expected error when specifying both image and custom disks") + } + if !strings.Contains(err.Error(), "cannot specify both image and custom disks") { + t.Fatalf("expected specific error message, got: %s", err) + } + }) + + // Test that config is required when using custom disks + t.Run("DisksWithoutConfig", func(t *testing.T) { + var b Builder + config := testConfig() + delete(config, "image") + config["disk"] = []map[string]any{ + { + "label": "boot", + "size": 25000, + "image": "linode/arch", + "filesystem": "ext4", + }, + } + + _, _, err := b.Prepare(config) + if err == nil { + t.Fatal("expected error when using custom disks without config") + } + if !strings.Contains(err.Error(), "disk and config blocks must be specified together") { + t.Fatalf("expected specific error message, got: %s", err) + } + }) + + // Test that either image or disks must be specified + t.Run("NoImageOrDisks", func(t *testing.T) { + var b Builder + config := testConfig() + delete(config, "image") + + _, _, err := b.Prepare(config) + if err == nil { + t.Fatal("expected error when neither image nor disks specified") + } + if !strings.Contains(err.Error(), "either image or custom disks must be specified") { + t.Fatalf("expected specific error message, got: %s", err) + } + }) + + // Test incompatible attributes with custom disks + t.Run("IncompatibleAuthorizedKeys", func(t *testing.T) { + var b Builder + config := testConfig() + delete(config, "image") + config["authorized_keys"] = []string{"ssh-rsa test"} + config["disk"] = []map[string]any{ + {"label": "boot", "size": 25000, "image": "linode/arch"}, + } + config["config"] = []map[string]any{ + {"label": "my-config"}, + } + + _, _, err := b.Prepare(config) + if err == nil { + t.Fatal("expected error with authorized_keys and custom disks") + } + if !strings.Contains(err.Error(), "authorized_keys cannot be specified when using custom disks") { + t.Fatalf("expected specific error message, got: %s", err) + } + }) + + t.Run("IncompatibleAuthorizedUsers", func(t *testing.T) { + var b Builder + config := testConfig() + delete(config, "image") + config["authorized_users"] = []string{"user1"} + config["disk"] = []map[string]any{ + {"label": "boot", "size": 25000, "image": "linode/arch"}, + } + config["config"] = []map[string]any{ + {"label": "my-config"}, + } + + _, _, err := b.Prepare(config) + if err == nil { + t.Fatal("expected error with authorized_users and custom disks") + } + if !strings.Contains(err.Error(), "authorized_users cannot be specified when using custom disks") { + t.Fatalf("expected specific error message, got: %s", err) + } + }) + + t.Run("IncompatibleSwapSize", func(t *testing.T) { + var b Builder + config := testConfig() + delete(config, "image") + config["swap_size"] = 512 + config["disk"] = []map[string]any{ + {"label": "boot", "size": 25000, "image": "linode/arch"}, + } + config["config"] = []map[string]any{ + {"label": "my-config"}, + } + + _, _, err := b.Prepare(config) + if err == nil { + t.Fatal("expected error with swap_size and custom disks") + } + if !strings.Contains(err.Error(), "swap_size cannot be specified when using custom disks") { + t.Fatalf("expected specific error message, got: %s", err) + } + }) + + t.Run("IncompatibleStackScriptID", func(t *testing.T) { + var b Builder + config := testConfig() + delete(config, "image") + config["stackscript_id"] = 12345 + config["disk"] = []map[string]any{ + {"label": "boot", "size": 25000, "image": "linode/arch"}, + } + config["config"] = []map[string]any{ + {"label": "my-config"}, + } + + _, _, err := b.Prepare(config) + if err == nil { + t.Fatal("expected error with stackscript_id and custom disks") + } + if !strings.Contains(err.Error(), "stackscript_id cannot be specified when using custom disks") { + t.Fatalf("expected specific error message, got: %s", err) + } + }) + + t.Run("IncompatibleStackScriptData", func(t *testing.T) { + var b Builder + config := testConfig() + delete(config, "image") + config["stackscript_data"] = map[string]string{"key": "value"} + config["disk"] = []map[string]any{ + {"label": "boot", "size": 25000, "image": "linode/arch"}, + } + config["config"] = []map[string]any{ + {"label": "my-config"}, + } + + _, _, err := b.Prepare(config) + if err == nil { + t.Fatal("expected error with stackscript_data and custom disks") + } + if !strings.Contains(err.Error(), "stackscript_data cannot be specified when using custom disks") { + t.Fatalf("expected specific error message, got: %s", err) + } + }) + + t.Run("IncompatibleInterface", func(t *testing.T) { + var b Builder + config := testConfig() + delete(config, "image") + config["interface"] = []map[string]any{ + {"purpose": "public"}, + } + config["disk"] = []map[string]any{ + {"label": "boot", "size": 25000, "image": "linode/arch"}, + } + config["config"] = []map[string]any{ + {"label": "my-config"}, + } + + _, _, err := b.Prepare(config) + if err == nil { + t.Fatal("expected error with interface and custom disks") + } + if !strings.Contains(err.Error(), "interface blocks cannot be specified when using custom disks") { + t.Fatalf("expected specific error message, got: %s", err) + } + }) + + t.Run("LinodeInterfaceWithCustomDisks", func(t *testing.T) { + var b Builder + config := testConfig() + delete(config, "image") + // linode_interface should be ALLOWED with custom disks + config["linode_interface"] = []map[string]any{ + {"public": map[string]any{}}, + } + config["disk"] = []map[string]any{ + {"label": "boot", "size": 25000, "image": "linode/arch"}, + } + config["config"] = []map[string]any{ + {"label": "my-config", "root_device": "/dev/sda", "devices": map[string]any{"sda": map[string]any{"disk_label": "boot"}}}, + } + + _, _, err := b.Prepare(config) + if err != nil { + t.Fatalf("linode_interface should be allowed with custom disks, got error: %s", err) + } + }) + + t.Run("MissingRootDevice", func(t *testing.T) { + var b Builder + config := testConfig() + delete(config, "image") + config["disk"] = []map[string]any{ + {"label": "boot", "size": 25000, "image": "linode/arch"}, + } + config["config"] = []map[string]any{ + {"label": "my-config", "devices": map[string]any{"sda": map[string]any{"disk_label": "boot"}}}, + } + // Missing root_device in boot config - should default to /dev/sda + + _, _, err := b.Prepare(config) + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + // Verify default was applied + if b.config.InstanceConfigs[0].RootDevice != "/dev/sda" { + t.Fatalf("expected root_device to default to /dev/sda, got: %s", b.config.InstanceConfigs[0].RootDevice) + } + }) + + t.Run("RootDevicePointsToUndefinedDisk", func(t *testing.T) { + var b Builder + config := testConfig() + delete(config, "image") + config["disk"] = []map[string]any{ + {"label": "boot", "size": 25000, "image": "linode/arch"}, + } + config["config"] = []map[string]any{ + {"label": "my-config", "root_device": "/dev/sda", "devices": map[string]any{"sda": map[string]any{"disk_label": "nonexistent"}}}, + } + + _, _, err := b.Prepare(config) + if err == nil { + t.Fatal("expected error when root_device points to undefined disk") + } + if !strings.Contains(err.Error(), "root_device points to disk") { + t.Fatalf("expected specific error message, got: %s", err) + } + }) + + t.Run("ConfigWithoutDisks", func(t *testing.T) { + var b Builder + config := testConfig() + // Specify config blocks without disk blocks + config["config"] = []map[string]any{ + {"label": "my-config"}, + } + + _, _, err := b.Prepare(config) + if err == nil { + t.Fatal("expected error when using config blocks without disk blocks") + } + if !strings.Contains(err.Error(), "disk and config blocks must be specified together") { + t.Fatalf("expected specific error message, got: %s", err) + } + }) + + t.Run("DuplicateDiskLabels", func(t *testing.T) { + var b Builder + config := testConfig() + delete(config, "image") + config["disk"] = []map[string]any{ + {"label": "boot", "size": 25000, "image": "linode/arch"}, + {"label": "boot", "size": 512, "filesystem": "swap"}, + } + config["config"] = []map[string]any{ + {"label": "my-config", "root_device": "/dev/sda", "devices": map[string]any{"sda": map[string]any{"disk_label": "boot"}}}, + } + + _, _, err := b.Prepare(config) + if err == nil { + t.Fatal("expected error when disk labels are duplicated") + } + if !strings.Contains(err.Error(), "duplicate disk label \"boot\" found") { + t.Fatalf("expected specific error message, got: %s", err) + } + }) + + t.Run("EmptyDiskLabel", func(t *testing.T) { + var b Builder + config := testConfig() + delete(config, "image") + config["disk"] = []map[string]any{ + {"label": "", "size": 25000, "image": "linode/arch"}, + } + config["config"] = []map[string]any{ + {"label": "my-config", "root_device": "/dev/sda", "devices": map[string]any{"sda": map[string]any{"disk_label": ""}}}, + } + + _, _, err := b.Prepare(config) + if err == nil { + t.Fatal("expected error when disk label is empty") + } + if !strings.Contains(err.Error(), "disk label cannot be empty") { + t.Fatalf("expected specific error message, got: %s", err) + } + }) +} diff --git a/builder/linode/config.go b/builder/linode/config.go index 5543d445..7e209ea5 100644 --- a/builder/linode/config.go +++ b/builder/linode/config.go @@ -1,5 +1,5 @@ //go:generate packer-sdc struct-markdown -//go:generate packer-sdc mapstructure-to-hcl2 -type Config,Interface,InterfaceIPv4,Metadata +//go:generate packer-sdc mapstructure-to-hcl2 -type Config,Interface,InterfaceIPv4,Metadata,Disk,InstanceConfig,InstanceConfigDevice,InstanceConfigDevices,InstanceConfigHelpers package linode @@ -10,6 +10,7 @@ import ( "fmt" "os" "regexp" + "strings" "time" "github.com/hashicorp/packer-plugin-sdk/common" @@ -33,6 +34,185 @@ type Metadata struct { UserData string `mapstructure:"user_data"` } +// Disk represents a disk to be created for the Linode instance. +// See https://techdocs.akamai.com/linode-api/reference/post-add-linode-disk +type Disk struct { + // The label for this disk. + Label string `mapstructure:"label" required:"true"` + + // The size of the disk in MB. NOTE: Resizing a disk can only be done + // when the Linode is offline and may take some time. + Size int `mapstructure:"size" required:"true"` + + // An Image ID to deploy the Linode Disk from. If provided, root_pass is required. + Image string `mapstructure:"image" required:"false"` + + // The filesystem for the disk. Valid values are raw, swap, ext3, ext4, initrd. + // Defaults to ext4. + Filesystem string `mapstructure:"filesystem" required:"false"` + + // A list of public SSH keys to be installed on the disk as the root user's + // ~/.ssh/authorized_keys file. + AuthorizedKeys []string `mapstructure:"authorized_keys" required:"false"` + + // A list of usernames that will have their SSH keys installed as the root + // user's ~/.ssh/authorized_keys file. + AuthorizedUsers []string `mapstructure:"authorized_users" required:"false"` + + // A StackScript ID to deploy to this disk. Only applies to Image-based disks. + StackscriptID int `mapstructure:"stackscript_id" required:"false"` + + // UDF data to pass to the StackScript. + StackscriptData map[string]string `mapstructure:"stackscript_data" required:"false"` +} + +// InstanceConfigDevice represents a device slot in a configuration profile. +type InstanceConfigDevice struct { + // The label of the disk to assign to this device slot. + // This will be resolved to the disk ID after disks are created. + DiskLabel string `mapstructure:"disk_label" required:"false"` + + // The ID of the volume to assign to this device slot. + VolumeID int `mapstructure:"volume_id" required:"false"` +} + +// InstanceConfigDevices represents the device mappings for a configuration profile. +// Each device slot can contain either a disk or a volume. +type InstanceConfigDevices struct { + // Device assignments for slots sda through sdz. + SDA *InstanceConfigDevice `mapstructure:"sda" required:"false"` + SDB *InstanceConfigDevice `mapstructure:"sdb" required:"false"` + SDC *InstanceConfigDevice `mapstructure:"sdc" required:"false"` + SDD *InstanceConfigDevice `mapstructure:"sdd" required:"false"` + SDE *InstanceConfigDevice `mapstructure:"sde" required:"false"` + SDF *InstanceConfigDevice `mapstructure:"sdf" required:"false"` + SDG *InstanceConfigDevice `mapstructure:"sdg" required:"false"` + SDH *InstanceConfigDevice `mapstructure:"sdh" required:"false"` + SDI *InstanceConfigDevice `mapstructure:"sdi" required:"false"` + SDJ *InstanceConfigDevice `mapstructure:"sdj" required:"false"` + SDK *InstanceConfigDevice `mapstructure:"sdk" required:"false"` + SDL *InstanceConfigDevice `mapstructure:"sdl" required:"false"` + SDM *InstanceConfigDevice `mapstructure:"sdm" required:"false"` + SDN *InstanceConfigDevice `mapstructure:"sdn" required:"false"` + SDO *InstanceConfigDevice `mapstructure:"sdo" required:"false"` + SDP *InstanceConfigDevice `mapstructure:"sdp" required:"false"` + SDQ *InstanceConfigDevice `mapstructure:"sdq" required:"false"` + SDR *InstanceConfigDevice `mapstructure:"sdr" required:"false"` + SDS *InstanceConfigDevice `mapstructure:"sds" required:"false"` + SDT *InstanceConfigDevice `mapstructure:"sdt" required:"false"` + SDU *InstanceConfigDevice `mapstructure:"sdu" required:"false"` + SDV *InstanceConfigDevice `mapstructure:"sdv" required:"false"` + SDW *InstanceConfigDevice `mapstructure:"sdw" required:"false"` + SDX *InstanceConfigDevice `mapstructure:"sdx" required:"false"` + SDY *InstanceConfigDevice `mapstructure:"sdy" required:"false"` + SDZ *InstanceConfigDevice `mapstructure:"sdz" required:"false"` + + // Device assignments for slots sdaa through sdaz. + SDAA *InstanceConfigDevice `mapstructure:"sdaa" required:"false"` + SDAB *InstanceConfigDevice `mapstructure:"sdab" required:"false"` + SDAC *InstanceConfigDevice `mapstructure:"sdac" required:"false"` + SDAD *InstanceConfigDevice `mapstructure:"sdad" required:"false"` + SDAE *InstanceConfigDevice `mapstructure:"sdae" required:"false"` + SDAF *InstanceConfigDevice `mapstructure:"sdaf" required:"false"` + SDAG *InstanceConfigDevice `mapstructure:"sdag" required:"false"` + SDAH *InstanceConfigDevice `mapstructure:"sdah" required:"false"` + SDAI *InstanceConfigDevice `mapstructure:"sdai" required:"false"` + SDAJ *InstanceConfigDevice `mapstructure:"sdaj" required:"false"` + SDAK *InstanceConfigDevice `mapstructure:"sdak" required:"false"` + SDAL *InstanceConfigDevice `mapstructure:"sdal" required:"false"` + SDAM *InstanceConfigDevice `mapstructure:"sdam" required:"false"` + SDAN *InstanceConfigDevice `mapstructure:"sdan" required:"false"` + SDAO *InstanceConfigDevice `mapstructure:"sdao" required:"false"` + SDAP *InstanceConfigDevice `mapstructure:"sdap" required:"false"` + SDAQ *InstanceConfigDevice `mapstructure:"sdaq" required:"false"` + SDAR *InstanceConfigDevice `mapstructure:"sdar" required:"false"` + SDAS *InstanceConfigDevice `mapstructure:"sdas" required:"false"` + SDAT *InstanceConfigDevice `mapstructure:"sdat" required:"false"` + SDAU *InstanceConfigDevice `mapstructure:"sdau" required:"false"` + SDAV *InstanceConfigDevice `mapstructure:"sdav" required:"false"` + SDAW *InstanceConfigDevice `mapstructure:"sdaw" required:"false"` + SDAX *InstanceConfigDevice `mapstructure:"sdax" required:"false"` + SDAY *InstanceConfigDevice `mapstructure:"sday" required:"false"` + SDAZ *InstanceConfigDevice `mapstructure:"sdaz" required:"false"` + + // Device assignments for slots sdba through sdbl. + SDBA *InstanceConfigDevice `mapstructure:"sdba" required:"false"` + SDBB *InstanceConfigDevice `mapstructure:"sdbb" required:"false"` + SDBC *InstanceConfigDevice `mapstructure:"sdbc" required:"false"` + SDBD *InstanceConfigDevice `mapstructure:"sdbd" required:"false"` + SDBE *InstanceConfigDevice `mapstructure:"sdbe" required:"false"` + SDBF *InstanceConfigDevice `mapstructure:"sdbf" required:"false"` + SDBG *InstanceConfigDevice `mapstructure:"sdbg" required:"false"` + SDBH *InstanceConfigDevice `mapstructure:"sdbh" required:"false"` + SDBI *InstanceConfigDevice `mapstructure:"sdbi" required:"false"` + SDBJ *InstanceConfigDevice `mapstructure:"sdbj" required:"false"` + SDBK *InstanceConfigDevice `mapstructure:"sdbk" required:"false"` + SDBL *InstanceConfigDevice `mapstructure:"sdbl" required:"false"` +} + +// InstanceConfigHelpers are helper options that control Linux distribution specific tweaks. +type InstanceConfigHelpers struct { + // Disables updatedb cron job to avoid disk thrashing. + UpdateDBDisabled *bool `mapstructure:"updatedb_disabled" required:"false"` + + // Enables the Distro filesystem helper. + Distro *bool `mapstructure:"distro" required:"false"` + + // Creates a modules dependency file for the Kernel. + ModulesDep *bool `mapstructure:"modules_dep" required:"false"` + + // Configures network services. + Network *bool `mapstructure:"network" required:"false"` + + // Automatically mounts devtmpfs. + DevTmpFsAutomount *bool `mapstructure:"devtmpfs_automount" required:"false"` +} + +// InstanceConfig represents a configuration profile for the Linode instance. +// See https://techdocs.akamai.com/linode-api/reference/post-add-linode-config +type InstanceConfig struct { + // The label for this configuration profile. + Label string `mapstructure:"label" required:"true"` + + // Whether to boot the Linode with this configuration profile. + // Only one configuration profile can have this set to true. + // If not specified, the first configuration profile will be used for booting. + Booted bool `mapstructure:"booted" required:"false"` + + // Optional comments about this configuration profile. + Comments string `mapstructure:"comments" required:"false"` + + // Device assignments for this configuration profile. + Devices *InstanceConfigDevices `mapstructure:"devices" required:"true"` + + // Helper options for this configuration profile. + Helpers *InstanceConfigHelpers `mapstructure:"helpers" required:"false"` + + // Legacy config interfaces for this configuration profile. + // Conflicts with the top-level interface and linode_interface blocks. + Interfaces []Interface `mapstructure:"interface" required:"false"` + + // Limits the amount of RAM the Linode can use. 0 (default) means no limit. + MemoryLimit int `mapstructure:"memory_limit" required:"false"` + + // The kernel to boot with. Use "linode/latest-64bit" or "linode/grub2". + // See https://api.linode.com/v4/linode/kernels for available kernels. + Kernel string `mapstructure:"kernel" required:"false"` + + // The init RAM disk to use. This is optional and typically not needed. + InitRD int `mapstructure:"init_rd" required:"false"` + + // The root device to boot from, e.g., "/dev/sda". When using custom disks, + // the disk at this device slot in the booted configuration profile will be imaged. + RootDevice string `mapstructure:"root_device" required:"false"` + + // The run level to boot into. Valid values are "default", "single", "binbash". + RunLevel string `mapstructure:"run_level" required:"false"` + + // The virtualization mode. Valid values are "paravirt" or "fullvirt". + VirtMode string `mapstructure:"virt_mode" required:"false"` +} + type VPCInterfaceAttributes struct { // The ID of the VPC Subnet this interface references. SubnetID *int `mapstructure:"subnet_id"` @@ -103,9 +283,9 @@ type Config struct { // An Image ID to deploy the Disk from. Official Linode Images start with `linode/`, // while user Images start with `private/`. See [images](https://api.linode.com/v4/images) - // for more information on the Images available for use. Examples are `linode/debian9`, - // `linode/fedora28`, `linode/ubuntu18.04`, `linode/arch`, and `private/12345`. - Image string `mapstructure:"image" required:"true"` + // for more information on the Images available for use. Examples are `linode/debian12`, + // `linode/debian13`, `linode/ubuntu24.04`, `linode/arch`, and `private/12345`. + Image string `mapstructure:"image" required:"false"` // The disk size (MiB) allocated for swap space. SwapSize int `mapstructure:"swap_size" required:"false"` @@ -167,6 +347,217 @@ type Config struct { // `legacy_config` or `linode`. The default value is determined by the // `interfaces_for_new_linodes` setting in the account settings. InterfaceGeneration string `mapstructure:"interface_generation" required:"false"` + + // Custom disks to create for this Linode. When specified, you are responsible + // for creating all disks including the boot disk. See the `disk` block + // documentation for available options. + Disks []Disk `mapstructure:"disk" required:"false"` + + // Custom configuration profiles to create for this Linode. When specified, + // you are responsible for creating all configuration profiles. + // See the `config` block documentation for available options. + InstanceConfigs []InstanceConfig `mapstructure:"config" required:"false"` +} + +// parseRootDevice extracts the device slot name from a root_device path. +// e.g., "/dev/sda" -> "sda", "/dev/sdab" -> "sdab" +func parseRootDevice(rootDevice string) string { + rootDevice = strings.TrimSpace(rootDevice) + if strings.HasPrefix(rootDevice, "/dev/") { + return strings.TrimPrefix(rootDevice, "/dev/") + } + return rootDevice +} + +// getBootConfig returns the configuration profile that will be booted. +// Returns the first config with booted=true, or the first config if none have booted=true. +// Returns nil if there are no configs. +func (c *Config) getBootConfig() *InstanceConfig { + if len(c.InstanceConfigs) == 0 { + return nil + } + + for i := range c.InstanceConfigs { + if c.InstanceConfigs[i].Booted { + return &c.InstanceConfigs[i] + } + } + return &c.InstanceConfigs[0] +} + +// getDeviceAtSlot returns the device configuration at the given slot name. +// Returns nil if the slot is empty or not found. +func (d *InstanceConfigDevices) getDeviceAtSlot(slot string) *InstanceConfigDevice { + if d == nil { + return nil + } + + slot = strings.ToLower(slot) + switch slot { + case "sda": + return d.SDA + case "sdb": + return d.SDB + case "sdc": + return d.SDC + case "sdd": + return d.SDD + case "sde": + return d.SDE + case "sdf": + return d.SDF + case "sdg": + return d.SDG + case "sdh": + return d.SDH + case "sdi": + return d.SDI + case "sdj": + return d.SDJ + case "sdk": + return d.SDK + case "sdl": + return d.SDL + case "sdm": + return d.SDM + case "sdn": + return d.SDN + case "sdo": + return d.SDO + case "sdp": + return d.SDP + case "sdq": + return d.SDQ + case "sdr": + return d.SDR + case "sds": + return d.SDS + case "sdt": + return d.SDT + case "sdu": + return d.SDU + case "sdv": + return d.SDV + case "sdw": + return d.SDW + case "sdx": + return d.SDX + case "sdy": + return d.SDY + case "sdz": + return d.SDZ + case "sdaa": + return d.SDAA + case "sdab": + return d.SDAB + case "sdac": + return d.SDAC + case "sdad": + return d.SDAD + case "sdae": + return d.SDAE + case "sdaf": + return d.SDAF + case "sdag": + return d.SDAG + case "sdah": + return d.SDAH + case "sdai": + return d.SDAI + case "sdaj": + return d.SDAJ + case "sdak": + return d.SDAK + case "sdal": + return d.SDAL + case "sdam": + return d.SDAM + case "sdan": + return d.SDAN + case "sdao": + return d.SDAO + case "sdap": + return d.SDAP + case "sdaq": + return d.SDAQ + case "sdar": + return d.SDAR + case "sdas": + return d.SDAS + case "sdat": + return d.SDAT + case "sdau": + return d.SDAU + case "sdav": + return d.SDAV + case "sdaw": + return d.SDAW + case "sdax": + return d.SDAX + case "sday": + return d.SDAY + case "sdaz": + return d.SDAZ + case "sdba": + return d.SDBA + case "sdbb": + return d.SDBB + case "sdbc": + return d.SDBC + case "sdbd": + return d.SDBD + case "sdbe": + return d.SDBE + case "sdbf": + return d.SDBF + case "sdbg": + return d.SDBG + case "sdbh": + return d.SDBH + case "sdbi": + return d.SDBI + case "sdbj": + return d.SDBJ + case "sdbk": + return d.SDBK + case "sdbl": + return d.SDBL + default: + return nil + } +} + +// getBootDiskLabel returns the disk label of the boot disk (the disk that will be imaged). +// It derives this from the booted config's root_device setting. +// Returns an error if the root_device is not set, doesn't point to a valid device, +// or the device is a volume instead of a disk. +func (c *Config) getBootDiskLabel() (string, error) { + bootConfig := c.getBootConfig() + if bootConfig == nil { + return "", errors.New("no configuration profile found") + } + + if bootConfig.RootDevice == "" { + return "", fmt.Errorf("root_device is required in the boot configuration profile %q when using custom disks", bootConfig.Label) + } + + slot := parseRootDevice(bootConfig.RootDevice) + device := bootConfig.Devices.getDeviceAtSlot(slot) + if device == nil { + return "", fmt.Errorf("root_device %q points to device slot %q which has no disk or volume assigned in config %q", + bootConfig.RootDevice, slot, bootConfig.Label) + } + + if device.DiskLabel == "" { + if device.VolumeID != 0 { + return "", fmt.Errorf("root_device %q points to a volume, not a disk; images can only be created from disks", + bootConfig.RootDevice) + } + return "", fmt.Errorf("root_device %q points to device slot %q which has no disk_label set", + bootConfig.RootDevice, slot) + } + + return device.DiskLabel, nil } func createRandomRootPassword() (string, error) { @@ -209,7 +600,7 @@ func (c *Config) Prepare(raws ...any) ([]string, error) { if def, err := interpolate.Render("packer-{{timestamp}}", nil); err == nil { c.ImageLabel = def } else { - errs = packersdk.MultiErrorAppend(errs, fmt.Errorf("Unable to render image name: %s", err)) + errs = packersdk.MultiErrorAppend(errs, fmt.Errorf("unable to render image name: %s", err)) } } @@ -218,7 +609,7 @@ func (c *Config) Prepare(raws ...any) ([]string, error) { if def, err := interpolate.Render("packer-{{timestamp}}", nil); err == nil { c.Label = def } else { - errs = packersdk.MultiErrorAppend(errs, fmt.Errorf("Unable to render Linode label: %s", err)) + errs = packersdk.MultiErrorAppend(errs, fmt.Errorf("unable to render Linode label: %s", err)) } } @@ -226,7 +617,7 @@ func (c *Config) Prepare(raws ...any) ([]string, error) { var err error c.RootPass, err = createRandomRootPassword() if err != nil { - errs = packersdk.MultiErrorAppend(errs, fmt.Errorf("Unable to generate root_pass: %s", err)) + errs = packersdk.MultiErrorAppend(errs, fmt.Errorf("unable to generate root_pass: %s", err)) } } @@ -262,9 +653,92 @@ func (c *Config) Prepare(raws ...any) ([]string, error) { errs, errors.New("instance_type is required")) } - if c.Image == "" { + if c.Image == "" && len(c.Disks) == 0 { + errs = packersdk.MultiErrorAppend( + errs, errors.New("either image or custom disks must be specified")) + } + + if c.Image != "" && len(c.Disks) > 0 { + errs = packersdk.MultiErrorAppend( + errs, errors.New("cannot specify both image and custom disks")) + } + + // Disks and configs must be specified together - both or neither + if (len(c.InstanceConfigs) == 0) != (len(c.Disks) == 0) { errs = packersdk.MultiErrorAppend( - errs, errors.New("image is required")) + errs, errors.New("disk and config blocks must be specified together")) + } + + // Set default root_device for configs that don't have one + for i := range c.InstanceConfigs { + if c.InstanceConfigs[i].RootDevice == "" { + c.InstanceConfigs[i].RootDevice = "/dev/sda" + } + } + + if len(c.Disks) > 0 { + // Validate disk labels are unique + diskLabels := make(map[string]bool) + for _, disk := range c.Disks { + if disk.Label == "" { + errs = packersdk.MultiErrorAppend( + errs, errors.New("disk label cannot be empty")) + } else if diskLabels[disk.Label] { + errs = packersdk.MultiErrorAppend( + errs, fmt.Errorf("duplicate disk label %q found", disk.Label)) + } else { + diskLabels[disk.Label] = true + } + } + + // Validate that the boot config's root_device points to a valid disk + bootDiskLabel, err := c.getBootDiskLabel() + if err != nil { + errs = packersdk.MultiErrorAppend(errs, err) + } else { + // Validate that the boot disk label exists in the disk blocks + found := false + for _, disk := range c.Disks { + if disk.Label == bootDiskLabel { + found = true + break + } + } + if !found { + errs = packersdk.MultiErrorAppend( + errs, fmt.Errorf("root_device points to disk %q which is not defined in the disk blocks", bootDiskLabel)) + } + } + + if len(c.AuthorizedKeys) > 0 { + errs = packersdk.MultiErrorAppend( + errs, errors.New("authorized_keys cannot be specified when using custom disks (specify in disk blocks instead)")) + } + + if len(c.AuthorizedUsers) > 0 { + errs = packersdk.MultiErrorAppend( + errs, errors.New("authorized_users cannot be specified when using custom disks (specify in disk blocks instead)")) + } + + if c.SwapSize > 0 { + errs = packersdk.MultiErrorAppend( + errs, errors.New("swap_size cannot be specified when using custom disks (create a swap disk instead)")) + } + + if c.StackScriptID > 0 { + errs = packersdk.MultiErrorAppend( + errs, errors.New("stackscript_id cannot be specified when using custom disks (specify in disk blocks instead)")) + } + + if len(c.StackScriptData) > 0 { + errs = packersdk.MultiErrorAppend( + errs, errors.New("stackscript_data cannot be specified when using custom disks (specify in disk blocks instead)")) + } + + if len(c.Interfaces) > 0 { + errs = packersdk.MultiErrorAppend( + errs, errors.New("interface blocks cannot be specified when using custom disks (specify in config blocks instead)")) + } } if c.Tags == nil { diff --git a/builder/linode/config.hcl2spec.go b/builder/linode/config.hcl2spec.go index 7dd745c6..0db49e03 100644 --- a/builder/linode/config.hcl2spec.go +++ b/builder/linode/config.hcl2spec.go @@ -77,7 +77,7 @@ type FlatConfig struct { InstanceType *string `mapstructure:"instance_type" required:"true" cty:"instance_type" hcl:"instance_type"` Label *string `mapstructure:"instance_label" required:"false" cty:"instance_label" hcl:"instance_label"` Tags []string `mapstructure:"instance_tags" required:"false" cty:"instance_tags" hcl:"instance_tags"` - Image *string `mapstructure:"image" required:"true" cty:"image" hcl:"image"` + Image *string `mapstructure:"image" required:"false" cty:"image" hcl:"image"` SwapSize *int `mapstructure:"swap_size" required:"false" cty:"swap_size" hcl:"swap_size"` PrivateIP *bool `mapstructure:"private_ip" required:"false" cty:"private_ip" hcl:"private_ip"` RootPass *string `mapstructure:"root_pass" required:"false" cty:"root_pass" hcl:"root_pass"` @@ -93,6 +93,8 @@ type FlatConfig struct { ImageRegions []string `mapstructure:"image_regions" required:"false" cty:"image_regions" hcl:"image_regions"` ImageShareGroupIDs []int `mapstructure:"image_share_group_ids" required:"false" cty:"image_share_group_ids" hcl:"image_share_group_ids"` InterfaceGeneration *string `mapstructure:"interface_generation" required:"false" cty:"interface_generation" hcl:"interface_generation"` + Disks []FlatDisk `mapstructure:"disk" required:"false" cty:"disk" hcl:"disk"` + InstanceConfigs []FlatInstanceConfig `mapstructure:"config" required:"false" cty:"config" hcl:"config"` } // FlatMapstructure returns a new FlatConfig. @@ -190,6 +192,295 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { "image_regions": &hcldec.AttrSpec{Name: "image_regions", Type: cty.List(cty.String), Required: false}, "image_share_group_ids": &hcldec.AttrSpec{Name: "image_share_group_ids", Type: cty.List(cty.Number), Required: false}, "interface_generation": &hcldec.AttrSpec{Name: "interface_generation", Type: cty.String, Required: false}, + "disk": &hcldec.BlockListSpec{TypeName: "disk", Nested: hcldec.ObjectSpec((*FlatDisk)(nil).HCL2Spec())}, + "config": &hcldec.BlockListSpec{TypeName: "config", Nested: hcldec.ObjectSpec((*FlatInstanceConfig)(nil).HCL2Spec())}, + } + return s +} + +// FlatDisk is an auto-generated flat version of Disk. +// Where the contents of a field with a `mapstructure:,squash` tag are bubbled up. +type FlatDisk struct { + Label *string `mapstructure:"label" required:"true" cty:"label" hcl:"label"` + Size *int `mapstructure:"size" required:"true" cty:"size" hcl:"size"` + Image *string `mapstructure:"image" required:"false" cty:"image" hcl:"image"` + Filesystem *string `mapstructure:"filesystem" required:"false" cty:"filesystem" hcl:"filesystem"` + AuthorizedKeys []string `mapstructure:"authorized_keys" required:"false" cty:"authorized_keys" hcl:"authorized_keys"` + AuthorizedUsers []string `mapstructure:"authorized_users" required:"false" cty:"authorized_users" hcl:"authorized_users"` + StackscriptID *int `mapstructure:"stackscript_id" required:"false" cty:"stackscript_id" hcl:"stackscript_id"` + StackscriptData map[string]string `mapstructure:"stackscript_data" required:"false" cty:"stackscript_data" hcl:"stackscript_data"` +} + +// FlatMapstructure returns a new FlatDisk. +// FlatDisk is an auto-generated flat version of Disk. +// Where the contents a fields with a `mapstructure:,squash` tag are bubbled up. +func (*Disk) FlatMapstructure() interface{ HCL2Spec() map[string]hcldec.Spec } { + return new(FlatDisk) +} + +// HCL2Spec returns the hcl spec of a Disk. +// This spec is used by HCL to read the fields of Disk. +// The decoded values from this spec will then be applied to a FlatDisk. +func (*FlatDisk) HCL2Spec() map[string]hcldec.Spec { + s := map[string]hcldec.Spec{ + "label": &hcldec.AttrSpec{Name: "label", Type: cty.String, Required: false}, + "size": &hcldec.AttrSpec{Name: "size", Type: cty.Number, Required: false}, + "image": &hcldec.AttrSpec{Name: "image", Type: cty.String, Required: false}, + "filesystem": &hcldec.AttrSpec{Name: "filesystem", Type: cty.String, Required: false}, + "authorized_keys": &hcldec.AttrSpec{Name: "authorized_keys", Type: cty.List(cty.String), Required: false}, + "authorized_users": &hcldec.AttrSpec{Name: "authorized_users", Type: cty.List(cty.String), Required: false}, + "stackscript_id": &hcldec.AttrSpec{Name: "stackscript_id", Type: cty.Number, Required: false}, + "stackscript_data": &hcldec.AttrSpec{Name: "stackscript_data", Type: cty.Map(cty.String), Required: false}, + } + return s +} + +// FlatInstanceConfig is an auto-generated flat version of InstanceConfig. +// Where the contents of a field with a `mapstructure:,squash` tag are bubbled up. +type FlatInstanceConfig struct { + Label *string `mapstructure:"label" required:"true" cty:"label" hcl:"label"` + Booted *bool `mapstructure:"booted" required:"false" cty:"booted" hcl:"booted"` + Comments *string `mapstructure:"comments" required:"false" cty:"comments" hcl:"comments"` + Devices *FlatInstanceConfigDevices `mapstructure:"devices" required:"true" cty:"devices" hcl:"devices"` + Helpers *FlatInstanceConfigHelpers `mapstructure:"helpers" required:"false" cty:"helpers" hcl:"helpers"` + Interfaces []FlatInterface `mapstructure:"interface" required:"false" cty:"interface" hcl:"interface"` + MemoryLimit *int `mapstructure:"memory_limit" required:"false" cty:"memory_limit" hcl:"memory_limit"` + Kernel *string `mapstructure:"kernel" required:"false" cty:"kernel" hcl:"kernel"` + InitRD *int `mapstructure:"init_rd" required:"false" cty:"init_rd" hcl:"init_rd"` + RootDevice *string `mapstructure:"root_device" required:"false" cty:"root_device" hcl:"root_device"` + RunLevel *string `mapstructure:"run_level" required:"false" cty:"run_level" hcl:"run_level"` + VirtMode *string `mapstructure:"virt_mode" required:"false" cty:"virt_mode" hcl:"virt_mode"` +} + +// FlatMapstructure returns a new FlatInstanceConfig. +// FlatInstanceConfig is an auto-generated flat version of InstanceConfig. +// Where the contents a fields with a `mapstructure:,squash` tag are bubbled up. +func (*InstanceConfig) FlatMapstructure() interface{ HCL2Spec() map[string]hcldec.Spec } { + return new(FlatInstanceConfig) +} + +// HCL2Spec returns the hcl spec of a InstanceConfig. +// This spec is used by HCL to read the fields of InstanceConfig. +// The decoded values from this spec will then be applied to a FlatInstanceConfig. +func (*FlatInstanceConfig) HCL2Spec() map[string]hcldec.Spec { + s := map[string]hcldec.Spec{ + "label": &hcldec.AttrSpec{Name: "label", Type: cty.String, Required: false}, + "booted": &hcldec.AttrSpec{Name: "booted", Type: cty.Bool, Required: false}, + "comments": &hcldec.AttrSpec{Name: "comments", Type: cty.String, Required: false}, + "devices": &hcldec.BlockSpec{TypeName: "devices", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevices)(nil).HCL2Spec())}, + "helpers": &hcldec.BlockSpec{TypeName: "helpers", Nested: hcldec.ObjectSpec((*FlatInstanceConfigHelpers)(nil).HCL2Spec())}, + "interface": &hcldec.BlockListSpec{TypeName: "interface", Nested: hcldec.ObjectSpec((*FlatInterface)(nil).HCL2Spec())}, + "memory_limit": &hcldec.AttrSpec{Name: "memory_limit", Type: cty.Number, Required: false}, + "kernel": &hcldec.AttrSpec{Name: "kernel", Type: cty.String, Required: false}, + "init_rd": &hcldec.AttrSpec{Name: "init_rd", Type: cty.Number, Required: false}, + "root_device": &hcldec.AttrSpec{Name: "root_device", Type: cty.String, Required: false}, + "run_level": &hcldec.AttrSpec{Name: "run_level", Type: cty.String, Required: false}, + "virt_mode": &hcldec.AttrSpec{Name: "virt_mode", Type: cty.String, Required: false}, + } + return s +} + +// FlatInstanceConfigDevice is an auto-generated flat version of InstanceConfigDevice. +// Where the contents of a field with a `mapstructure:,squash` tag are bubbled up. +type FlatInstanceConfigDevice struct { + DiskLabel *string `mapstructure:"disk_label" required:"false" cty:"disk_label" hcl:"disk_label"` + VolumeID *int `mapstructure:"volume_id" required:"false" cty:"volume_id" hcl:"volume_id"` +} + +// FlatMapstructure returns a new FlatInstanceConfigDevice. +// FlatInstanceConfigDevice is an auto-generated flat version of InstanceConfigDevice. +// Where the contents a fields with a `mapstructure:,squash` tag are bubbled up. +func (*InstanceConfigDevice) FlatMapstructure() interface{ HCL2Spec() map[string]hcldec.Spec } { + return new(FlatInstanceConfigDevice) +} + +// HCL2Spec returns the hcl spec of a InstanceConfigDevice. +// This spec is used by HCL to read the fields of InstanceConfigDevice. +// The decoded values from this spec will then be applied to a FlatInstanceConfigDevice. +func (*FlatInstanceConfigDevice) HCL2Spec() map[string]hcldec.Spec { + s := map[string]hcldec.Spec{ + "disk_label": &hcldec.AttrSpec{Name: "disk_label", Type: cty.String, Required: false}, + "volume_id": &hcldec.AttrSpec{Name: "volume_id", Type: cty.Number, Required: false}, + } + return s +} + +// FlatInstanceConfigDevices is an auto-generated flat version of InstanceConfigDevices. +// Where the contents of a field with a `mapstructure:,squash` tag are bubbled up. +type FlatInstanceConfigDevices struct { + SDA *FlatInstanceConfigDevice `mapstructure:"sda" required:"false" cty:"sda" hcl:"sda"` + SDB *FlatInstanceConfigDevice `mapstructure:"sdb" required:"false" cty:"sdb" hcl:"sdb"` + SDC *FlatInstanceConfigDevice `mapstructure:"sdc" required:"false" cty:"sdc" hcl:"sdc"` + SDD *FlatInstanceConfigDevice `mapstructure:"sdd" required:"false" cty:"sdd" hcl:"sdd"` + SDE *FlatInstanceConfigDevice `mapstructure:"sde" required:"false" cty:"sde" hcl:"sde"` + SDF *FlatInstanceConfigDevice `mapstructure:"sdf" required:"false" cty:"sdf" hcl:"sdf"` + SDG *FlatInstanceConfigDevice `mapstructure:"sdg" required:"false" cty:"sdg" hcl:"sdg"` + SDH *FlatInstanceConfigDevice `mapstructure:"sdh" required:"false" cty:"sdh" hcl:"sdh"` + SDI *FlatInstanceConfigDevice `mapstructure:"sdi" required:"false" cty:"sdi" hcl:"sdi"` + SDJ *FlatInstanceConfigDevice `mapstructure:"sdj" required:"false" cty:"sdj" hcl:"sdj"` + SDK *FlatInstanceConfigDevice `mapstructure:"sdk" required:"false" cty:"sdk" hcl:"sdk"` + SDL *FlatInstanceConfigDevice `mapstructure:"sdl" required:"false" cty:"sdl" hcl:"sdl"` + SDM *FlatInstanceConfigDevice `mapstructure:"sdm" required:"false" cty:"sdm" hcl:"sdm"` + SDN *FlatInstanceConfigDevice `mapstructure:"sdn" required:"false" cty:"sdn" hcl:"sdn"` + SDO *FlatInstanceConfigDevice `mapstructure:"sdo" required:"false" cty:"sdo" hcl:"sdo"` + SDP *FlatInstanceConfigDevice `mapstructure:"sdp" required:"false" cty:"sdp" hcl:"sdp"` + SDQ *FlatInstanceConfigDevice `mapstructure:"sdq" required:"false" cty:"sdq" hcl:"sdq"` + SDR *FlatInstanceConfigDevice `mapstructure:"sdr" required:"false" cty:"sdr" hcl:"sdr"` + SDS *FlatInstanceConfigDevice `mapstructure:"sds" required:"false" cty:"sds" hcl:"sds"` + SDT *FlatInstanceConfigDevice `mapstructure:"sdt" required:"false" cty:"sdt" hcl:"sdt"` + SDU *FlatInstanceConfigDevice `mapstructure:"sdu" required:"false" cty:"sdu" hcl:"sdu"` + SDV *FlatInstanceConfigDevice `mapstructure:"sdv" required:"false" cty:"sdv" hcl:"sdv"` + SDW *FlatInstanceConfigDevice `mapstructure:"sdw" required:"false" cty:"sdw" hcl:"sdw"` + SDX *FlatInstanceConfigDevice `mapstructure:"sdx" required:"false" cty:"sdx" hcl:"sdx"` + SDY *FlatInstanceConfigDevice `mapstructure:"sdy" required:"false" cty:"sdy" hcl:"sdy"` + SDZ *FlatInstanceConfigDevice `mapstructure:"sdz" required:"false" cty:"sdz" hcl:"sdz"` + SDAA *FlatInstanceConfigDevice `mapstructure:"sdaa" required:"false" cty:"sdaa" hcl:"sdaa"` + SDAB *FlatInstanceConfigDevice `mapstructure:"sdab" required:"false" cty:"sdab" hcl:"sdab"` + SDAC *FlatInstanceConfigDevice `mapstructure:"sdac" required:"false" cty:"sdac" hcl:"sdac"` + SDAD *FlatInstanceConfigDevice `mapstructure:"sdad" required:"false" cty:"sdad" hcl:"sdad"` + SDAE *FlatInstanceConfigDevice `mapstructure:"sdae" required:"false" cty:"sdae" hcl:"sdae"` + SDAF *FlatInstanceConfigDevice `mapstructure:"sdaf" required:"false" cty:"sdaf" hcl:"sdaf"` + SDAG *FlatInstanceConfigDevice `mapstructure:"sdag" required:"false" cty:"sdag" hcl:"sdag"` + SDAH *FlatInstanceConfigDevice `mapstructure:"sdah" required:"false" cty:"sdah" hcl:"sdah"` + SDAI *FlatInstanceConfigDevice `mapstructure:"sdai" required:"false" cty:"sdai" hcl:"sdai"` + SDAJ *FlatInstanceConfigDevice `mapstructure:"sdaj" required:"false" cty:"sdaj" hcl:"sdaj"` + SDAK *FlatInstanceConfigDevice `mapstructure:"sdak" required:"false" cty:"sdak" hcl:"sdak"` + SDAL *FlatInstanceConfigDevice `mapstructure:"sdal" required:"false" cty:"sdal" hcl:"sdal"` + SDAM *FlatInstanceConfigDevice `mapstructure:"sdam" required:"false" cty:"sdam" hcl:"sdam"` + SDAN *FlatInstanceConfigDevice `mapstructure:"sdan" required:"false" cty:"sdan" hcl:"sdan"` + SDAO *FlatInstanceConfigDevice `mapstructure:"sdao" required:"false" cty:"sdao" hcl:"sdao"` + SDAP *FlatInstanceConfigDevice `mapstructure:"sdap" required:"false" cty:"sdap" hcl:"sdap"` + SDAQ *FlatInstanceConfigDevice `mapstructure:"sdaq" required:"false" cty:"sdaq" hcl:"sdaq"` + SDAR *FlatInstanceConfigDevice `mapstructure:"sdar" required:"false" cty:"sdar" hcl:"sdar"` + SDAS *FlatInstanceConfigDevice `mapstructure:"sdas" required:"false" cty:"sdas" hcl:"sdas"` + SDAT *FlatInstanceConfigDevice `mapstructure:"sdat" required:"false" cty:"sdat" hcl:"sdat"` + SDAU *FlatInstanceConfigDevice `mapstructure:"sdau" required:"false" cty:"sdau" hcl:"sdau"` + SDAV *FlatInstanceConfigDevice `mapstructure:"sdav" required:"false" cty:"sdav" hcl:"sdav"` + SDAW *FlatInstanceConfigDevice `mapstructure:"sdaw" required:"false" cty:"sdaw" hcl:"sdaw"` + SDAX *FlatInstanceConfigDevice `mapstructure:"sdax" required:"false" cty:"sdax" hcl:"sdax"` + SDAY *FlatInstanceConfigDevice `mapstructure:"sday" required:"false" cty:"sday" hcl:"sday"` + SDAZ *FlatInstanceConfigDevice `mapstructure:"sdaz" required:"false" cty:"sdaz" hcl:"sdaz"` + SDBA *FlatInstanceConfigDevice `mapstructure:"sdba" required:"false" cty:"sdba" hcl:"sdba"` + SDBB *FlatInstanceConfigDevice `mapstructure:"sdbb" required:"false" cty:"sdbb" hcl:"sdbb"` + SDBC *FlatInstanceConfigDevice `mapstructure:"sdbc" required:"false" cty:"sdbc" hcl:"sdbc"` + SDBD *FlatInstanceConfigDevice `mapstructure:"sdbd" required:"false" cty:"sdbd" hcl:"sdbd"` + SDBE *FlatInstanceConfigDevice `mapstructure:"sdbe" required:"false" cty:"sdbe" hcl:"sdbe"` + SDBF *FlatInstanceConfigDevice `mapstructure:"sdbf" required:"false" cty:"sdbf" hcl:"sdbf"` + SDBG *FlatInstanceConfigDevice `mapstructure:"sdbg" required:"false" cty:"sdbg" hcl:"sdbg"` + SDBH *FlatInstanceConfigDevice `mapstructure:"sdbh" required:"false" cty:"sdbh" hcl:"sdbh"` + SDBI *FlatInstanceConfigDevice `mapstructure:"sdbi" required:"false" cty:"sdbi" hcl:"sdbi"` + SDBJ *FlatInstanceConfigDevice `mapstructure:"sdbj" required:"false" cty:"sdbj" hcl:"sdbj"` + SDBK *FlatInstanceConfigDevice `mapstructure:"sdbk" required:"false" cty:"sdbk" hcl:"sdbk"` + SDBL *FlatInstanceConfigDevice `mapstructure:"sdbl" required:"false" cty:"sdbl" hcl:"sdbl"` +} + +// FlatMapstructure returns a new FlatInstanceConfigDevices. +// FlatInstanceConfigDevices is an auto-generated flat version of InstanceConfigDevices. +// Where the contents a fields with a `mapstructure:,squash` tag are bubbled up. +func (*InstanceConfigDevices) FlatMapstructure() interface{ HCL2Spec() map[string]hcldec.Spec } { + return new(FlatInstanceConfigDevices) +} + +// HCL2Spec returns the hcl spec of a InstanceConfigDevices. +// This spec is used by HCL to read the fields of InstanceConfigDevices. +// The decoded values from this spec will then be applied to a FlatInstanceConfigDevices. +func (*FlatInstanceConfigDevices) HCL2Spec() map[string]hcldec.Spec { + s := map[string]hcldec.Spec{ + "sda": &hcldec.BlockSpec{TypeName: "sda", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdb": &hcldec.BlockSpec{TypeName: "sdb", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdc": &hcldec.BlockSpec{TypeName: "sdc", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdd": &hcldec.BlockSpec{TypeName: "sdd", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sde": &hcldec.BlockSpec{TypeName: "sde", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdf": &hcldec.BlockSpec{TypeName: "sdf", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdg": &hcldec.BlockSpec{TypeName: "sdg", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdh": &hcldec.BlockSpec{TypeName: "sdh", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdi": &hcldec.BlockSpec{TypeName: "sdi", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdj": &hcldec.BlockSpec{TypeName: "sdj", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdk": &hcldec.BlockSpec{TypeName: "sdk", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdl": &hcldec.BlockSpec{TypeName: "sdl", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdm": &hcldec.BlockSpec{TypeName: "sdm", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdn": &hcldec.BlockSpec{TypeName: "sdn", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdo": &hcldec.BlockSpec{TypeName: "sdo", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdp": &hcldec.BlockSpec{TypeName: "sdp", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdq": &hcldec.BlockSpec{TypeName: "sdq", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdr": &hcldec.BlockSpec{TypeName: "sdr", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sds": &hcldec.BlockSpec{TypeName: "sds", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdt": &hcldec.BlockSpec{TypeName: "sdt", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdu": &hcldec.BlockSpec{TypeName: "sdu", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdv": &hcldec.BlockSpec{TypeName: "sdv", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdw": &hcldec.BlockSpec{TypeName: "sdw", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdx": &hcldec.BlockSpec{TypeName: "sdx", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdy": &hcldec.BlockSpec{TypeName: "sdy", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdz": &hcldec.BlockSpec{TypeName: "sdz", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdaa": &hcldec.BlockSpec{TypeName: "sdaa", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdab": &hcldec.BlockSpec{TypeName: "sdab", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdac": &hcldec.BlockSpec{TypeName: "sdac", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdad": &hcldec.BlockSpec{TypeName: "sdad", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdae": &hcldec.BlockSpec{TypeName: "sdae", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdaf": &hcldec.BlockSpec{TypeName: "sdaf", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdag": &hcldec.BlockSpec{TypeName: "sdag", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdah": &hcldec.BlockSpec{TypeName: "sdah", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdai": &hcldec.BlockSpec{TypeName: "sdai", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdaj": &hcldec.BlockSpec{TypeName: "sdaj", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdak": &hcldec.BlockSpec{TypeName: "sdak", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdal": &hcldec.BlockSpec{TypeName: "sdal", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdam": &hcldec.BlockSpec{TypeName: "sdam", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdan": &hcldec.BlockSpec{TypeName: "sdan", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdao": &hcldec.BlockSpec{TypeName: "sdao", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdap": &hcldec.BlockSpec{TypeName: "sdap", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdaq": &hcldec.BlockSpec{TypeName: "sdaq", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdar": &hcldec.BlockSpec{TypeName: "sdar", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdas": &hcldec.BlockSpec{TypeName: "sdas", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdat": &hcldec.BlockSpec{TypeName: "sdat", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdau": &hcldec.BlockSpec{TypeName: "sdau", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdav": &hcldec.BlockSpec{TypeName: "sdav", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdaw": &hcldec.BlockSpec{TypeName: "sdaw", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdax": &hcldec.BlockSpec{TypeName: "sdax", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sday": &hcldec.BlockSpec{TypeName: "sday", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdaz": &hcldec.BlockSpec{TypeName: "sdaz", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdba": &hcldec.BlockSpec{TypeName: "sdba", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdbb": &hcldec.BlockSpec{TypeName: "sdbb", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdbc": &hcldec.BlockSpec{TypeName: "sdbc", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdbd": &hcldec.BlockSpec{TypeName: "sdbd", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdbe": &hcldec.BlockSpec{TypeName: "sdbe", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdbf": &hcldec.BlockSpec{TypeName: "sdbf", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdbg": &hcldec.BlockSpec{TypeName: "sdbg", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdbh": &hcldec.BlockSpec{TypeName: "sdbh", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdbi": &hcldec.BlockSpec{TypeName: "sdbi", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdbj": &hcldec.BlockSpec{TypeName: "sdbj", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdbk": &hcldec.BlockSpec{TypeName: "sdbk", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + "sdbl": &hcldec.BlockSpec{TypeName: "sdbl", Nested: hcldec.ObjectSpec((*FlatInstanceConfigDevice)(nil).HCL2Spec())}, + } + return s +} + +// FlatInstanceConfigHelpers is an auto-generated flat version of InstanceConfigHelpers. +// Where the contents of a field with a `mapstructure:,squash` tag are bubbled up. +type FlatInstanceConfigHelpers struct { + UpdateDBDisabled *bool `mapstructure:"updatedb_disabled" required:"false" cty:"updatedb_disabled" hcl:"updatedb_disabled"` + Distro *bool `mapstructure:"distro" required:"false" cty:"distro" hcl:"distro"` + ModulesDep *bool `mapstructure:"modules_dep" required:"false" cty:"modules_dep" hcl:"modules_dep"` + Network *bool `mapstructure:"network" required:"false" cty:"network" hcl:"network"` + DevTmpFsAutomount *bool `mapstructure:"devtmpfs_automount" required:"false" cty:"devtmpfs_automount" hcl:"devtmpfs_automount"` +} + +// FlatMapstructure returns a new FlatInstanceConfigHelpers. +// FlatInstanceConfigHelpers is an auto-generated flat version of InstanceConfigHelpers. +// Where the contents a fields with a `mapstructure:,squash` tag are bubbled up. +func (*InstanceConfigHelpers) FlatMapstructure() interface{ HCL2Spec() map[string]hcldec.Spec } { + return new(FlatInstanceConfigHelpers) +} + +// HCL2Spec returns the hcl spec of a InstanceConfigHelpers. +// This spec is used by HCL to read the fields of InstanceConfigHelpers. +// The decoded values from this spec will then be applied to a FlatInstanceConfigHelpers. +func (*FlatInstanceConfigHelpers) HCL2Spec() map[string]hcldec.Spec { + s := map[string]hcldec.Spec{ + "updatedb_disabled": &hcldec.AttrSpec{Name: "updatedb_disabled", Type: cty.Bool, Required: false}, + "distro": &hcldec.AttrSpec{Name: "distro", Type: cty.Bool, Required: false}, + "modules_dep": &hcldec.AttrSpec{Name: "modules_dep", Type: cty.Bool, Required: false}, + "network": &hcldec.AttrSpec{Name: "network", Type: cty.Bool, Required: false}, + "devtmpfs_automount": &hcldec.AttrSpec{Name: "devtmpfs_automount", Type: cty.Bool, Required: false}, } return s } diff --git a/builder/linode/step_create_disk_config.go b/builder/linode/step_create_disk_config.go new file mode 100644 index 00000000..8dd2de21 --- /dev/null +++ b/builder/linode/step_create_disk_config.go @@ -0,0 +1,396 @@ +package linode + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/hashicorp/packer-plugin-sdk/multistep" + packersdk "github.com/hashicorp/packer-plugin-sdk/packer" + "github.com/linode/linodego" + "github.com/linode/packer-plugin-linode/helper" +) + +// stepCreateDiskConfig creates custom disks and configuration profiles for a Linode instance. +// This step runs after the instance is created without an image (when custom disks/configs are specified). +type stepCreateDiskConfig struct { + client *linodego.Client +} + +func flattenDisk(d Disk) linodego.InstanceDiskCreateOptions { + return linodego.InstanceDiskCreateOptions{ + Label: d.Label, + Size: d.Size, + Image: d.Image, + Filesystem: d.Filesystem, + AuthorizedKeys: d.AuthorizedKeys, + AuthorizedUsers: d.AuthorizedUsers, + StackscriptID: d.StackscriptID, + StackscriptData: d.StackscriptData, + } +} + +func flattenInstanceConfigHelpers(h *InstanceConfigHelpers) *linodego.InstanceConfigHelpers { + if h == nil { + return nil + } + + result := &linodego.InstanceConfigHelpers{} + + if h.UpdateDBDisabled != nil { + result.UpdateDBDisabled = *h.UpdateDBDisabled + } + if h.Distro != nil { + result.Distro = *h.Distro + } + if h.ModulesDep != nil { + result.ModulesDep = *h.ModulesDep + } + if h.Network != nil { + result.Network = *h.Network + } + if h.DevTmpFsAutomount != nil { + result.DevTmpFsAutomount = *h.DevTmpFsAutomount + } + + return result +} + +// resolveDiskLabel resolves a disk label to a disk ID using the provided map. +func resolveDiskLabel(label string, diskLabelToID map[string]int) (int, error) { + if label == "" { + return 0, fmt.Errorf("disk label cannot be empty") + } + + diskID, ok := diskLabelToID[label] + if !ok { + return 0, fmt.Errorf("disk with label %q not found", label) + } + return diskID, nil +} + +// flattenInstanceConfigDevice resolves disk labels and creates the linodego device struct. +func flattenInstanceConfigDevice(d *InstanceConfigDevice, diskLabelToID map[string]int) (*linodego.InstanceConfigDevice, error) { + if d == nil { + return nil, nil + } + + result := &linodego.InstanceConfigDevice{} + + if d.DiskLabel != "" { + diskID, err := resolveDiskLabel(d.DiskLabel, diskLabelToID) + if err != nil { + return nil, err + } + result.DiskID = diskID + } + + if d.VolumeID != 0 { + result.VolumeID = d.VolumeID + } + + // A device must have exactly one of disk or volume (not both, not neither) + if (result.DiskID == 0) == (result.VolumeID == 0) { + return nil, fmt.Errorf("device must specify exactly one of disk_label or volume_id") + } + + return result, nil +} + +// flattenInstanceConfigDevices resolves all device slots. +func flattenInstanceConfigDevices(d *InstanceConfigDevices, diskLabelToID map[string]int) (linodego.InstanceConfigDeviceMap, error) { + result := linodego.InstanceConfigDeviceMap{} + + if d == nil { + return result, nil + } + + var err error + + // Define explicit mappings for all device slots (sda through sdbl) + deviceMappings := []struct { + name string + src *InstanceConfigDevice + dst **linodego.InstanceConfigDevice + }{ + // sda through sdz + {name: "sda", src: d.SDA, dst: &result.SDA}, + {name: "sdb", src: d.SDB, dst: &result.SDB}, + {name: "sdc", src: d.SDC, dst: &result.SDC}, + {name: "sdd", src: d.SDD, dst: &result.SDD}, + {name: "sde", src: d.SDE, dst: &result.SDE}, + {name: "sdf", src: d.SDF, dst: &result.SDF}, + {name: "sdg", src: d.SDG, dst: &result.SDG}, + {name: "sdh", src: d.SDH, dst: &result.SDH}, + {name: "sdi", src: d.SDI, dst: &result.SDI}, + {name: "sdj", src: d.SDJ, dst: &result.SDJ}, + {name: "sdk", src: d.SDK, dst: &result.SDK}, + {name: "sdl", src: d.SDL, dst: &result.SDL}, + {name: "sdm", src: d.SDM, dst: &result.SDM}, + {name: "sdn", src: d.SDN, dst: &result.SDN}, + {name: "sdo", src: d.SDO, dst: &result.SDO}, + {name: "sdp", src: d.SDP, dst: &result.SDP}, + {name: "sdq", src: d.SDQ, dst: &result.SDQ}, + {name: "sdr", src: d.SDR, dst: &result.SDR}, + {name: "sds", src: d.SDS, dst: &result.SDS}, + {name: "sdt", src: d.SDT, dst: &result.SDT}, + {name: "sdu", src: d.SDU, dst: &result.SDU}, + {name: "sdv", src: d.SDV, dst: &result.SDV}, + {name: "sdw", src: d.SDW, dst: &result.SDW}, + {name: "sdx", src: d.SDX, dst: &result.SDX}, + {name: "sdy", src: d.SDY, dst: &result.SDY}, + {name: "sdz", src: d.SDZ, dst: &result.SDZ}, + // sdaa through sdaz + {name: "sdaa", src: d.SDAA, dst: &result.SDAA}, + {name: "sdab", src: d.SDAB, dst: &result.SDAB}, + {name: "sdac", src: d.SDAC, dst: &result.SDAC}, + {name: "sdad", src: d.SDAD, dst: &result.SDAD}, + {name: "sdae", src: d.SDAE, dst: &result.SDAE}, + {name: "sdaf", src: d.SDAF, dst: &result.SDAF}, + {name: "sdag", src: d.SDAG, dst: &result.SDAG}, + {name: "sdah", src: d.SDAH, dst: &result.SDAH}, + {name: "sdai", src: d.SDAI, dst: &result.SDAI}, + {name: "sdaj", src: d.SDAJ, dst: &result.SDAJ}, + {name: "sdak", src: d.SDAK, dst: &result.SDAK}, + {name: "sdal", src: d.SDAL, dst: &result.SDAL}, + {name: "sdam", src: d.SDAM, dst: &result.SDAM}, + {name: "sdan", src: d.SDAN, dst: &result.SDAN}, + {name: "sdao", src: d.SDAO, dst: &result.SDAO}, + {name: "sdap", src: d.SDAP, dst: &result.SDAP}, + {name: "sdaq", src: d.SDAQ, dst: &result.SDAQ}, + {name: "sdar", src: d.SDAR, dst: &result.SDAR}, + {name: "sdas", src: d.SDAS, dst: &result.SDAS}, + {name: "sdat", src: d.SDAT, dst: &result.SDAT}, + {name: "sdau", src: d.SDAU, dst: &result.SDAU}, + {name: "sdav", src: d.SDAV, dst: &result.SDAV}, + {name: "sdaw", src: d.SDAW, dst: &result.SDAW}, + {name: "sdax", src: d.SDAX, dst: &result.SDAX}, + {name: "sday", src: d.SDAY, dst: &result.SDAY}, + {name: "sdaz", src: d.SDAZ, dst: &result.SDAZ}, + // sdba through sdbl + {name: "sdba", src: d.SDBA, dst: &result.SDBA}, + {name: "sdbb", src: d.SDBB, dst: &result.SDBB}, + {name: "sdbc", src: d.SDBC, dst: &result.SDBC}, + {name: "sdbd", src: d.SDBD, dst: &result.SDBD}, + {name: "sdbe", src: d.SDBE, dst: &result.SDBE}, + {name: "sdbf", src: d.SDBF, dst: &result.SDBF}, + {name: "sdbg", src: d.SDBG, dst: &result.SDBG}, + {name: "sdbh", src: d.SDBH, dst: &result.SDBH}, + {name: "sdbi", src: d.SDBI, dst: &result.SDBI}, + {name: "sdbj", src: d.SDBJ, dst: &result.SDBJ}, + {name: "sdbk", src: d.SDBK, dst: &result.SDBK}, + {name: "sdbl", src: d.SDBL, dst: &result.SDBL}, + } + + for _, mapping := range deviceMappings { + if *mapping.dst, err = flattenInstanceConfigDevice(mapping.src, diskLabelToID); err != nil { + return result, fmt.Errorf("%s: %w", mapping.name, err) + } + } + + return result, nil +} + +// flattenInstanceConfig creates the linodego config create options. +func flattenInstanceConfig(cfg InstanceConfig, diskLabelToID map[string]int) (linodego.InstanceConfigCreateOptions, error) { + devices, err := flattenInstanceConfigDevices(cfg.Devices, diskLabelToID) + if err != nil { + return linodego.InstanceConfigCreateOptions{}, fmt.Errorf("failed to resolve devices: %w", err) + } + + // Flatten legacy interfaces if specified in the config block + interfaces := make([]linodego.InstanceConfigInterfaceCreateOptions, len(cfg.Interfaces)) + for i, v := range cfg.Interfaces { + interfaces[i] = flattenConfigInterface(v) + } + + opts := linodego.InstanceConfigCreateOptions{ + Label: cfg.Label, + Comments: cfg.Comments, + Devices: devices, + Helpers: flattenInstanceConfigHelpers(cfg.Helpers), + Interfaces: interfaces, + MemoryLimit: cfg.MemoryLimit, + Kernel: cfg.Kernel, + InitRD: cfg.InitRD, + RunLevel: cfg.RunLevel, + VirtMode: cfg.VirtMode, + } + + if cfg.RootDevice != "" { + opts.RootDevice = &cfg.RootDevice + } + + return opts, nil +} + +// selectBootConfig determines which configuration profile should be booted. +// Returns the index of the config to boot, or an error if multiple configs have booted=true. +// If no configs have booted=true, returns 0 (first config). +// If one config has booted=true, returns its index. +func selectBootConfig(configs []InstanceConfig) (int, error) { + bootedCount := 0 + bootedIndex := -1 + + for i, cfg := range configs { + if cfg.Booted { + bootedCount++ + bootedIndex = i + } + } + + if bootedCount > 1 { + return 0, errors.New("only one configuration profile can have 'booted' set to true") + } + + if bootedIndex >= 0 { + return bootedIndex, nil + } + + return 0, nil +} + +func (s *stepCreateDiskConfig) Run(ctx context.Context, state multistep.StateBag) multistep.StepAction { + c := state.Get("config").(*Config) + ui := state.Get("ui").(packersdk.Ui) + instance := state.Get("instance").(*linodego.Instance) + + handleError := func(prefix string, err error) multistep.StepAction { + return helper.ErrorHelper(state, ui, prefix, err) + } + + // Skip if no custom disks or configs are defined + if len(c.Disks) == 0 && len(c.InstanceConfigs) == 0 { + return multistep.ActionContinue + } + + // Map to track disk label -> disk ID for config device resolution + diskLabelToID := make(map[string]int) + + // Get the boot disk label from the boot config's root_device + // This is validated during Prepare() so it should always succeed + bootDiskLabel, err := c.getBootDiskLabel() + if err != nil { + return handleError("Failed to determine boot disk", err) + } + + for _, diskCfg := range c.Disks { + ui.Say(fmt.Sprintf("Creating disk: %s...", diskCfg.Label)) + + diskOpts := flattenDisk(diskCfg) + + // Only append SSH key to the disk specified by root_device (the boot disk) + // Note: Top-level authorized_keys/authorized_users are validated to be empty when using custom disks + if diskCfg.Label == bootDiskLabel { + if len(c.Comm.SSHPublicKey) > 0 { + diskOpts.AuthorizedKeys = append(diskOpts.AuthorizedKeys, string(c.Comm.SSHPublicKey)) + } + } + + if diskOpts.RootPass == "" && diskOpts.Image != "" { + diskOpts.RootPass = c.Comm.Password() + } + + disk, err := s.client.CreateInstanceDisk(ctx, instance.ID, diskOpts) + if err != nil { + return handleError(fmt.Sprintf("Failed to create disk %q", diskCfg.Label), err) + } + + // Wait for disk to be ready + disk, err = s.client.WaitForInstanceDiskStatus(ctx, instance.ID, disk.ID, linodego.DiskReady, int(c.StateTimeout.Seconds())) + if err != nil { + return handleError(fmt.Sprintf("Failed to wait for disk %q", diskCfg.Label), err) + } + + // Resolve a disk inconsistency where the disk may not be immediately bootable after creation + time.Sleep(1 * time.Second) + + ui.Say(fmt.Sprintf("Disk %s created with ID: %d", disk.Label, disk.ID)) + diskLabelToID[disk.Label] = disk.ID + } + + // Store disk map in state for other steps + state.Put("disk_label_to_id", diskLabelToID) + + // Determine which config to boot + bootConfigIndex, err := selectBootConfig(c.InstanceConfigs) + if err != nil { + return handleError("Multiple configuration profiles marked as booted", err) + } + + // Create configuration profiles and track the boot config ID + var bootConfigID int + for i, cfgProfile := range c.InstanceConfigs { + ui.Say(fmt.Sprintf("Creating configuration profile: %s...", cfgProfile.Label)) + + configOpts, err := flattenInstanceConfig(cfgProfile, diskLabelToID) + if err != nil { + return handleError(fmt.Sprintf("Failed to prepare config %q", cfgProfile.Label), err) + } + + config, err := s.client.CreateInstanceConfig(ctx, instance.ID, configOpts) + if err != nil { + return handleError(fmt.Sprintf("Failed to create config %q", cfgProfile.Label), err) + } + + ui.Say(fmt.Sprintf("Configuration profile %s created with ID: %d", config.Label, config.ID)) + + // Track the config that should be used for booting + if i == bootConfigIndex { + bootConfigID = config.ID + if cfgProfile.Booted { + ui.Say(fmt.Sprintf("Configuration profile %s will be used for booting", config.Label)) + } + } + } + + // Find the disk for imaging based on the boot config's root_device + var imageDisk *linodego.InstanceDisk + bootDiskID, ok := diskLabelToID[bootDiskLabel] + if !ok { + return handleError("Failed to find boot disk", fmt.Errorf("disk with label %q not found", bootDiskLabel)) + } + + disks, err := s.client.ListInstanceDisks(ctx, instance.ID, nil) + if err != nil { + return handleError("Failed to list instance disks", err) + } + + for _, disk := range disks { + if disk.ID == bootDiskID { + imageDisk = &disk + break + } + } + + if imageDisk == nil { + return handleError("Failed to find boot disk", fmt.Errorf("disk with ID %d not found", bootDiskID)) + } + + state.Put("disk", imageDisk) + + // Boot the instance with the first configuration profile + if bootConfigID != 0 { + ui.Say(fmt.Sprintf("Booting Linode with config ID %d...", bootConfigID)) + err = s.client.BootInstance(ctx, instance.ID, bootConfigID) + if err != nil { + return handleError("Failed to boot Linode", err) + } + + // Wait for instance to be running + instance, err = s.client.WaitForInstanceStatus(ctx, instance.ID, linodego.InstanceRunning, int(c.StateTimeout.Seconds())) + if err != nil { + return handleError("Failed to wait for Linode to be running", err) + } + state.Put("instance", instance) + ui.Say("Linode is now running") + } + + return multistep.ActionContinue +} + +func (*stepCreateDiskConfig) Cleanup(state multistep.StateBag) { + // Disks and configs are deleted when the instance is deleted + // No additional cleanup needed here +} diff --git a/builder/linode/step_create_disk_config_test.go b/builder/linode/step_create_disk_config_test.go new file mode 100644 index 00000000..e3ffd48c --- /dev/null +++ b/builder/linode/step_create_disk_config_test.go @@ -0,0 +1,545 @@ +package linode + +import ( + "strings" + "testing" + + "github.com/linode/linodego" +) + +func TestResolveDiskLabel(t *testing.T) { + diskLabelToID := map[string]int{ + "boot": 12345, + "swap": 67890, + "data": 11111, + } + + tests := []struct { + name string + label string + wantID int + wantError bool + }{ + { + name: "Valid label", + label: "boot", + wantID: 12345, + wantError: false, + }, + { + name: "Another valid label", + label: "swap", + wantID: 67890, + wantError: false, + }, + { + name: "Empty label - error", + label: "", + wantID: 0, + wantError: true, + }, + { + name: "Nonexistent label", + label: "nonexistent", + wantID: 0, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotID, err := resolveDiskLabel(tt.label, diskLabelToID) + + if tt.wantError { + if err == nil { + t.Errorf("resolveDiskLabel() expected error but got none") + } + } else { + if err != nil { + t.Errorf("resolveDiskLabel() unexpected error: %v", err) + } + } + + if gotID != tt.wantID { + t.Errorf("resolveDiskLabel() = %v, want %v", gotID, tt.wantID) + } + }) + } +} + +func TestFlattenInstanceConfigDevice(t *testing.T) { + diskLabelToID := map[string]int{ + "boot": 12345, + "swap": 67890, + } + + tests := []struct { + name string + device *InstanceConfigDevice + wantDisk int + wantVol int + wantNil bool + wantError bool + }{ + { + name: "Nil device returns nil", + device: nil, + wantNil: true, + wantError: false, + }, + { + name: "Valid disk label", + device: &InstanceConfigDevice{ + DiskLabel: "boot", + }, + wantDisk: 12345, + wantError: false, + }, + { + name: "Valid volume ID", + device: &InstanceConfigDevice{ + VolumeID: 99999, + }, + wantVol: 99999, + wantError: false, + }, + { + name: "Both disk and volume - error", + device: &InstanceConfigDevice{ + DiskLabel: "boot", + VolumeID: 99999, + }, + wantError: true, + }, + { + name: "Invalid disk label", + device: &InstanceConfigDevice{ + DiskLabel: "nonexistent", + }, + wantError: true, + }, + { + name: "Empty device - error", + device: &InstanceConfigDevice{}, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := flattenInstanceConfigDevice(tt.device, diskLabelToID) + + if tt.wantError { + if err == nil { + t.Errorf("flattenInstanceConfigDevice() expected error but got none") + } + return + } + + if err != nil { + t.Errorf("flattenInstanceConfigDevice() unexpected error: %v", err) + return + } + + if tt.wantNil { + if got != nil { + t.Errorf("flattenInstanceConfigDevice() expected nil but got %v", got) + } + return + } + + if got == nil { + t.Errorf("flattenInstanceConfigDevice() unexpected nil result") + return + } + + if got.DiskID != tt.wantDisk { + t.Errorf("flattenInstanceConfigDevice() DiskID = %v, want %v", got.DiskID, tt.wantDisk) + } + + if got.VolumeID != tt.wantVol { + t.Errorf("flattenInstanceConfigDevice() VolumeID = %v, want %v", got.VolumeID, tt.wantVol) + } + }) + } +} + +func TestFlattenInstanceConfigDevices(t *testing.T) { + diskLabelToID := map[string]int{ + "boot": 12345, + "swap": 67890, + } + + t.Run("Nil devices returns empty map", func(t *testing.T) { + result, err := flattenInstanceConfigDevices(nil, diskLabelToID) + if err != nil { + t.Errorf("flattenInstanceConfigDevices() unexpected error: %v", err) + } + if result.SDA != nil || result.SDB != nil { + t.Errorf("flattenInstanceConfigDevices() expected empty device slots") + } + }) + + t.Run("Valid device mappings", func(t *testing.T) { + devices := &InstanceConfigDevices{ + SDA: &InstanceConfigDevice{DiskLabel: "boot"}, + SDB: &InstanceConfigDevice{DiskLabel: "swap"}, + } + + result, err := flattenInstanceConfigDevices(devices, diskLabelToID) + if err != nil { + t.Errorf("flattenInstanceConfigDevices() unexpected error: %v", err) + } + + if result.SDA == nil || result.SDA.DiskID != 12345 { + t.Errorf("flattenInstanceConfigDevices() SDA = %v, want DiskID 12345", result.SDA) + } + + if result.SDB == nil || result.SDB.DiskID != 67890 { + t.Errorf("flattenInstanceConfigDevices() SDB = %v, want DiskID 67890", result.SDB) + } + }) + + t.Run("Invalid disk label in device", func(t *testing.T) { + devices := &InstanceConfigDevices{ + SDA: &InstanceConfigDevice{DiskLabel: "nonexistent"}, + } + + _, err := flattenInstanceConfigDevices(devices, diskLabelToID) + if err == nil { + t.Errorf("flattenInstanceConfigDevices() expected error for invalid disk label") + } + }) +} + +func TestFlattenDisk(t *testing.T) { + tests := []struct { + name string + disk Disk + want linodego.InstanceDiskCreateOptions + }{ + { + name: "Basic disk", + disk: Disk{ + Label: "boot", + Size: 25000, + Image: "linode/ubuntu24.04", + Filesystem: "ext4", + }, + want: linodego.InstanceDiskCreateOptions{ + Label: "boot", + Size: 25000, + Image: "linode/ubuntu24.04", + Filesystem: "ext4", + }, + }, + { + name: "Disk with authorized keys", + disk: Disk{ + Label: "boot", + Size: 25000, + Image: "linode/arch", + AuthorizedKeys: []string{"ssh-rsa AAAA..."}, + }, + want: linodego.InstanceDiskCreateOptions{ + Label: "boot", + Size: 25000, + Image: "linode/arch", + AuthorizedKeys: []string{"ssh-rsa AAAA..."}, + }, + }, + { + name: "Disk with stackscript", + disk: Disk{ + Label: "boot", + Size: 25000, + Image: "linode/debian12", + StackscriptID: 12345, + StackscriptData: map[string]string{"key": "value"}, + }, + want: linodego.InstanceDiskCreateOptions{ + Label: "boot", + Size: 25000, + Image: "linode/debian12", + StackscriptID: 12345, + StackscriptData: map[string]string{"key": "value"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := flattenDisk(tt.disk) + + if got.Label != tt.want.Label { + t.Errorf("flattenDisk() Label = %v, want %v", got.Label, tt.want.Label) + } + if got.Size != tt.want.Size { + t.Errorf("flattenDisk() Size = %v, want %v", got.Size, tt.want.Size) + } + if got.Image != tt.want.Image { + t.Errorf("flattenDisk() Image = %v, want %v", got.Image, tt.want.Image) + } + if got.Filesystem != tt.want.Filesystem { + t.Errorf("flattenDisk() Filesystem = %v, want %v", got.Filesystem, tt.want.Filesystem) + } + }) + } +} + +func TestFlattenInstanceConfigHelpers(t *testing.T) { + t.Run("Nil helpers returns nil", func(t *testing.T) { + result := flattenInstanceConfigHelpers(nil) + if result != nil { + t.Errorf("flattenInstanceConfigHelpers() expected nil for nil input") + } + }) + + t.Run("All helpers set to true", func(t *testing.T) { + trueVal := true + helpers := &InstanceConfigHelpers{ + UpdateDBDisabled: &trueVal, + Distro: &trueVal, + ModulesDep: &trueVal, + Network: &trueVal, + DevTmpFsAutomount: &trueVal, + } + + result := flattenInstanceConfigHelpers(helpers) + if result == nil { + t.Fatal("flattenInstanceConfigHelpers() unexpected nil result") + } + + if !result.UpdateDBDisabled { + t.Errorf("flattenInstanceConfigHelpers() UpdateDBDisabled = false, want true") + } + if !result.Distro { + t.Errorf("flattenInstanceConfigHelpers() Distro = false, want true") + } + if !result.ModulesDep { + t.Errorf("flattenInstanceConfigHelpers() ModulesDep = false, want true") + } + if !result.Network { + t.Errorf("flattenInstanceConfigHelpers() Network = false, want true") + } + if !result.DevTmpFsAutomount { + t.Errorf("flattenInstanceConfigHelpers() DevTmpFsAutomount = false, want true") + } + }) + + t.Run("Mixed helper values", func(t *testing.T) { + trueVal := true + falseVal := false + helpers := &InstanceConfigHelpers{ + UpdateDBDisabled: &trueVal, + Distro: &falseVal, + ModulesDep: &trueVal, + Network: nil, // Not set + DevTmpFsAutomount: &falseVal, + } + + result := flattenInstanceConfigHelpers(helpers) + if result == nil { + t.Fatal("flattenInstanceConfigHelpers() unexpected nil result") + } + + if !result.UpdateDBDisabled { + t.Errorf("flattenInstanceConfigHelpers() UpdateDBDisabled = false, want true") + } + if result.Distro { + t.Errorf("flattenInstanceConfigHelpers() Distro = true, want false") + } + if !result.ModulesDep { + t.Errorf("flattenInstanceConfigHelpers() ModulesDep = false, want true") + } + if result.Network { + t.Errorf("flattenInstanceConfigHelpers() Network = true, want false (nil defaults to false)") + } + if result.DevTmpFsAutomount { + t.Errorf("flattenInstanceConfigHelpers() DevTmpFsAutomount = true, want false") + } + }) +} + +// TestFlattenInstanceConfig tests the integration of device resolution and config creation +func TestFlattenInstanceConfig(t *testing.T) { + diskLabelToID := map[string]int{ + "boot": 12345, + "swap": 67890, + } + + tests := []struct { + name string + config InstanceConfig + wantErr bool + errContains string + }{ + { + name: "Valid config with devices", + config: InstanceConfig{ + Label: "my-config", + Comments: "Test config", + Devices: &InstanceConfigDevices{ + SDA: &InstanceConfigDevice{DiskLabel: "boot"}, + SDB: &InstanceConfigDevice{DiskLabel: "swap"}, + }, + Kernel: "linode/grub2", + RootDevice: "/dev/sda", + }, + wantErr: false, + }, + { + name: "Invalid disk label in devices", + config: InstanceConfig{ + Label: "my-config", + Devices: &InstanceConfigDevices{ + SDA: &InstanceConfigDevice{DiskLabel: "nonexistent"}, + }, + }, + wantErr: true, + errContains: "failed to resolve devices", + }, + { + name: "Config with interfaces", + config: InstanceConfig{ + Label: "my-config", + Devices: &InstanceConfigDevices{ + SDA: &InstanceConfigDevice{DiskLabel: "boot"}, + }, + Interfaces: []Interface{ + {Purpose: "public"}, + }, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts, err := flattenInstanceConfig(tt.config, diskLabelToID) + + if tt.wantErr { + if err == nil { + t.Errorf("flattenInstanceConfig() expected error but got none") + return + } + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("flattenInstanceConfig() error = %v, want error containing %q", err, tt.errContains) + } + return + } + + if err != nil { + t.Errorf("flattenInstanceConfig() unexpected error: %v", err) + return + } + + if opts.Label != tt.config.Label { + t.Errorf("flattenInstanceConfig() Label = %v, want %v", opts.Label, tt.config.Label) + } + }) + } +} + +// TestSelectBootConfig tests the boot configuration selection logic +func TestSelectBootConfig(t *testing.T) { + tests := []struct { + name string + configs []InstanceConfig + wantIndex int + wantError bool + wantErrorContains string + }{ + { + name: "Multiple configs with booted=true should error", + configs: []InstanceConfig{ + {Label: "config1", Booted: true}, + {Label: "config2", Booted: true}, + }, + wantError: true, + wantErrorContains: "only one configuration profile can have 'booted' set to true", + }, + { + name: "Single config with booted=true should use that config", + configs: []InstanceConfig{ + {Label: "config1", Booted: false}, + {Label: "config2", Booted: true}, + {Label: "config3", Booted: false}, + }, + wantIndex: 1, + wantError: false, + }, + { + name: "No configs with booted=true should default to first config", + configs: []InstanceConfig{ + {Label: "config1", Booted: false}, + {Label: "config2", Booted: false}, + }, + wantIndex: 0, + wantError: false, + }, + { + name: "Single config not marked as booted should use that config", + configs: []InstanceConfig{ + {Label: "config1", Booted: false}, + }, + wantIndex: 0, + wantError: false, + }, + { + name: "First config marked as booted should return index 0", + configs: []InstanceConfig{ + {Label: "config1", Booted: true}, + {Label: "config2", Booted: false}, + }, + wantIndex: 0, + wantError: false, + }, + { + name: "Last config marked as booted should return its index", + configs: []InstanceConfig{ + {Label: "config1", Booted: false}, + {Label: "config2", Booted: false}, + {Label: "config3", Booted: true}, + }, + wantIndex: 2, + wantError: false, + }, + { + name: "Three configs with first one booted", + configs: []InstanceConfig{ + {Label: "config1", Booted: true}, + {Label: "config2", Booted: false}, + {Label: "config3", Booted: false}, + }, + wantIndex: 0, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotIndex, err := selectBootConfig(tt.configs) + + if tt.wantError { + if err == nil { + t.Errorf("selectBootConfig() expected error but got none") + return + } + if tt.wantErrorContains != "" && !strings.Contains(err.Error(), tt.wantErrorContains) { + t.Errorf("selectBootConfig() error = %v, want error containing %q", err, tt.wantErrorContains) + } + return + } + + if err != nil { + t.Errorf("selectBootConfig() unexpected error: %v", err) + return + } + + if gotIndex != tt.wantIndex { + t.Errorf("selectBootConfig() = %d, want %d (config: %q)", gotIndex, tt.wantIndex, tt.configs[tt.wantIndex].Label) + } + }) + } +} diff --git a/builder/linode/step_create_linode.go b/builder/linode/step_create_linode.go index 8f936a32..422a16f1 100644 --- a/builder/linode/step_create_linode.go +++ b/builder/linode/step_create_linode.go @@ -146,24 +146,38 @@ func (s *stepCreateLinode) Run(ctx context.Context, state multistep.StateBag) mu ui.Say("Creating Linode...") + // Determine if we're using custom disks/configs (explicit provisioning) + // When custom disks are specified, we don't set the Image on instance creation + // because we'll create disks and configs manually in a separate step. + useCustomDisks := len(c.Disks) > 0 + createOpts := linodego.InstanceCreateOptions{ - RootPass: c.Comm.Password(), AuthorizedKeys: []string{}, AuthorizedUsers: []string{}, PrivateIP: c.PrivateIP, Region: c.Region, - StackScriptID: c.StackScriptID, - StackScriptData: c.StackScriptData, Type: c.InstanceType, Label: c.Label, - Image: c.Image, - SwapSize: &c.SwapSize, Tags: c.Tags, FirewallID: c.FirewallID, Metadata: flattenMetadata(c.Metadata), InterfaceGeneration: linodego.InterfaceGeneration(c.InterfaceGeneration), } + // Only set image-related options when NOT using custom disks + if !useCustomDisks { + createOpts.RootPass = c.Comm.Password() + createOpts.Image = c.Image + createOpts.SwapSize = &c.SwapSize + createOpts.StackScriptID = c.StackScriptID + createOpts.StackScriptData = c.StackScriptData + } else { + ui.Say("Using custom disk configuration - instance will be created without an image") + + // When using custom disks, we need to boot the instance ourselves after config is created + createOpts.Booted = linodego.Pointer(false) + } + interfaces := make([]linodego.InstanceConfigInterfaceCreateOptions, len(c.Interfaces)) for i, v := range c.Interfaces { interfaces[i] = flattenConfigInterface(v) @@ -174,7 +188,10 @@ func (s *stepCreateLinode) Run(ctx context.Context, state multistep.StateBag) mu linodeInterfaces[i] = flattenLinodeInterface(v) } - if len(interfaces) > 0 { + // Only add legacy interfaces to instance creation when NOT using custom disks + // (when using custom disks, legacy interfaces should be specified in the config block) + // linode_interface (newer system) can be specified at instance level regardless of disk mode + if !useCustomDisks && len(interfaces) > 0 { createOpts.Interfaces = interfaces } @@ -196,6 +213,19 @@ func (s *stepCreateLinode) Run(ctx context.Context, state multistep.StateBag) mu state.Put("instance", instance) state.Put("instance_id", instance.ID) + // When using custom disks, we skip waiting for running state here + // because the instance won't boot until we create disks and configs + if useCustomDisks { + // Wait for instance to be in offline state (resources allocated) + instance, err = s.client.WaitForInstanceStatus(ctx, instance.ID, linodego.InstanceOffline, int(c.StateTimeout.Seconds())) + if err != nil { + return handleError("Failed to wait for Linode to be offline", err) + } + state.Put("instance", instance) + // Disk will be set by stepCreateDiskConfig + return multistep.ActionContinue + } + // wait until instance is running instance, err = s.client.WaitForInstanceStatus(ctx, instance.ID, linodego.InstanceRunning, int(c.StateTimeout.Seconds())) if err != nil { diff --git a/datasource/image/data.go b/datasource/image/data.go index b106abfd..e66f2ae5 100644 --- a/datasource/image/data.go +++ b/datasource/image/data.go @@ -58,9 +58,9 @@ func (d *Datasource) Configure(raws ...interface{}) error { envToken := os.Getenv(helper.TokenEnvVar) if envToken == "" { errs = packersdk.MultiErrorAppend(errs, fmt.Errorf( - "A Linode API token is required. You can specify it in an "+ + "a Linode API token is required; you can specify it in an "+ "environment variable %q or set linode_token "+ - "attribute in the datasource block.", + "attribute in the datasource block", helper.TokenEnvVar, )) } diff --git a/datasource/image/filter.go b/datasource/image/filter.go index 823ff4d4..852a7395 100644 --- a/datasource/image/filter.go +++ b/datasource/image/filter.go @@ -65,12 +65,12 @@ func filterImageResults(images []linodego.Image, config Config) (linodego.Image, } return linodego.Image{}, errors.New( - "Multiple images found. Please try a more specific search, " + - "or set latest to true in the data source config block.", + "multiple images found; please try a more specific search, " + + "or set latest to true in the data source config block", ) } if len(images) == 0 { - return linodego.Image{}, errors.New("No image found.") + return linodego.Image{}, errors.New("no image found") } return images[0], nil diff --git a/docs/builders/linode.mdx b/docs/builders/linode.mdx index 4ff745a1..b9824f40 100644 --- a/docs/builders/linode.mdx +++ b/docs/builders/linode.mdx @@ -167,6 +167,53 @@ This section outlines the fields configurable for a single metadata object. @include 'builder/linode/Metadata-not-required.mdx' +#### Custom Disks and Configuration Profiles + +When you specify custom `disk` and `config` blocks, you take full control over the Linode's disk layout and boot configuration. This is useful for advanced scenarios like: +- Creating multiple disks (boot, data, swap) +- Configuring specific filesystems +- Setting up custom device mappings +- Deploying from custom or multiple images + +**Important:** When using custom disks, the following top-level attributes are **not compatible** and must not be specified: +- `image` - Specify images at the disk level instead +- `authorized_keys` - Specify in disk blocks instead +- `authorized_users` - Specify in disk blocks instead +- `swap_size` - Create a swap disk instead +- `stackscript_id` - Specify in disk blocks instead +- `stackscript_data` - Specify in disk blocks instead +- `interface` - Specify in config blocks instead + +**Note:** The newer `linode_interface` blocks CAN be used with custom disks as they are specified at the instance level and work independently of the disk/config provisioning. + +The SSH public key from the communicator configuration will be automatically added to the disk specified by the `root_device` in the booted configuration profile. The disk at the root device slot (identified via the `devices` mapping) will also be used to create the final image. + +**Important:** The `root_device` must point to a device slot (e.g., `/dev/sda`) that has a disk assigned in the `devices` block. The disk at that slot will be used for both SSH key injection and image creation. + +**Note:** Deploying an image to and booting from a volume are currently unsupported. Therefore, the `root_device` cannot point to a volume; it must reference a disk. + +##### Disk Block + +@include 'builder/linode/Disk-required.mdx' +@include 'builder/linode/Disk-not-required.mdx' + +##### Configuration Profile Block (config) + +@include 'builder/linode/InstanceConfig-required.mdx' +@include 'builder/linode/InstanceConfig-not-required.mdx' + +###### Configuration Helpers (helpers) + +@include 'builder/linode/InstanceConfigHelpers-not-required.mdx' + +###### Device Mappings (devices) + +@include 'builder/linode/InstanceConfigDevices-not-required.mdx' + +###### Device Configuration (InstanceConfigDevice) + +@include 'builder/linode/InstanceConfigDevice-not-required.mdx' + ## Examples ### Basic Example @@ -183,7 +230,7 @@ or in the config file or the environmental variable, `LINODE_TOKEN`. locals { timestamp = regex_replace(timestamp(), "[- TZ:]", "") } source "linode" "example" { - image = "linode/debian11" + image = "linode/debian13" image_description = "My Private Image" image_label = "private-image-${local.timestamp}" image_share_group_ids = [12345] @@ -207,7 +254,7 @@ build { "source": { "linode": { "example": { - "image": "linode/debian11", + "image": "linode/debian13", "linode_token": "YOUR API TOKEN", "region": "us-mia", "instance_type": "g6-nanode-1", @@ -237,7 +284,7 @@ build { locals { timestamp = regex_replace(timestamp(), "[- TZ:]", "") } source "linode" "example" { - image = "linode/debian11" + image = "linode/debian13" image_description = "My Private Image" image_label = "private-image-${local.timestamp}" instance_label = "temporary-linode-${local.timestamp}" @@ -297,7 +344,7 @@ build { "source": { "linode": { "example": { - "image": "linode/debian11", + "image": "linode/debian13", "region": "us-southeast", "instance_type": "g6-nanode-1", "instance_label": "temporary-linode-{{timestamp}}", @@ -351,7 +398,7 @@ build { locals { timestamp = regex_replace(timestamp(), "[- TZ:]", "") } source "linode" "example" { - image = "linode/ubuntu24.04" + image = "linode/debian13" image_description = "My Private Image" image_label = "private-image-${local.timestamp}" instance_label = "temporary-linode-${local.timestamp}" @@ -378,6 +425,134 @@ build { } ``` +## Custom Disk and Configuration Example + +This example demonstrates creating a Linode with custom disks and a configuration profile. This provides full control over disk layout and boot configuration. + +**HCL2** + +```hcl +locals { timestamp = regex_replace(timestamp(), "[- TZ:]", "") } + +source "linode" "custom" { + image_description = "Custom Disk Image" + image_label = "custom-disk-${local.timestamp}" + instance_label = "temporary-linode-${local.timestamp}" + instance_type = "g6-nanode-1" + region = "us-mia" + ssh_username = "root" + interface_generation = "legacy_config" + + # Define custom disks + disk { + label = "boot" + size = 25000 + image = "linode/debian13" + filesystem = "ext4" + } + + disk { + label = "swap" + size = 512 + filesystem = "swap" + } + + # Define configuration profile + config { + label = "my-config" + comments = "Boot configuration" + kernel = "linode/latest-64bit" + root_device = "/dev/sda" + run_level = "default" + + # Map disks to device slots + devices { + sda { disk_label = "boot" } + sdb { disk_label = "swap" } + } + + # Configure helpers + helpers { + updatedb_disabled = true + distro = true + modules_dep = true + network = true + devtmpfs_automount = true + } + + # Define network interfaces + interface { + purpose = "public" + } + } +} + +build { + sources = ["source.linode.custom"] +} +``` + +**JSON** + +```json +{ + "source": { + "linode": { + "custom": { + "image_description": "Custom Disk Image", + "image_label": "custom-disk-{{timestamp}}", + "instance_label": "temporary-linode-{{timestamp}}", + "instance_type": "g6-nanode-1", + "region": "us-mia", + "ssh_username": "root", + "interface_generation": "legacy_config", + "disk": [ + { + "label": "boot", + "size": 25000, + "image": "linode/debian13", + "filesystem": "ext4" + }, + { + "label": "swap", + "size": 512, + "filesystem": "swap" + } + ], + "config": [ + { + "label": "my-config", + "comments": "Boot configuration", + "kernel": "linode/latest-64bit", + "root_device": "/dev/sda", + "run_level": "default", + "devices": { + "sda": { "disk_label": "boot" }, + "sdb": { "disk_label": "swap" } + }, + "helpers": { + "updatedb_disabled": true, + "distro": true, + "modules_dep": true, + "network": true, + "devtmpfs_automount": true + }, + "interface": [ + { + "purpose": "public" + } + ] + } + ] + } + } + }, + "build": { + "sources": ["source.linode.custom"] + } +} +``` + **JSON** ```json @@ -385,7 +560,7 @@ build { "source": { "linode": { "example": { - "image": "linode/ubuntu24.04", + "image": "linode/debian13", "linode_token": "YOUR API TOKEN", "region": "us-mia", "instance_type": "g6-nanode-1", diff --git a/example/basic_linode.json b/example/basic_linode.json index 4d93bae8..f2aa274f 100644 --- a/example/basic_linode.json +++ b/example/basic_linode.json @@ -2,8 +2,8 @@ "builders": [{ "type": "linode", "linode_token": "YOUR API TOKEN", - "image": "linode/debian9", - "region": "us-east", + "image": "linode/debian13", + "region": "us-mia", "instance_type": "g6-nanode-1", "instance_label": "temporary-linode-{{timestamp}}", diff --git a/example/basic_linode.pkr.hcl b/example/basic_linode.pkr.hcl index ba9843e6..1ed64371 100644 --- a/example/basic_linode.pkr.hcl +++ b/example/basic_linode.pkr.hcl @@ -10,13 +10,13 @@ packer { locals { timestamp = regex_replace(timestamp(), "[- TZ:]", "") } source "linode" "example" { - image = "linode/debian9" + image = "linode/debian13" image_description = "My Private Image" image_label = "private-image-${local.timestamp}" instance_label = "temporary-linode-${local.timestamp}" instance_type = "g6-nanode-1" linode_token = "YOUR API TOKEN" - region = "us-east" + region = "us-mia" ssh_username = "root" } diff --git a/example/hcp_packer_linode.pkr.hcl b/example/hcp_packer_linode.pkr.hcl index 6bc08a7c..a62ef88b 100644 --- a/example/hcp_packer_linode.pkr.hcl +++ b/example/hcp_packer_linode.pkr.hcl @@ -11,12 +11,12 @@ locals { timestamp = regex_replace(timestamp(), "[- TZ:]", "") } source "linode" "example" { linode_token = "Your Personal Access Token" - image = "linode/debian9" + image = "linode/debian13" image_description = "My Private Image" image_label = "private-image-${local.timestamp}" instance_label = "temporary-linode-${local.timestamp}" instance_type = "g6-nanode-1" - region = "us-east" + region = "us-mia" ssh_username = "root" } diff --git a/example/main.tf b/example/main.tf index ec30ab10..e5a764c5 100644 --- a/example/main.tf +++ b/example/main.tf @@ -32,13 +32,13 @@ data "hcp_packer_image" "production_linode_image" { bucket_name = "linode-hcp-test" cloud_provider = "linode" iteration_id = data.hcp_packer_iteration.production_linode.ulid - region = "us-east" + region = "us-mia" } resource "linode_instance" "production_linode_instance" { label = "test-hcp-linode-instance" image = data.hcp_packer_image.production_linode_image.cloud_image_id - region = "us-east" + region = "us-mia" type = "g6-nanode-1" root_pass = "terr4form_test" } \ No newline at end of file diff --git a/go.mod b/go.mod index 1fd1090d..708cbbdb 100644 --- a/go.mod +++ b/go.mod @@ -1,8 +1,8 @@ module github.com/linode/packer-plugin-linode -go 1.24.0 +go 1.25 -toolchain go1.24.1 +toolchain go1.25.7 require ( github.com/hashicorp/hcl/v2 v2.24.0