diff --git a/.web-docs/components/builder/linode/README.md b/.web-docs/components/builder/linode/README.md index ffcbbe4..4764f0c 100644 --- a/.web-docs/components/builder/linode/README.md +++ b/.web-docs/components/builder/linode/README.md @@ -90,12 +90,20 @@ can also be supplied to override the typical auto-generated key: - `swap_size` (\*int) - The disk size (MiB) allocated for swap space. +- `boot_size` (\*int) - The size (MiB) of the primary boot disk. Any remaining disk space beyond + the boot disk and swap partition is left unallocated. If not specified, + the boot disk will use all available space after swap. + +- `kernel` (string) - The kernel to boot the instance with. This can be a kernel ID such as + "linode/latest-64bit" or "linode/grub2". See the available kernels at + https://api.linode.com/v4/linode/kernels. + - `private_ip` (bool) - If true, the created Linode will have private networking enabled and assigned a private IPv4 address. - `root_pass` (string) - The root password of the Linode instance for building the image. Please note that when - you create a new Linode instance with a private image, you will be required to setup a - new root password. + you create a new Linode instance with an image, at least one of root_pass, + authorized_keys, or authorized_users must be provided - `image_label` (string) - The name of the resulting image that will appear in your account. Defaults to `packer-{{timestamp}}` (see [configuration @@ -495,11 +503,15 @@ The SSH public key from the communicator configuration will be automatically add -- `image` (string) - An Image ID to deploy the Linode Disk from. If provided, root_pass is required. +- `image` (string) - An Image ID to deploy the Linode Disk from. If provided, + at least one of root_pass, authorized_keys, or authorized_users + must be provided to ensure access. - `filesystem` (string) - The filesystem for the disk. Valid values are raw, swap, ext3, ext4, initrd. Defaults to ext4. +- `root_pass` (string) - The root password for this disk when deploying from an image. + - `authorized_keys` ([]string) - A list of public SSH keys to be installed on the disk as the root user's ~/.ssh/authorized_keys file. diff --git a/builder/linode/builder_test.go b/builder/linode/builder_test.go index b88dfdd..4eb66e9 100644 --- a/builder/linode/builder_test.go +++ b/builder/linode/builder_test.go @@ -13,11 +13,12 @@ import ( func testConfig() map[string]any { return map[string]any{ - "linode_token": "bar", - "region": "us-ord", - "instance_type": "g6-nanode-1", - "ssh_username": "root", - "image": "linode/arch", + "linode_token": "bar", + "region": "us-ord", + "instance_type": "g6-nanode-1", + "ssh_username": "root", + "image": "linode/arch", + "authorized_keys": []string{"ssh-rsa AAAA..."}, } } @@ -373,9 +374,11 @@ func TestBuilderPrepare_AuthorizedKeysAndUsers(t *testing.T) { var b Builder config := testConfig() - // Test optional + // Test optional - when image is specified, at least one of root_pass, authorized_keys, or authorized_users is required + // So we use root_pass as the alternative auth method delete(config, "authorized_keys") delete(config, "authorized_users") + config["root_pass"] = "testpassword123" _, warnings, err := b.Prepare(config) if len(warnings) > 0 { @@ -414,6 +417,293 @@ func TestBuilderPrepare_AuthorizedKeysAndUsers(t *testing.T) { } } +// TestBuilderPrepare_RootPassOptional tests that root_pass is optional when +// authorized_keys or authorized_users is provided for instances with an image. +func TestBuilderPrepare_RootPassOptional(t *testing.T) { + t.Run("RootPassOnlyWithImage", func(t *testing.T) { + var b Builder + config := testConfig() + delete(config, "authorized_keys") + delete(config, "authorized_users") + config["root_pass"] = "testpassword123" + + _, _, err := b.Prepare(config) + if err != nil { + t.Fatalf("root_pass only should work: %s", err) + } + if b.config.RootPass != "testpassword123" { + t.Errorf("expected root_pass to be set, got: %s", b.config.RootPass) + } + }) + + t.Run("AuthorizedKeysOnlyWithImage", func(t *testing.T) { + var b Builder + config := testConfig() + delete(config, "root_pass") + // authorized_keys is already set in testConfig() + + _, _, err := b.Prepare(config) + if err != nil { + t.Fatalf("authorized_keys only should work: %s", err) + } + if b.config.RootPass != "" { + t.Errorf("expected root_pass to be empty, got: %s", b.config.RootPass) + } + }) + + t.Run("AuthorizedUsersOnlyWithImage", func(t *testing.T) { + var b Builder + config := testConfig() + delete(config, "authorized_keys") + delete(config, "root_pass") + config["authorized_users"] = []string{"testuser"} + + _, _, err := b.Prepare(config) + if err != nil { + t.Fatalf("authorized_users only should work: %s", err) + } + if b.config.RootPass != "" { + t.Errorf("expected root_pass to be empty, got: %s", b.config.RootPass) + } + }) + + t.Run("NoAuthMethodWithImage_AutoGeneratedKey", func(t *testing.T) { + // When no explicit auth method is provided and no ssh_private_key_file, + // Packer will auto-generate an SSH key, which is acceptable + var b Builder + config := testConfig() + delete(config, "authorized_keys") + delete(config, "authorized_users") + delete(config, "root_pass") + + _, _, err := b.Prepare(config) + if err != nil { + t.Fatalf("should pass when Packer can auto-generate SSH key: %s", err) + } + }) + + t.Run("NoAuthMethodWithImage_PrivateKeyFile", func(t *testing.T) { + // When ssh_private_key_file is provided, Packer won't auto-generate a key, + // so at least one explicit auth method must be provided + var b Builder + config := testConfig() + delete(config, "authorized_keys") + delete(config, "authorized_users") + delete(config, "root_pass") + config["ssh_private_key_file"] = "/path/to/key" + + _, _, err := b.Prepare(config) + if err == nil { + t.Fatal("expected error when ssh_private_key_file is set but no auth method is specified with image") + } + if !strings.Contains(err.Error(), "at least one of root_pass, authorized_keys, or authorized_users must be provided") { + t.Fatalf("expected specific error message, got: %s", err) + } + }) + + t.Run("AllAuthMethodsWithImage", func(t *testing.T) { + var b Builder + config := testConfig() + config["root_pass"] = "testpassword123" + config["authorized_keys"] = []string{"ssh-rsa AAAA..."} + config["authorized_users"] = []string{"testuser"} + + _, _, err := b.Prepare(config) + if err != nil { + t.Fatalf("all auth methods together should work: %s", err) + } + }) +} + +// TestBuilderPrepare_DiskRootPassOptional tests that root_pass is optional for +// custom disks with images when authorized_keys or authorized_users is provided. +func TestBuilderPrepare_DiskRootPassOptional(t *testing.T) { + t.Run("DiskWithRootPassOnly", func(t *testing.T) { + var b Builder + config := testConfig() + delete(config, "image") + delete(config, "authorized_keys") + config["disk"] = []map[string]any{ + { + "label": "boot", + "size": 25000, + "image": "linode/arch", + "root_pass": "diskpassword123", + }, + } + 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("disk with root_pass only should work: %s", err) + } + }) + + t.Run("DiskWithAuthorizedKeysOnly", func(t *testing.T) { + var b Builder + config := testConfig() + delete(config, "image") + delete(config, "authorized_keys") + config["disk"] = []map[string]any{ + { + "label": "boot", + "size": 25000, + "image": "linode/arch", + "authorized_keys": []string{"ssh-rsa AAAA..."}, + }, + } + 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("disk with authorized_keys only should work: %s", err) + } + }) + + t.Run("DiskWithAuthorizedUsersOnly", func(t *testing.T) { + var b Builder + config := testConfig() + delete(config, "image") + delete(config, "authorized_keys") + config["disk"] = []map[string]any{ + { + "label": "boot", + "size": 25000, + "image": "linode/arch", + "authorized_users": []string{"testuser"}, + }, + } + 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("disk with authorized_users only should work: %s", err) + } + }) + + t.Run("BootDiskWithNoAuthMethod_AutoGeneratedKey", func(t *testing.T) { + // Boot disk can rely on auto-generated SSH key when no ssh_private_key_file is set + var b Builder + config := testConfig() + delete(config, "image") + delete(config, "authorized_keys") + config["disk"] = []map[string]any{ + { + "label": "boot", + "size": 25000, + "image": "linode/arch", + // No auth method specified - will use auto-generated SSH key + }, + } + 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("boot disk should pass when Packer can auto-generate SSH key: %s", err) + } + }) + + t.Run("BootDiskWithNoAuthMethod_PrivateKeyFile", func(t *testing.T) { + // When ssh_private_key_file is set, boot disk needs explicit auth + var b Builder + config := testConfig() + delete(config, "image") + delete(config, "authorized_keys") + config["ssh_private_key_file"] = "/path/to/key" + config["disk"] = []map[string]any{ + { + "label": "boot", + "size": 25000, + "image": "linode/arch", + // No auth method specified + }, + } + 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 ssh_private_key_file is set but boot disk has no auth method") + } + errStr := err.Error() + if !strings.Contains(errStr, "root_pass, authorized_keys, or authorized_users") { + t.Fatalf("expected specific error message, got: %s", errStr) + } + }) + + t.Run("DiskWithoutImageNoAuthRequired", func(t *testing.T) { + var b Builder + config := testConfig() + delete(config, "image") + delete(config, "authorized_keys") + config["disk"] = []map[string]any{ + { + "label": "boot", + "size": 25000, + "image": "linode/arch", + "authorized_keys": []string{"ssh-rsa AAAA..."}, + }, + { + "label": "data", + "size": 10000, + "filesystem": "ext4", + // No image, so no auth method required + }, + } + config["config"] = []map[string]any{ + {"label": "my-config", "root_device": "/dev/sda", "devices": map[string]any{"sda": map[string]any{"disk_label": "boot"}, "sdb": map[string]any{"disk_label": "data"}}}, + } + + _, _, err := b.Prepare(config) + if err != nil { + t.Fatalf("disk without image should not require auth method: %s", err) + } + }) + + t.Run("NonBootDiskWithImage_NeedsExplicitAuth", func(t *testing.T) { + // Non-boot disks with an image still need explicit auth (auto-generated key only applies to boot disk) + var b Builder + config := testConfig() + delete(config, "image") + delete(config, "authorized_keys") + config["disk"] = []map[string]any{ + { + "label": "boot", + "size": 25000, + "image": "linode/arch", + "authorized_keys": []string{"ssh-rsa AAAA..."}, + }, + { + "label": "other", + "size": 10000, + "image": "linode/debian11", + // No auth method - this should fail even though Packer auto-generates key + // because auto-generated key is only added to boot disk + }, + } + config["config"] = []map[string]any{ + {"label": "my-config", "root_device": "/dev/sda", "devices": map[string]any{"sda": map[string]any{"disk_label": "boot"}, "sdb": map[string]any{"disk_label": "other"}}}, + } + + _, _, err := b.Prepare(config) + if err == nil { + t.Fatal("expected error when non-boot disk with image has no auth method") + } + if !strings.Contains(err.Error(), "at least one of root_pass, authorized_keys, or authorized_users must be provided") { + t.Fatalf("expected specific error message, got: %s", err) + } + }) +} + func TestBuilderPrepare_PrivateIP(t *testing.T) { var b Builder config := testConfig() @@ -843,6 +1133,136 @@ func TestBuilderPrepare_ImageShareGroupIDs(t *testing.T) { } } +func TestBuilderPrepare_BootSizeAndKernel(t *testing.T) { + t.Run("DefaultsAreNilAndEmpty", func(t *testing.T) { + var b Builder + config := testConfig() + delete(config, "boot_size") + delete(config, "kernel") + + _, warnings, err := b.Prepare(config) + if len(warnings) > 0 { + t.Fatalf("bad: %#v", warnings) + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if b.config.BootSize != nil { + t.Errorf("expected boot_size to be nil, got %v", b.config.BootSize) + } + if b.config.Kernel != "" { + t.Errorf("expected kernel to be empty, got %v", b.config.Kernel) + } + }) + + t.Run("BootSizeSet", func(t *testing.T) { + var b Builder + config := testConfig() + expectedBootSize := 20000 + config["boot_size"] = expectedBootSize + + _, warnings, err := b.Prepare(config) + if len(warnings) > 0 { + t.Fatalf("bad: %#v", warnings) + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if b.config.BootSize == nil || *b.config.BootSize != expectedBootSize { + t.Errorf("expected boot_size to be %d, got %v", expectedBootSize, b.config.BootSize) + } + }) + + t.Run("KernelSet", func(t *testing.T) { + var b Builder + config := testConfig() + expectedKernel := "linode/latest-64bit" + config["kernel"] = expectedKernel + + _, warnings, err := b.Prepare(config) + if len(warnings) > 0 { + t.Fatalf("bad: %#v", warnings) + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if b.config.Kernel != expectedKernel { + t.Errorf("expected kernel to be %s, got %s", expectedKernel, b.config.Kernel) + } + }) + + t.Run("BothSet", func(t *testing.T) { + var b Builder + config := testConfig() + expectedBootSize := 15000 + expectedKernel := "linode/grub2" + config["boot_size"] = expectedBootSize + config["kernel"] = expectedKernel + + _, warnings, err := b.Prepare(config) + if len(warnings) > 0 { + t.Fatalf("bad: %#v", warnings) + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if b.config.BootSize == nil || *b.config.BootSize != expectedBootSize { + t.Errorf("expected boot_size to be %d, got %v", expectedBootSize, b.config.BootSize) + } + if b.config.Kernel != expectedKernel { + t.Errorf("expected kernel to be %s, got %s", expectedKernel, b.config.Kernel) + } + }) + + t.Run("BootSizeNotAllowedWithCustomDisks", func(t *testing.T) { + var b Builder + config := testConfig() + delete(config, "image") + delete(config, "authorized_keys") + config["boot_size"] = 20000 + config["disk"] = []map[string]any{ + {"label": "boot", "size": 25000, "image": "linode/arch", "authorized_keys": []string{"ssh-rsa AAAA..."}}, + } + 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 boot_size is specified with custom disks") + } + if !strings.Contains(err.Error(), "boot_size cannot be specified when using custom disks") { + t.Fatalf("expected specific error message, got: %s", err) + } + }) + + t.Run("KernelNotAllowedWithCustomDisks", func(t *testing.T) { + var b Builder + config := testConfig() + delete(config, "image") + delete(config, "authorized_keys") + config["kernel"] = "linode/latest-64bit" + config["disk"] = []map[string]any{ + {"label": "boot", "size": 25000, "image": "linode/arch", "authorized_keys": []string{"ssh-rsa AAAA..."}}, + } + 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 kernel is specified with custom disks") + } + if !strings.Contains(err.Error(), "kernel cannot be specified when using custom disks") { + t.Fatalf("expected specific error message, got: %s", err) + } + }) +} + func TestBuilderPrepare_CustomDisks(t *testing.T) { var b Builder config := testConfig() @@ -850,10 +1270,11 @@ func TestBuilderPrepare_CustomDisks(t *testing.T) { // Test with custom disk config["disk"] = []map[string]any{ { - "label": "boot", - "size": 25000, - "image": "linode/arch", - "filesystem": "ext4", + "label": "boot", + "size": 25000, + "image": "linode/arch", + "filesystem": "ext4", + "authorized_keys": []string{"ssh-rsa AAAA..."}, }, { "label": "swap", @@ -880,7 +1301,9 @@ func TestBuilderPrepare_CustomDisks(t *testing.T) { } // When using custom disks, image should not be required at top level + // Also remove top-level authorized_keys since it's not allowed with custom disks delete(config, "image") + delete(config, "authorized_keys") _, warnings, err := b.Prepare(config) if len(warnings) > 0 { @@ -935,14 +1358,16 @@ func TestBuilderPrepare_CustomConfig(t *testing.T) { config["disk"] = []map[string]any{ { - "label": "boot", - "size": 25000, - "image": "linode/arch", + "label": "boot", + "size": 25000, + "image": "linode/arch", + "authorized_keys": []string{"ssh-rsa AAAA..."}, }, } - // When using custom disks, image should not be required at top level + // When using custom disks, image and authorized_keys should not be at top level delete(config, "image") + delete(config, "authorized_keys") _, warnings, err := b.Prepare(config) if len(warnings) > 0 { @@ -1193,12 +1618,13 @@ func TestBuilderPrepare_CustomDisksValidation(t *testing.T) { var b Builder config := testConfig() delete(config, "image") + delete(config, "authorized_keys") // 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"}, + {"label": "boot", "size": 25000, "image": "linode/arch", "authorized_keys": []string{"ssh-rsa AAAA..."}}, } config["config"] = []map[string]any{ {"label": "my-config", "root_device": "/dev/sda", "devices": map[string]any{"sda": map[string]any{"disk_label": "boot"}}}, @@ -1214,8 +1640,9 @@ func TestBuilderPrepare_CustomDisksValidation(t *testing.T) { var b Builder config := testConfig() delete(config, "image") + delete(config, "authorized_keys") config["disk"] = []map[string]any{ - {"label": "boot", "size": 25000, "image": "linode/arch"}, + {"label": "boot", "size": 25000, "image": "linode/arch", "authorized_keys": []string{"ssh-rsa AAAA..."}}, } config["config"] = []map[string]any{ {"label": "my-config", "devices": map[string]any{"sda": map[string]any{"disk_label": "boot"}}}, diff --git a/builder/linode/config.go b/builder/linode/config.go index fc0d90e..66d0ea1 100644 --- a/builder/linode/config.go +++ b/builder/linode/config.go @@ -4,8 +4,6 @@ package linode import ( - "crypto/rand" - "encoding/base64" "errors" "fmt" "os" @@ -44,13 +42,18 @@ type Disk struct { // 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. + // An Image ID to deploy the Linode Disk from. If provided, + // at least one of root_pass, authorized_keys, or authorized_users + // must be provided to ensure access. 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"` + // The root password for this disk when deploying from an image. + RootPass string `mapstructure:"root_pass" 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"` @@ -290,13 +293,23 @@ type Config struct { // The disk size (MiB) allocated for swap space. SwapSize *int `mapstructure:"swap_size" required:"false"` + // The size (MiB) of the primary boot disk. Any remaining disk space beyond + // the boot disk and swap partition is left unallocated. If not specified, + // the boot disk will use all available space after swap. + BootSize *int `mapstructure:"boot_size" required:"false"` + + // The kernel to boot the instance with. This can be a kernel ID such as + // "linode/latest-64bit" or "linode/grub2". See the available kernels at + // https://api.linode.com/v4/linode/kernels. + Kernel string `mapstructure:"kernel" required:"false"` + // If true, the created Linode will have private networking enabled and assigned // a private IPv4 address. PrivateIP bool `mapstructure:"private_ip" required:"false"` // The root password of the Linode instance for building the image. Please note that when - // you create a new Linode instance with a private image, you will be required to setup a - // new root password. + // you create a new Linode instance with an image, at least one of root_pass, + // authorized_keys, or authorized_users must be provided RootPass string `mapstructure:"root_pass" required:"false"` // The name of the resulting image that will appear @@ -560,16 +573,6 @@ func (c *Config) getBootDiskLabel() (string, error) { return device.DiskLabel, nil } -func createRandomRootPassword() (string, error) { - rawRootPass := make([]byte, 50) - _, err := rand.Read(rawRootPass) - if err != nil { - return "", fmt.Errorf("failed to generate random password") - } - rootPass := base64.StdEncoding.EncodeToString(rawRootPass) - return rootPass, nil -} - func (c *Config) Prepare(raws ...any) ([]string, error) { if err := config.Decode(c, &config.DecodeOpts{ Interpolate: true, @@ -613,14 +616,6 @@ func (c *Config) Prepare(raws ...any) ([]string, error) { } } - if c.RootPass == "" { - var err error - c.RootPass, err = createRandomRootPassword() - if err != nil { - errs = packersdk.MultiErrorAppend(errs, fmt.Errorf("unable to generate root_pass: %s", err)) - } - } - if c.StateTimeout == 0 { // Default to 5 minute timeouts waiting for state change c.StateTimeout = 5 * time.Minute @@ -631,11 +626,94 @@ func (c *Config) Prepare(raws ...any) ([]string, error) { c.ImageCreateTimeout = 10 * time.Minute } + if strings.TrimSpace(c.RootPass) != "" { + c.Comm.SSHPassword = c.RootPass + } + if es := c.Comm.Prepare(&c.ctx); len(es) > 0 { errs = packersdk.MultiErrorAppend(errs, es...) } - c.Comm.SSHPassword = c.RootPass + // When creating a Linode from an image, at least one of root_pass, authorized_keys, or authorized_users + // must be provided to ensure access to the instance. + // Note: If no SSHPrivateKeyFile is provided, Packer will auto-generate an SSH key pair during the build, + // which will be added to authorized_keys on the instance, satisfying this requirement. + if c.Image != "" { + hasRootPass := strings.TrimSpace(c.RootPass) != "" + hasKeys := len(c.AuthorizedKeys) > 0 + hasUsers := len(c.AuthorizedUsers) > 0 + // If no private key file is specified, Packer will auto-generate an SSH key + willAutoGenerateKey := c.Comm.SSHPrivateKeyFile == "" + + if !hasRootPass && !hasKeys && !hasUsers && !willAutoGenerateKey { + errs = packersdk.MultiErrorAppend( + errs, + fmt.Errorf( + "when image is specified, at least one of root_pass, authorized_keys, or authorized_users must be provided", + ), + ) + } + } + + // Get boot disk label for validation (if custom disks are configured) + bootLabel, bootLabelErr := c.getBootDiskLabel() + + // Validate non-boot disks: they don't get the auto-generated SSH key, so they need explicit auth + for _, d := range c.Disks { + if strings.TrimSpace(d.Image) == "" { + continue + } + + // Skip boot disk - it's validated separately below + if bootLabelErr == nil && d.Label == bootLabel { + continue + } + + hasRootPass := strings.TrimSpace(d.RootPass) != "" + hasKeys := len(d.AuthorizedKeys) > 0 + hasUsers := len(d.AuthorizedUsers) > 0 + + if !hasRootPass && !hasKeys && !hasUsers { + errs = packersdk.MultiErrorAppend( + errs, + fmt.Errorf( + "disk %q: when image is specified, at least one of root_pass, authorized_keys, or authorized_users must be provided", + d.Label, + ), + ) + } + } + + // Validate boot disk: it gets the auto-generated SSH key appended (if no ssh_private_key_file) + if bootLabelErr == nil { + for _, d := range c.Disks { + if d.Label != bootLabel { + continue + } + + if strings.TrimSpace(d.Image) == "" { + break + } + + hasRootPass := strings.TrimSpace(d.RootPass) != "" + hasKeys := len(d.AuthorizedKeys) > 0 + hasUsers := len(d.AuthorizedUsers) > 0 + // If no private key file is specified, Packer will auto-generate an SSH key + willAutoGenerateKey := c.Comm.SSHPrivateKeyFile == "" + + if !hasRootPass && !hasKeys && !hasUsers && !willAutoGenerateKey { + errs = packersdk.MultiErrorAppend( + errs, + fmt.Errorf( + "boot disk %q must define root_pass, authorized_keys, or authorized_users", + d.Label, + ), + ) + } + + break + } + } if c.PersonalAccessToken == "" { // Required configurations that will display errors if not set @@ -725,6 +803,16 @@ func (c *Config) Prepare(raws ...any) ([]string, error) { errs, errors.New("swap_size cannot be specified when using custom disks (create a swap disk instead)")) } + if c.BootSize != nil { + errs = packersdk.MultiErrorAppend( + errs, errors.New("boot_size cannot be specified when using custom disks (specify size in disk blocks instead)")) + } + + if c.Kernel != "" { + errs = packersdk.MultiErrorAppend( + errs, errors.New("kernel cannot be specified when using custom disks (specify in config blocks 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)")) diff --git a/builder/linode/config.hcl2spec.go b/builder/linode/config.hcl2spec.go index 0db49e0..fa53476 100644 --- a/builder/linode/config.hcl2spec.go +++ b/builder/linode/config.hcl2spec.go @@ -79,6 +79,8 @@ type FlatConfig struct { Tags []string `mapstructure:"instance_tags" required:"false" cty:"instance_tags" hcl:"instance_tags"` Image *string `mapstructure:"image" required:"false" cty:"image" hcl:"image"` SwapSize *int `mapstructure:"swap_size" required:"false" cty:"swap_size" hcl:"swap_size"` + BootSize *int `mapstructure:"boot_size" required:"false" cty:"boot_size" hcl:"boot_size"` + Kernel *string `mapstructure:"kernel" required:"false" cty:"kernel" hcl:"kernel"` 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"` ImageLabel *string `mapstructure:"image_label" required:"false" cty:"image_label" hcl:"image_label"` @@ -178,6 +180,8 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { "instance_tags": &hcldec.AttrSpec{Name: "instance_tags", Type: cty.List(cty.String), Required: false}, "image": &hcldec.AttrSpec{Name: "image", Type: cty.String, Required: false}, "swap_size": &hcldec.AttrSpec{Name: "swap_size", Type: cty.Number, Required: false}, + "boot_size": &hcldec.AttrSpec{Name: "boot_size", Type: cty.Number, Required: false}, + "kernel": &hcldec.AttrSpec{Name: "kernel", Type: cty.String, Required: false}, "private_ip": &hcldec.AttrSpec{Name: "private_ip", Type: cty.Bool, Required: false}, "root_pass": &hcldec.AttrSpec{Name: "root_pass", Type: cty.String, Required: false}, "image_label": &hcldec.AttrSpec{Name: "image_label", Type: cty.String, Required: false}, @@ -205,6 +209,7 @@ type FlatDisk struct { 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"` + RootPass *string `mapstructure:"root_pass" required:"false" cty:"root_pass" hcl:"root_pass"` 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"` @@ -227,6 +232,7 @@ func (*FlatDisk) HCL2Spec() map[string]hcldec.Spec { "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}, + "root_pass": &hcldec.AttrSpec{Name: "root_pass", 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}, diff --git a/builder/linode/config_test.go b/builder/linode/config_test.go index 2fc6063..62c7066 100644 --- a/builder/linode/config_test.go +++ b/builder/linode/config_test.go @@ -22,10 +22,11 @@ func TestPrepare(t *testing.T) { ctx: interpolate.Context{}, Comm: communicator.Config{SSH: data}, - Region: "us-ord", - InstanceType: "g6-standard-1", - Image: "linode/debian12", - ImageRegions: []string{"us-ord", "us-mia", "us-lax"}, + Region: "us-ord", + InstanceType: "g6-standard-1", + Image: "linode/debian12", + ImageRegions: []string{"us-ord", "us-mia", "us-lax"}, + AuthorizedKeys: []string{"ssh-rsa AAAA..."}, } warnings, err := config.Prepare() diff --git a/builder/linode/step_create_disk_config.go b/builder/linode/step_create_disk_config.go index a2b8162..8b88f5c 100644 --- a/builder/linode/step_create_disk_config.go +++ b/builder/linode/step_create_disk_config.go @@ -24,6 +24,7 @@ func flattenDisk(d Disk) linodego.InstanceDiskCreateOptions { Size: d.Size, Image: d.Image, Filesystem: d.Filesystem, + RootPass: d.RootPass, AuthorizedKeys: d.AuthorizedKeys, AuthorizedUsers: d.AuthorizedUsers, StackscriptID: d.StackscriptID, @@ -288,9 +289,8 @@ func (s *stepCreateDiskConfig) Run(ctx context.Context, state multistep.StateBag } } - if diskOpts.RootPass == "" && diskOpts.Image != "" { - diskOpts.RootPass = c.Comm.Password() - } + // Note: Each disk with an image must define its own auth method (root_pass, authorized_keys, or authorized_users) + // This is validated in config.go - we don't fall back to any default here disk, err := s.client.CreateInstanceDisk(ctx, instance.ID, diskOpts) if err != nil { diff --git a/builder/linode/step_create_linode.go b/builder/linode/step_create_linode.go index 888c58d..e7202d8 100644 --- a/builder/linode/step_create_linode.go +++ b/builder/linode/step_create_linode.go @@ -131,7 +131,9 @@ func flattenVLANInterface(vlan *VLANInterface) *linodego.VLANInterface { } func flattenLinodeInterface(li LinodeInterface) (opts linodego.LinodeInterfaceCreateOptions) { - opts.FirewallID = li.FirewallID + if li.FirewallID != nil { + opts.FirewallID = li.FirewallID + } if li.DefaultRoute != nil { opts.DefaultRoute = &linodego.InterfaceDefaultRoute{ @@ -185,12 +187,19 @@ func (s *stepCreateLinode) Run(ctx context.Context, state multistep.StateBag) mu // Only set image-related options when NOT using custom disks if !useCustomDisks { - createOpts.RootPass = c.Comm.Password() + // Use c.RootPass directly instead of c.Comm.Password() to respect the user's choice + // If root_pass is not provided, the API will accept it when authorized_keys or authorized_users is set + createOpts.RootPass = c.RootPass createOpts.Image = c.Image createOpts.SwapSize = c.SwapSize + createOpts.BootSize = c.BootSize createOpts.StackScriptID = c.StackScriptID createOpts.StackScriptData = c.StackScriptData + if c.Kernel != "" { + createOpts.Kernel = &c.Kernel + } + if pubKey := string(c.Comm.SSHPublicKey); pubKey != "" { createOpts.AuthorizedKeys = append(createOpts.AuthorizedKeys, pubKey) } diff --git a/go.mod b/go.mod index 8b76716..e50a899 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ toolchain go1.25.7 require ( github.com/hashicorp/hcl/v2 v2.24.0 github.com/hashicorp/packer-plugin-sdk v0.6.7 - github.com/linode/linodego v1.68.0 + github.com/linode/linodego v1.69.0 github.com/mitchellh/mapstructure v1.5.0 github.com/zclconf/go-cty v1.16.3 golang.org/x/crypto v0.50.0 @@ -147,7 +147,7 @@ require ( google.golang.org/genproto/googleapis/rpc v0.0.0-20260311181403-84a4fc48630c // indirect google.golang.org/grpc v1.79.3 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/ini.v1 v1.67.1 // indirect + gopkg.in/ini.v1 v1.67.2 // indirect ) // Temporary replacement for issue causing releases to fail (see: #379) diff --git a/go.sum b/go.sum index 50e9a85..489c5e9 100644 --- a/go.sum +++ b/go.sum @@ -287,8 +287,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/linode/linodego v1.68.0 h1:lAsXuHm/cwQT3KCbVpMGtRiH8IpQl4hUuBOXpqkuNwo= -github.com/linode/linodego v1.68.0/go.mod h1:X7nmTNq1GmZT4bG6w9aiuVrOnhVxYaywrzxM+buC/qU= +github.com/linode/linodego v1.69.0 h1:5Qsv6kwoTwwOBETfgietEg5UcaKg1bQEN0DDYc2wIeM= +github.com/linode/linodego v1.69.0/go.mod h1:QPzvRKCy9oz1lRaIACPRORoAI9AAfKKg2AR5mXujoB0= github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786 h1:2ZKn+w/BJeL43sCxI2jhPLRv73oVVOjEKZjKkflyqxg= github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786/go.mod h1:kCEbxUJlNDEBNbdQMkPSp6yaKcRXVI6f4ddk8Riv4bc= github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321 h1:AKIJL2PfBX2uie0Mn5pxtG1+zut3hAVMZbRfoXecFzI= @@ -542,8 +542,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= -gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss= +gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=