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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .web-docs/components/builder/googlecompute/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,10 @@ builder.

Refer to the [Node Affinity](#node-affinities) for more information on affinities.

- `reservation_affinity` (\*common.ReservationAffinity) - ReservationAffinity: Specifies the reservations that this instance can consume from.

- `specific_reservation_required` (bool) - If you are using a reservation, you must set this to true.

- `state_timeout` (duration string | ex: "1h5m2s") - The time to wait for instance state changes. Defaults to "5m".

- `region` (string) - The region in which to launch the instance. Defaults to the region
Expand Down
2 changes: 1 addition & 1 deletion GNUmakefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ dev:
@go build -ldflags="-X '${PLUGIN_FQN}/version.VersionPrerelease=dev'" -o '${BINARY}'
packer plugins install --path ${BINARY} "$(shell echo "${PLUGIN_FQN}" | sed 's/packer-plugin-//')"

test:
test:
@go test -race -count $(COUNT) $(TEST) -timeout=3m

install-packer-sdc: ## Install packer sofware development command
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,23 @@ documentation located in the [`docs/`](docs) directory.
fix a bug, please do so by opening a Pull Request in this GitHub repository.
In case of feature contribution, we kindly ask you to open an issue to
discuss it beforehand.

### Running Acceptance Tests

To run the acceptance tests locally, you need to set the `GOOGLE_PROJECT_ID` environment variable. This variable specifies the Google Cloud project where the tests will provision resources.

You can set it directly when invoking the `make testacc` command:

```bash
GOOGLE_PROJECT_ID="your-gcp-project-id" make testacc
```

Alternatively, you can export it in your shell session before running `make testacc`:

```bash
export GOOGLE_PROJECT_ID="your-gcp-project-id"
make testacc
```

Note that `make` may not always inherit environment variables exported in the current shell. If you encounter issues, explicitly setting the variable in the `make` command is recommended.

109 changes: 109 additions & 0 deletions builder/googlecompute/builder_acc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package googlecompute

import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
Expand All @@ -18,6 +19,7 @@ import (

"github.com/hashicorp/packer-plugin-googlecompute/lib/common"
"github.com/hashicorp/packer-plugin-sdk/acctest"
"google.golang.org/api/compute/v1"
)

//go:embed testdata
Expand Down Expand Up @@ -586,3 +588,110 @@ func TestAccBuilder_CustomEndpointsAndUniverse(t *testing.T) {
})
}
}

func TestAccBuilder_WithReservation(t *testing.T) {
t.Parallel()

// Use a timestamp to generate a unique name.
uniqueID := fmt.Sprintf("packer-test-%d", time.Now().UnixNano())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Keep this name similar to that of the test name.

reservationName := uniqueID
imageName := uniqueID

tmpl, err := testDataFs.ReadFile("testdata/reservation.pkr.hcl")
if err != nil {
t.Fatalf("failed to read testdata file: %s", err)
}

testCase := &acctest.PluginTestCase{
Name: "googlecompute-packer-with-reservation",
Template: string(tmpl),
BuildExtraArgs: []string{
"-var", fmt.Sprintf("project_id=%s", os.Getenv("GOOGLE_PROJECT_ID")),
"-var", fmt.Sprintf("image_name=%s", imageName),
"-var", fmt.Sprintf("reservation_name=%s", reservationName),
},
Setup: func() error {
projectID := os.Getenv("GOOGLE_PROJECT_ID")
if projectID == "" {
return fmt.Errorf("GOOGLE_PROJECT_ID environment variable not set")
}
// The firewall rule is required for Packer to be able to SSH into the instance.
ctx := context.Background()
computeService, err := compute.NewService(ctx)
if err != nil {
return fmt.Errorf("failed to create compute service: %s", err)
}

firewall := &compute.Firewall{
Name: "packer-test",
Allowed: []*compute.FirewallAllowed{
{
IPProtocol: "tcp",
Ports: []string{"22"},
},
},
Network: "global/networks/default",
TargetTags: []string{"packer-test"},
}
_, err = computeService.Firewalls.Insert(projectID, firewall).Context(ctx).Do()
if err != nil {
return fmt.Errorf("failed to create firewall rule: %s", err)
}
reservation := &compute.Reservation{
Name: reservationName,
SpecificReservation: &compute.AllocationSpecificSKUReservation{
Count: 1,
InstanceProperties: &compute.AllocationSpecificSKUAllocationReservedInstanceProperties{
MachineType: "n1-standard-1",
},
},
SpecificReservationRequired: true,
Zone: "us-central1-a",
}
_, err = computeService.Reservations.Insert(projectID, "us-central1-a", reservation).Context(ctx).Do()
if err != nil {
return fmt.Errorf("failed to create reservation: %s", err)
}
return nil
}, Teardown: func() error {
projectID := os.Getenv("GOOGLE_PROJECT_ID")
if projectID == "" {
return fmt.Errorf("GOOGLE_PROJECT_ID environment variable not set")
}
defer os.Unsetenv("GOOGLE_PROJECT_ID")

Copilot AI Jan 3, 2026

Copy link

Choose a reason for hiding this comment

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

The defer os.Unsetenv(\"GOOGLE_PROJECT_ID\") statement will unset the environment variable after teardown completes, but this could interfere with other parallel tests that depend on this variable. Since the test is marked with t.Parallel(), this may cause race conditions. Remove this line as the environment variable should remain set for the duration of the test suite.

Suggested change
defer os.Unsetenv("GOOGLE_PROJECT_ID")

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please look into this @lornaluo

ctx := context.Background()
computeService, err := compute.NewService(ctx)
if err != nil {
return fmt.Errorf("failed to create compute service: %s", err)
}
_, err = computeService.Firewalls.Delete(projectID, "packer-test").Context(ctx).Do()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Once the build completes, is there a way to verify if the reservation was actually utilized? Currently we seem to only be checking for build success.

if err != nil {
// Don't fail teardown if the firewall rule is already gone.
fmt.Printf("failed to delete firewall rule: %s", err)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Lets use t.Logf instead to ensure output is associated with the test

}

_, err = computeService.Reservations.Delete(projectID, "us-central1-a", reservationName).Context(ctx).Do()
if err != nil {
// Don't fail teardown if the reservation is already gone.
fmt.Printf("failed to delete reservation: %s", err)
}

_, err = computeService.Images.Delete(projectID, imageName).Context(ctx).Do()
if err != nil {
// Don't fail teardown if the image is already gone.
fmt.Printf("failed to delete image: %s", err)
}

return nil
},
Check: func(buildCommand *exec.Cmd, logfile string) error {
if buildCommand.ProcessState != nil {
if buildCommand.ProcessState.ExitCode() != 0 {
return fmt.Errorf("Bad exit code. Logfile: %s", logfile)
}
}
return nil
},
}
acctest.TestPlugin(t, testCase)
}
4 changes: 4 additions & 0 deletions builder/googlecompute/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,10 @@ type Config struct {
//
// Refer to the [Node Affinity](#node-affinities) for more information on affinities.
NodeAffinities []common.NodeAffinity `mapstructure:"node_affinity" required:"false"`
// ReservationAffinity: Specifies the reservations that this instance can consume from.
ReservationAffinity *common.ReservationAffinity `mapstructure:"reservation_affinity" required:"false"`
// If you are using a reservation, you must set this to true.

Copilot AI Jan 3, 2026

Copy link

Choose a reason for hiding this comment

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

The comment is incomplete and unclear about when this field should be set to true. It should explain that this field must be set to true when using a SPECIFIC_RESERVATION type reservation affinity, and provide context about its relationship to the reservation_affinity field.

Suggested change
// If you are using a reservation, you must set this to true.
// SpecificReservationRequired: When using ReservationAffinity with type
// SPECIFIC_RESERVATION, this field must be set to true to require that the
// instance consume capacity only from the specified reservation. This works
// together with the `reservation_affinity` configuration; if
// `reservation_affinity.type` is SPECIFIC_RESERVATION and this flag is false,
// the instance may not be constrained to that specific reservation.

Copilot uses AI. Check for mistakes.
SpecificReservationRequired bool `mapstructure:"specific_reservation_required" required:"false"`
// The time to wait for instance state changes. Defaults to "5m".
StateTimeout time.Duration `mapstructure:"state_timeout" required:"false"`
// The region in which to launch the instance. Defaults to the region
Expand Down
4 changes: 4 additions & 0 deletions builder/googlecompute/config.hcl2spec.go

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

42 changes: 42 additions & 0 deletions builder/googlecompute/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,48 @@ func TestLabelsValidity(t *testing.T) {
}
}

func TestConfig_ReservationAffinity(t *testing.T) {
// Get a standard, valid base configuration
raw, tempfile := testConfig(t)
defer os.Remove(tempfile)

// Add the reservation_affinity block to the configuration
raw["reservation_affinity"] = map[string]interface{}{
"consume_reservation_type": "SPECIFIC_RESERVATION",
"key": "compute.googleapis.com/reservation-name",
"values": []string{"test-reservation"},
}

// Prepare the configuration, which parses the raw map into the Config struct
var c Config
warns, errs := c.Prepare(raw)

// Use the existing helper to assert that there were no errors
testConfigOk(t, warns, errs)

// --- Verification ---
// Now, check that the ReservationAffinity struct was populated correctly.

if c.ReservationAffinity == nil {
t.Fatal("ReservationAffinity should not be nil after preparing config")
}

if c.ReservationAffinity.ConsumeReservationType != "SPECIFIC_RESERVATION" {
t.Errorf("expected ConsumeReservationType to be 'SPECIFIC_RESERVATION', but got '%s'",
c.ReservationAffinity.ConsumeReservationType)
}

if c.ReservationAffinity.Key != "compute.googleapis.com/reservation-name" {
t.Errorf("expected Key to be 'compute.googleapis.com/reservation-name', but got '%s'",
c.ReservationAffinity.Key)
}

if len(c.ReservationAffinity.Values) != 1 || c.ReservationAffinity.Values[0] != "test-reservation" {
t.Errorf("expected Values to be ['test-reservation'], but got '%v'",
c.ReservationAffinity.Values)
}
}

// Helper stuff below

func testConfig(t *testing.T) (config map[string]interface{}, tempAccountFile string) {
Expand Down
2 changes: 2 additions & 0 deletions builder/googlecompute/step_create_instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ func (s *StepCreateInstance) Run(ctx context.Context, state multistep.StateBag)
Preemptible: c.Preemptible,
NodeAffinities: c.NodeAffinities,
Region: c.Region,
ReservationAffinity: c.ReservationAffinity,
SpecificReservationRequired: c.SpecificReservationRequired,
ServiceAccountEmail: c.ServiceAccountEmail,
Scopes: c.Scopes,
Subnetwork: c.Subnetwork,
Expand Down
32 changes: 32 additions & 0 deletions builder/googlecompute/testdata/reservation.pkr.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
variable "image_name" {
type = string
}

variable "reservation_name" {
type = string
}

variable "project_id" {
type = string
}

source "googlecompute" "reservation-test" {
project_id = var.project_id
source_image_family = "ubuntu-2204-lts"
source_image_project_id = ["ubuntu-os-cloud"]
zone = "us-central1-a"
tags = ["packer-test"]
network = "default"
ssh_username = "packer"
machine_type = "n1-standard-1"
reservation_affinity {
consume_reservation_type = "SPECIFIC_RESERVATION"
key = "compute.googleapis.com/reservation-name"
values = [var.reservation_name]
}
specific_reservation_required = true
}

build {
sources = ["source.googlecompute.reservation-test"]
}
4 changes: 4 additions & 0 deletions docs-partials/builder/googlecompute/Config-not-required.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,10 @@

Refer to the [Node Affinity](#node-affinities) for more information on affinities.

- `reservation_affinity` (\*common.ReservationAffinity) - ReservationAffinity: Specifies the reservations that this instance can consume from.

- `specific_reservation_required` (bool) - If you are using a reservation, you must set this to true.

- `state_timeout` (duration string | ex: "1h5m2s") - The time to wait for instance state changes. Defaults to "5m".

- `region` (string) - The region in which to launch the instance. Defaults to the region
Expand Down
13 changes: 13 additions & 0 deletions docs-partials/lib/common/ReservationAffinity-not-required.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!-- Code generated from the comments of the ReservationAffinity struct in lib/common/reservation_affinity.go; DO NOT EDIT MANUALLY -->

- `consume_reservation_type` (string) - ConsumeReservationType: Specifies the type of reservation from which this
instance can consume resources.
See https://cloud.google.com/compute/docs/instances/reservations-overview for examples.

- `key` (string) - Key: Corresponds to the label key of a reservation resource. To target a
SPECIFIC_RESERVATION by name, specify `compute.googleapis.com/reservation-name`
as the key.

- `values` ([]string) - Values: Corresponds to the label values of a reservation resource.

<!-- End of code generated from the comments of the ReservationAffinity struct in lib/common/reservation_affinity.go; -->
6 changes: 6 additions & 0 deletions docs-partials/lib/common/ReservationAffinity.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<!-- Code generated from the comments of the ReservationAffinity struct in lib/common/reservation_affinity.go; DO NOT EDIT MANUALLY -->

ReservationAffinity is the configuration structure for instance reservation
affinity. It allows you to consume a specific reservation.

<!-- End of code generated from the comments of the ReservationAffinity struct in lib/common/reservation_affinity.go; -->
5 changes: 5 additions & 0 deletions lib/common/driver_gce.go
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,11 @@ func (d *driverGCE) RunInstance(c *InstanceConfig) (<-chan error, error) {
},
}

if c.ReservationAffinity != nil {
log.Printf("[DEBUG] setting reservation affinity to %s", c.ReservationAffinity)
instance.ReservationAffinity = c.ReservationAffinity.ComputeType()
}

if c.MaxRunDurationInSeconds > 0 {
log.Printf("[DEBUG] setting max run duration to %d seconds", c.MaxRunDurationInSeconds)
instance.Scheduling.MaxRunDuration = &compute.Duration{
Expand Down
2 changes: 2 additions & 0 deletions lib/common/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ type InstanceConfig struct {
InstanceTerminationAction string
Preemptible bool
NodeAffinities []NodeAffinity
ReservationAffinity *ReservationAffinity
SpecificReservationRequired bool
Region string
ServiceAccountEmail string
Scopes []string
Expand Down
39 changes: 39 additions & 0 deletions lib/common/reservation_affinity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0

//go:generate packer-sdc struct-markdown
//go:generate packer-sdc mapstructure-to-hcl2 -type ReservationAffinity

package common

import compute "google.golang.org/api/compute/v1"

// ReservationAffinity is the configuration structure for instance reservation
// affinity. It allows you to consume a specific reservation.
type ReservationAffinity struct {
// ConsumeReservationType: Specifies the type of reservation from which this
// instance can consume resources.
// See https://cloud.google.com/compute/docs/instances/reservations-overview for examples.
ConsumeReservationType string `mapstructure:"consume_reservation_type"`

// Key: Corresponds to the label key of a reservation resource. To target a
// SPECIFIC_RESERVATION by name, specify `compute.googleapis.com/reservation-name`
// as the key.
Key string `mapstructure:"key"`

// Values: Corresponds to the label values of a reservation resource.
Values []string `mapstructure:"values"`
}

// ComputeType converts the Packer-specific ReservationAffinity struct to the
// type required by the Google Cloud API client library.
func (r *ReservationAffinity) ComputeType() *compute.ReservationAffinity {
if r == nil {
return nil
}
return &compute.ReservationAffinity{
ConsumeReservationType: r.ConsumeReservationType,
Key: r.Key,
Values: r.Values,
}
}
Loading