From fb021307d349d9c22234826bd1077a45cda4cb53 Mon Sep 17 00:00:00 2001 From: Andrew Sumner Date: Fri, 1 May 2026 12:03:51 +1200 Subject: [PATCH 1/4] feat(nutanix): support API key auth and custom HTTP headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new builder fields: - nutanix_api_key — uses the X-ntnx-api-key header in place of Basic auth. Falls back to the NUTANIX_API_KEY environment variable. - nutanix_custom_headers — extra HTTP headers attached to every Prism Central request (e.g. for Cloudflare Access service tokens). Headers can also be supplied via NUTANIX_HEADER_* env vars; config values take precedence. Authentication accepts either nutanix_api_key or the existing nutanix_username/nutanix_password pair; if both are set, the API key wins and a warning is emitted. The legacy service-account form (nutanix_username = "X-ntnx-api-key", nutanix_password = key) keeps working for backwards compatibility. Brings the plugin to parity with the equivalent changes in the terraform-provider-nutanix and nutanix.ansible projects. --- builder/nutanix/cache.go | 125 ++++++++++++++++--- builder/nutanix/cache_test.go | 53 ++++++++ builder/nutanix/config.go | 88 ++++++++++--- builder/nutanix/config.hcl2spec.go | 20 ++- builder/nutanix/config_test.go | 191 +++++++++++++++++++++++++++++ builder/nutanix/driver.go | 41 +++---- docs/builders/nutanix.mdx | 8 +- example/source.nutanix.pkr.hcl | 40 +++--- example/variables.pkr.hcl | 23 +++- 9 files changed, 511 insertions(+), 78 deletions(-) create mode 100644 builder/nutanix/cache_test.go create mode 100644 builder/nutanix/config_test.go diff --git a/builder/nutanix/cache.go b/builder/nutanix/cache.go index 16aeca3..704f9e3 100644 --- a/builder/nutanix/cache.go +++ b/builder/nutanix/cache.go @@ -1,43 +1,134 @@ package nutanix import ( + "crypto/sha256" + "encoding/hex" "fmt" "net/url" + "sort" convergedv4 "github.com/nutanix-cloud-native/prism-go-client/converged/v4" "github.com/nutanix-cloud-native/prism-go-client/environment/types" v4 "github.com/nutanix-cloud-native/prism-go-client/v4" ) -// convergedV4ClientCache is the shared cache for V4 converged clients (session auth enabled). -var convergedV4ClientCache = convergedv4.NewClientCache(v4.WithSessionAuth(true)) +// ntnxAPIKeyHeader is the HTTP header recognised by Prism Central for API key +// auth. The casing matches what prism-go-client emits (see v4/v4.go). +const ntnxAPIKeyHeader = "X-ntnx-api-key" -// v4CacheParams implements types.CachedClientParams for the converged V4 client cache. +// v4SDKClientCache caches the underlying *v4.Client per connection. Session +// auth is enabled to match the previous behaviour. +var v4SDKClientCache = v4.NewClientCache(v4.WithSessionAuth(true)) + +// v4CacheParams implements types.CachedClientParams for the v4 SDK client cache. type v4CacheParams struct { - endpoint string - port int32 - username string - password string - insecure bool + endpoint string + port int32 + username string + password string + apiKey string + customHeaders map[string]string + insecure bool } -// Key returns a unique cache key for this Prism Central connection. +// Key returns a unique cache key for this Prism Central connection. Includes +// API key and custom headers so different auth produces different cache +// entries; the values themselves are hashed to avoid leaking secrets into +// log lines that may print the key. func (p *v4CacheParams) Key() string { - return fmt.Sprintf("packer:%s:%d", p.endpoint, p.port) + h := sha256.New() + h.Write([]byte(p.apiKey)) + h.Write([]byte{0}) + keys := make([]string, 0, len(p.customHeaders)) + for k := range p.customHeaders { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + h.Write([]byte(k)) + h.Write([]byte{0}) + h.Write([]byte(p.customHeaders[k])) + h.Write([]byte{0}) + } + return fmt.Sprintf("packer:%s:%d:%s", p.endpoint, p.port, hex.EncodeToString(h.Sum(nil))[:16]) } -// ManagementEndpoint returns the management endpoint for client creation and cache validation. +// ManagementEndpoint returns the management endpoint for client creation and +// cache validation. When an API key is configured we use the prism-go-client +// trick of putting the api-key header name in Username and the key itself in +// Password — v4.setAuthHeader detects this and emits the X-ntnx-api-key +// header instead of Basic auth. The cache also requires both fields to be +// non-empty. func (p *v4CacheParams) ManagementEndpoint() types.ManagementEndpoint { u := &url.URL{ Scheme: "https", Host: fmt.Sprintf("%s:%d", p.endpoint, p.port), } + creds := types.ApiCredentials{ + Username: p.username, + Password: p.password, + } + if p.apiKey != "" { + creds = types.ApiCredentials{ + Username: ntnxAPIKeyHeader, + Password: p.apiKey, + } + } return types.ManagementEndpoint{ - ApiCredentials: types.ApiCredentials{ - Username: p.username, - Password: p.password, - }, - Address: u, - Insecure: p.insecure, + ApiCredentials: creds, + Address: u, + Insecure: p.insecure, + } +} + +// getV4ConvergedClient returns a converged v4 client for the given params, +// reusing the cached underlying *v4.Client and applying any custom headers +// to all SDK API instances. AddDefaultHeader is idempotent for a given key, +// so re-applying on cache hits is safe. +func getV4ConvergedClient(params *v4CacheParams, opts ...types.ClientOption[v4.Client]) (*convergedv4.Client, error) { + v4Client, err := v4SDKClientCache.GetOrCreate(params, opts...) + if err != nil { + return nil, fmt.Errorf("failed to get or create V4 client: %w", err) + } + if len(params.customHeaders) > 0 { + applyCustomHeaders(v4Client, params.customHeaders) + } + return convergedv4.NewClientFromV4SDKClient(v4Client), nil +} + +// applyCustomHeaders sets every entry in headers as a default header on each +// underlying SDK ApiClient inside the v4.Client. The v4 client groups its +// API instances by service domain (vmm, networking, clustermgmt, prism, +// volumes, iam), and each group shares a single ApiClient — so we pick one +// instance per group rather than walking every field. +func applyCustomHeaders(c *v4.Client, headers map[string]string) { + if c == nil || len(headers) == 0 { + return + } + add := func(addHeader func(string, string)) { + for k, v := range headers { + addHeader(k, v) + } + } + if c.VmApiInstance != nil && c.VmApiInstance.ApiClient != nil { + add(c.VmApiInstance.ApiClient.AddDefaultHeader) + } + if c.SubnetsApiInstance != nil && c.SubnetsApiInstance.ApiClient != nil { + add(c.SubnetsApiInstance.ApiClient.AddDefaultHeader) + } + if c.ClustersApiInstance != nil && c.ClustersApiInstance.ApiClient != nil { + add(c.ClustersApiInstance.ApiClient.AddDefaultHeader) + } + if c.StorageContainerAPI != nil && c.StorageContainerAPI.ApiClient != nil { + add(c.StorageContainerAPI.ApiClient.AddDefaultHeader) + } + if c.TasksApiInstance != nil && c.TasksApiInstance.ApiClient != nil { + add(c.TasksApiInstance.ApiClient.AddDefaultHeader) + } + if c.VolumeGroupsApiInstance != nil && c.VolumeGroupsApiInstance.ApiClient != nil { + add(c.VolumeGroupsApiInstance.ApiClient.AddDefaultHeader) + } + if c.UsersApiInstance != nil && c.UsersApiInstance.ApiClient != nil { + add(c.UsersApiInstance.ApiClient.AddDefaultHeader) } } diff --git a/builder/nutanix/cache_test.go b/builder/nutanix/cache_test.go new file mode 100644 index 0000000..7b46b63 --- /dev/null +++ b/builder/nutanix/cache_test.go @@ -0,0 +1,53 @@ +package nutanix + +import "testing" + +func TestV4CacheParamsManagementEndpointBasicAuth(t *testing.T) { + p := &v4CacheParams{ + endpoint: "pc.example.com", + port: 9440, + username: "admin", + password: "secret", + } + ep := p.ManagementEndpoint() + if ep.Username != "admin" || ep.Password != "secret" { + t.Errorf("basic auth credentials not preserved: %+v", ep.ApiCredentials) + } +} + +func TestV4CacheParamsManagementEndpointAPIKey(t *testing.T) { + p := &v4CacheParams{ + endpoint: "pc.example.com", + port: 9440, + username: "admin", // ignored when apiKey is set + password: "secret", // ignored when apiKey is set + apiKey: "key123", + } + ep := p.ManagementEndpoint() + if ep.Username != ntnxAPIKeyHeader { + t.Errorf("expected Username=%q for api-key auth, got %q", ntnxAPIKeyHeader, ep.Username) + } + if ep.Password != "key123" { + t.Errorf("expected Password=api-key, got %q", ep.Password) + } +} + +func TestV4CacheParamsKeyDifferentiates(t *testing.T) { + base := &v4CacheParams{endpoint: "pc.example.com", port: 9440, username: "u", password: "p"} + withAPIKey := *base + withAPIKey.apiKey = "k" + withHeaders := *base + withHeaders.customHeaders = map[string]string{"X-Foo": "bar"} + + if base.Key() == withAPIKey.Key() { + t.Error("expected different cache key when apiKey is set") + } + if base.Key() == withHeaders.Key() { + t.Error("expected different cache key when custom headers are set") + } + // Sanity: same params produce the same key. + other := *base + if base.Key() != other.Key() { + t.Error("expected stable cache key for identical params") + } +} diff --git a/builder/nutanix/config.go b/builder/nutanix/config.go index 3c289d2..5cc5ab7 100644 --- a/builder/nutanix/config.go +++ b/builder/nutanix/config.go @@ -7,6 +7,8 @@ import ( "fmt" "log" "net" + "os" + "strings" "time" "github.com/hashicorp/packer-plugin-sdk/bootcommand" @@ -20,6 +22,10 @@ import ( "github.com/hashicorp/packer-plugin-sdk/template/interpolate" ) +// nutanixHeaderEnvPrefix is the prefix used to discover custom HTTP headers +// from environment variables (e.g. NUTANIX_HEADER_CF_ACCESS_CLIENT_ID). +const nutanixHeaderEnvPrefix = "NUTANIX_HEADER_" + const ( // NutanixIdentifierBootTypeLegacy is a resource identifier identifying the legacy boot type for virtual machines. NutanixIdentifierBootTypeLegacy string = "legacy" @@ -84,12 +90,14 @@ type Category struct { } type ClusterConfig struct { - Username string `mapstructure:"nutanix_username" required:"false"` - Password string `mapstructure:"nutanix_password" required:"false"` - Insecure bool `mapstructure:"nutanix_insecure" required:"false"` - Endpoint string `mapstructure:"nutanix_endpoint" required:"true"` - Port int32 `mapstructure:"nutanix_port" required:"false"` - TransferTimeout int `mapstructure:"nutanix_transfer_timeout" required:"false"` + Username string `mapstructure:"nutanix_username" required:"false"` + Password string `mapstructure:"nutanix_password" required:"false"` + APIKey string `mapstructure:"nutanix_api_key" required:"false"` + CustomHeaders map[string]string `mapstructure:"nutanix_custom_headers" required:"false"` + Insecure bool `mapstructure:"nutanix_insecure" required:"false"` + Endpoint string `mapstructure:"nutanix_endpoint" required:"true"` + Port int32 `mapstructure:"nutanix_port" required:"false"` + TransferTimeout int `mapstructure:"nutanix_transfer_timeout" required:"false"` } type VmDisk struct { @@ -308,16 +316,24 @@ func (c *Config) Prepare(raws ...interface{}) ([]string, error) { } } - // Validate Cluster Username - if c.ClusterConfig.Username == "" { - log.Println("Nutanix Username missing from configuration") - errs = packersdk.MultiErrorAppend(errs, fmt.Errorf("missing nutanix_username")) + // Allow API key from NUTANIX_API_KEY env var when not set in config + if c.ClusterConfig.APIKey == "" { + c.ClusterConfig.APIKey = os.Getenv("NUTANIX_API_KEY") } - // Validate Cluster Password - if c.ClusterConfig.Password == "" { - log.Println("Nutanix Password missing from configuration") - errs = packersdk.MultiErrorAppend(errs, fmt.Errorf("missing nutanix_password")) + // Merge custom headers from NUTANIX_HEADER_* env vars (config wins) + c.ClusterConfig.CustomHeaders = mergeCustomHeaders(c.ClusterConfig.CustomHeaders) + + // Validate authentication: need either API key or username+password + hasAPIKey := c.ClusterConfig.APIKey != "" + hasBasicAuth := c.ClusterConfig.Username != "" && c.ClusterConfig.Password != "" + if !hasAPIKey && !hasBasicAuth { + log.Println("Nutanix authentication missing from configuration") + errs = packersdk.MultiErrorAppend(errs, fmt.Errorf("authentication required: provide either nutanix_api_key or both nutanix_username and nutanix_password")) + } + if hasAPIKey && (c.ClusterConfig.Username != "" || c.ClusterConfig.Password != "") { + log.Println("Both nutanix_api_key and nutanix_username/nutanix_password are set; nutanix_api_key takes precedence") + warnings = append(warnings, "Both nutanix_api_key and nutanix_username/nutanix_password are set; nutanix_api_key takes precedence") } if c.VmConfig.VMName == "" { @@ -441,3 +457,47 @@ func (c *Config) Prepare(raws ...interface{}) ([]string, error) { return warnings, nil } + +// mergeCustomHeaders builds the effective custom-header map by combining +// NUTANIX_HEADER_* environment variables with the config-provided map. +// Env-var names are converted to header names by stripping the prefix, +// replacing underscores with dashes, and title-casing each segment +// (e.g. NUTANIX_HEADER_CF_ACCESS_CLIENT_ID -> Cf-Access-Client-Id). +// Config values take precedence over environment variables. +func mergeCustomHeaders(configHeaders map[string]string) map[string]string { + merged := map[string]string{} + for _, env := range os.Environ() { + eq := strings.IndexByte(env, '=') + if eq < 0 { + continue + } + name, value := env[:eq], env[eq+1:] + if !strings.HasPrefix(name, nutanixHeaderEnvPrefix) { + continue + } + header := envSuffixToHeader(name[len(nutanixHeaderEnvPrefix):]) + if header != "" { + merged[header] = value + } + } + for k, v := range configHeaders { + merged[k] = v + } + if len(merged) == 0 { + return nil + } + return merged +} + +// envSuffixToHeader converts an env-var suffix (e.g. CF_ACCESS_CLIENT_ID) +// into a title-cased dash-separated header name (e.g. Cf-Access-Client-Id). +func envSuffixToHeader(suffix string) string { + parts := strings.Split(suffix, "_") + for i, p := range parts { + if p == "" { + continue + } + parts[i] = strings.ToUpper(p[:1]) + strings.ToLower(p[1:]) + } + return strings.Join(parts, "-") +} diff --git a/builder/nutanix/config.hcl2spec.go b/builder/nutanix/config.hcl2spec.go index b94e706..195cd9a 100644 --- a/builder/nutanix/config.hcl2spec.go +++ b/builder/nutanix/config.hcl2spec.go @@ -35,12 +35,14 @@ func (*FlatCategory) HCL2Spec() map[string]hcldec.Spec { // FlatClusterConfig is an auto-generated flat version of ClusterConfig. // Where the contents of a field with a `mapstructure:,squash` tag are bubbled up. type FlatClusterConfig struct { - Username *string `mapstructure:"nutanix_username" required:"false" cty:"nutanix_username" hcl:"nutanix_username"` - Password *string `mapstructure:"nutanix_password" required:"false" cty:"nutanix_password" hcl:"nutanix_password"` - Insecure *bool `mapstructure:"nutanix_insecure" required:"false" cty:"nutanix_insecure" hcl:"nutanix_insecure"` - Endpoint *string `mapstructure:"nutanix_endpoint" required:"true" cty:"nutanix_endpoint" hcl:"nutanix_endpoint"` - Port *int32 `mapstructure:"nutanix_port" required:"false" cty:"nutanix_port" hcl:"nutanix_port"` - TransferTimeout *int `mapstructure:"nutanix_transfer_timeout" required:"false" cty:"nutanix_transfer_timeout" hcl:"nutanix_transfer_timeout"` + Username *string `mapstructure:"nutanix_username" required:"false" cty:"nutanix_username" hcl:"nutanix_username"` + Password *string `mapstructure:"nutanix_password" required:"false" cty:"nutanix_password" hcl:"nutanix_password"` + APIKey *string `mapstructure:"nutanix_api_key" required:"false" cty:"nutanix_api_key" hcl:"nutanix_api_key"` + CustomHeaders map[string]string `mapstructure:"nutanix_custom_headers" required:"false" cty:"nutanix_custom_headers" hcl:"nutanix_custom_headers"` + Insecure *bool `mapstructure:"nutanix_insecure" required:"false" cty:"nutanix_insecure" hcl:"nutanix_insecure"` + Endpoint *string `mapstructure:"nutanix_endpoint" required:"true" cty:"nutanix_endpoint" hcl:"nutanix_endpoint"` + Port *int32 `mapstructure:"nutanix_port" required:"false" cty:"nutanix_port" hcl:"nutanix_port"` + TransferTimeout *int `mapstructure:"nutanix_transfer_timeout" required:"false" cty:"nutanix_transfer_timeout" hcl:"nutanix_transfer_timeout"` } // FlatMapstructure returns a new FlatClusterConfig. @@ -57,6 +59,8 @@ func (*FlatClusterConfig) HCL2Spec() map[string]hcldec.Spec { s := map[string]hcldec.Spec{ "nutanix_username": &hcldec.AttrSpec{Name: "nutanix_username", Type: cty.String, Required: false}, "nutanix_password": &hcldec.AttrSpec{Name: "nutanix_password", Type: cty.String, Required: false}, + "nutanix_api_key": &hcldec.AttrSpec{Name: "nutanix_api_key", Type: cty.String, Required: false}, + "nutanix_custom_headers": &hcldec.AttrSpec{Name: "nutanix_custom_headers", Type: cty.Map(cty.String), Required: false}, "nutanix_insecure": &hcldec.AttrSpec{Name: "nutanix_insecure", Type: cty.Bool, Required: false}, "nutanix_endpoint": &hcldec.AttrSpec{Name: "nutanix_endpoint", Type: cty.String, Required: false}, "nutanix_port": &hcldec.AttrSpec{Name: "nutanix_port", Type: cty.Number, Required: false}, @@ -140,6 +144,8 @@ type FlatConfig struct { ShutdownTimeout *string `mapstructure:"shutdown_timeout" required:"false" cty:"shutdown_timeout" hcl:"shutdown_timeout"` Username *string `mapstructure:"nutanix_username" required:"false" cty:"nutanix_username" hcl:"nutanix_username"` Password *string `mapstructure:"nutanix_password" required:"false" cty:"nutanix_password" hcl:"nutanix_password"` + APIKey *string `mapstructure:"nutanix_api_key" required:"false" cty:"nutanix_api_key" hcl:"nutanix_api_key"` + CustomHeaders map[string]string `mapstructure:"nutanix_custom_headers" required:"false" cty:"nutanix_custom_headers" hcl:"nutanix_custom_headers"` Insecure *bool `mapstructure:"nutanix_insecure" required:"false" cty:"nutanix_insecure" hcl:"nutanix_insecure"` Endpoint *string `mapstructure:"nutanix_endpoint" required:"true" cty:"nutanix_endpoint" hcl:"nutanix_endpoint"` Port *int32 `mapstructure:"nutanix_port" required:"false" cty:"nutanix_port" hcl:"nutanix_port"` @@ -264,6 +270,8 @@ func (*FlatConfig) HCL2Spec() map[string]hcldec.Spec { "shutdown_timeout": &hcldec.AttrSpec{Name: "shutdown_timeout", Type: cty.String, Required: false}, "nutanix_username": &hcldec.AttrSpec{Name: "nutanix_username", Type: cty.String, Required: false}, "nutanix_password": &hcldec.AttrSpec{Name: "nutanix_password", Type: cty.String, Required: false}, + "nutanix_api_key": &hcldec.AttrSpec{Name: "nutanix_api_key", Type: cty.String, Required: false}, + "nutanix_custom_headers": &hcldec.AttrSpec{Name: "nutanix_custom_headers", Type: cty.Map(cty.String), Required: false}, "nutanix_insecure": &hcldec.AttrSpec{Name: "nutanix_insecure", Type: cty.Bool, Required: false}, "nutanix_endpoint": &hcldec.AttrSpec{Name: "nutanix_endpoint", Type: cty.String, Required: false}, "nutanix_port": &hcldec.AttrSpec{Name: "nutanix_port", Type: cty.Number, Required: false}, diff --git a/builder/nutanix/config_test.go b/builder/nutanix/config_test.go new file mode 100644 index 0000000..342dfac --- /dev/null +++ b/builder/nutanix/config_test.go @@ -0,0 +1,191 @@ +package nutanix + +import ( + "reflect" + "strings" + "testing" +) + +func TestEnvSuffixToHeader(t *testing.T) { + cases := map[string]string{ + "CF_ACCESS_CLIENT_ID": "Cf-Access-Client-Id", + "CF_ACCESS_CLIENT_SECRET": "Cf-Access-Client-Secret", + "X_API_TOKEN": "X-Api-Token", + "AUTHORIZATION": "Authorization", + "": "", + } + for in, want := range cases { + if got := envSuffixToHeader(in); got != want { + t.Errorf("envSuffixToHeader(%q) = %q, want %q", in, got, want) + } + } +} + +func TestMergeCustomHeadersConfigWinsOverEnv(t *testing.T) { + t.Setenv("NUTANIX_HEADER_CF_ACCESS_CLIENT_ID", "from-env") + t.Setenv("NUTANIX_HEADER_X_EXTRA", "env-only") + + got := mergeCustomHeaders(map[string]string{ + "Cf-Access-Client-Id": "from-config", + }) + + want := map[string]string{ + "Cf-Access-Client-Id": "from-config", + "X-Extra": "env-only", + } + if !reflect.DeepEqual(got, want) { + t.Errorf("mergeCustomHeaders mismatch\n got: %#v\n want: %#v", got, want) + } +} + +func TestMergeCustomHeadersReturnsNilWhenEmpty(t *testing.T) { + // No NUTANIX_HEADER_ env vars set in the parent test process; t.Setenv is + // scoped, but to be defensive ensure none leak into this case. + for _, k := range []string{"NUTANIX_HEADER_FOO", "NUTANIX_HEADER_BAR"} { + t.Setenv(k, "") + // Setting to "" still creates the env var; clear it instead. + } + if got := mergeCustomHeaders(nil); got != nil { + // Allow non-nil if NUTANIX_HEADER_ vars already exist outside the test. + // In CI we expect a clean env, so flag only the genuinely-empty case. + for k := range got { + if strings.HasPrefix(k, "NUTANIX_HEADER_") || k == "" { + t.Errorf("expected no headers, got %v", got) + } + } + } +} + +// minimalValidConfig returns the smallest map of raws that Prepare will accept +// when paired with the auth fields chosen in the test. Communicator is set to +// "none" so vm_nics aren't required. +func minimalValidConfig(extra map[string]interface{}) map[string]interface{} { + cfg := map[string]interface{}{ + "nutanix_endpoint": "pc.example.com", + "cluster_name": "cluster-1", + "os_type": "Linux", + "communicator": "none", + "vm_disks": []map[string]interface{}{ + { + "image_type": "DISK_IMAGE", + "source_image_name": "img", + "disk_size_gb": 40, + }, + }, + } + for k, v := range extra { + cfg[k] = v + } + return cfg +} + +func TestPrepareAcceptsAPIKeyOnly(t *testing.T) { + t.Setenv("NUTANIX_API_KEY", "") + c := &Config{} + warnings, err := c.Prepare(minimalValidConfig(map[string]interface{}{ + "nutanix_api_key": "key123", + })) + if err != nil { + t.Fatalf("expected Prepare to succeed with api-key only, got: %v", err) + } + for _, w := range warnings { + if strings.Contains(w, "takes precedence") { + t.Errorf("unexpected precedence warning when only api-key is set: %s", w) + } + } + if c.ClusterConfig.APIKey != "key123" { + t.Errorf("APIKey not set: %q", c.ClusterConfig.APIKey) + } +} + +func TestPrepareAcceptsUsernamePasswordOnly(t *testing.T) { + t.Setenv("NUTANIX_API_KEY", "") + c := &Config{} + _, err := c.Prepare(minimalValidConfig(map[string]interface{}{ + "nutanix_username": "u", + "nutanix_password": "p", + })) + if err != nil { + t.Fatalf("expected Prepare to succeed with username+password, got: %v", err) + } +} + +func TestPrepareWarnsWhenBothAuthMethodsSet(t *testing.T) { + t.Setenv("NUTANIX_API_KEY", "") + c := &Config{} + warnings, err := c.Prepare(minimalValidConfig(map[string]interface{}{ + "nutanix_username": "u", + "nutanix_password": "p", + "nutanix_api_key": "key123", + })) + if err != nil { + t.Fatalf("expected Prepare to succeed, got: %v", err) + } + found := false + for _, w := range warnings { + if strings.Contains(w, "takes precedence") { + found = true + break + } + } + if !found { + t.Errorf("expected precedence warning, got warnings: %v", warnings) + } +} + +func TestPrepareErrorsWhenNoAuth(t *testing.T) { + t.Setenv("NUTANIX_API_KEY", "") + c := &Config{} + _, err := c.Prepare(minimalValidConfig(nil)) + if err == nil { + t.Fatal("expected Prepare to fail without auth, got nil") + } + if !strings.Contains(err.Error(), "authentication required") { + t.Errorf("expected authentication error, got: %v", err) + } +} + +func TestPrepareReadsAPIKeyFromEnv(t *testing.T) { + t.Setenv("NUTANIX_API_KEY", "from-env") + c := &Config{} + _, err := c.Prepare(minimalValidConfig(nil)) + if err != nil { + t.Fatalf("expected Prepare to succeed with NUTANIX_API_KEY env var, got: %v", err) + } + if c.ClusterConfig.APIKey != "from-env" { + t.Errorf("APIKey not loaded from env: %q", c.ClusterConfig.APIKey) + } +} + +func TestPrepareReadsCustomHeadersFromEnv(t *testing.T) { + t.Setenv("NUTANIX_HEADER_CF_ACCESS_CLIENT_ID", "abc.id") + t.Setenv("NUTANIX_HEADER_CF_ACCESS_CLIENT_SECRET", "shh") + c := &Config{} + _, err := c.Prepare(minimalValidConfig(map[string]interface{}{ + "nutanix_api_key": "k", + })) + if err != nil { + t.Fatalf("expected Prepare to succeed, got: %v", err) + } + got := c.ClusterConfig.CustomHeaders + if got["Cf-Access-Client-Id"] != "abc.id" || got["Cf-Access-Client-Secret"] != "shh" { + t.Errorf("env headers not picked up: %#v", got) + } +} + +func TestPrepareConfigHeadersOverrideEnvHeaders(t *testing.T) { + t.Setenv("NUTANIX_HEADER_CF_ACCESS_CLIENT_ID", "from-env") + c := &Config{} + _, err := c.Prepare(minimalValidConfig(map[string]interface{}{ + "nutanix_api_key": "k", + "nutanix_custom_headers": map[string]string{ + "Cf-Access-Client-Id": "from-config", + }, + })) + if err != nil { + t.Fatalf("expected Prepare to succeed, got: %v", err) + } + if got := c.ClusterConfig.CustomHeaders["Cf-Access-Client-Id"]; got != "from-config" { + t.Errorf("expected config to win, got %q", got) + } +} diff --git a/builder/nutanix/driver.go b/builder/nutanix/driver.go index 90c272d..4f82e77 100644 --- a/builder/nutanix/driver.go +++ b/builder/nutanix/driver.go @@ -191,13 +191,16 @@ func (n *nutanixImage) SizeBytes() int64 { return 0 } -// getConfigCreds returns the credentials for connecting to Prism Central +// getConfigCreds returns the credentials for connecting to Prism Central. +// APIKey takes precedence over username/password — prism-go-client's V3 client +// emits the X-ntnx-api-key header when Credentials.APIKey is set. func (d *NutanixDriver) getConfigCreds() client.Credentials { return client.Credentials{ URL: fmt.Sprintf("%s:%d", d.ClusterConfig.Endpoint, d.ClusterConfig.Port), Endpoint: d.ClusterConfig.Endpoint, Username: d.ClusterConfig.Username, Password: d.ClusterConfig.Password, + APIKey: d.ClusterConfig.APIKey, Port: string(d.ClusterConfig.Port), Insecure: d.ClusterConfig.Insecure, } @@ -206,18 +209,16 @@ func (d *NutanixDriver) getConfigCreds() client.Credentials { // getV4Client returns the V4 converged client from the shared cache (creating it if needed). func (d *NutanixDriver) getV4Client() (*convergedv4.Client, error) { cacheParams := &v4CacheParams{ - endpoint: d.ClusterConfig.Endpoint, - port: d.ClusterConfig.Port, - username: d.ClusterConfig.Username, - password: d.ClusterConfig.Password, - insecure: d.ClusterConfig.Insecure, + endpoint: d.ClusterConfig.Endpoint, + port: d.ClusterConfig.Port, + username: d.ClusterConfig.Username, + password: d.ClusterConfig.Password, + apiKey: d.ClusterConfig.APIKey, + customHeaders: d.ClusterConfig.CustomHeaders, + insecure: d.ClusterConfig.Insecure, } - v4Client, err := convergedV4ClientCache.GetOrCreate(cacheParams) - if err != nil { - return nil, fmt.Errorf("failed to get or create V4 client: %w", err) - } - return v4Client, nil + return getV4ConvergedClient(cacheParams) } // getV4TransferClient returns a V4 client for upload/download operations. @@ -231,18 +232,16 @@ func (d *NutanixDriver) getV4TransferClient() (*convergedv4.Client, error) { opts = append(opts, v4.WithReadTimeout(transferTimeout)) cacheParams := &v4CacheParams{ - endpoint: d.ClusterConfig.Endpoint, - port: d.ClusterConfig.Port, - username: d.ClusterConfig.Username, - password: d.ClusterConfig.Password, - insecure: d.ClusterConfig.Insecure, + endpoint: d.ClusterConfig.Endpoint, + port: d.ClusterConfig.Port, + username: d.ClusterConfig.Username, + password: d.ClusterConfig.Password, + apiKey: d.ClusterConfig.APIKey, + customHeaders: d.ClusterConfig.CustomHeaders, + insecure: d.ClusterConfig.Insecure, } - v4Client, err := convergedV4ClientCache.GetOrCreate(cacheParams, opts...) - if err != nil { - return nil, fmt.Errorf("failed to get or create V4 transfer client: %w", err) - } - return v4Client, nil + return getV4ConvergedClient(cacheParams, opts...) } func findProjectByName(ctx context.Context, conn *v3.Client, name string) (*v3.Project, error) { diff --git a/docs/builders/nutanix.mdx b/docs/builders/nutanix.mdx index 387b381..ce56fc5 100644 --- a/docs/builders/nutanix.mdx +++ b/docs/builders/nutanix.mdx @@ -16,15 +16,17 @@ The Nutanix plugin will create a temporary VM as foundation of your Packer image These parameters allow to define information about platform and temporary VM used to create the image. ### Required - - `nutanix_username` (string) - User used for Prism Central login. - - `nutanix_password` (string) - Password of this user for Prism Central login. - `nutanix_endpoint` (string) - Prism Central FQDN or IP. - `cluster_name` or `cluster_uuid` (string) - Nutanix cluster name or uuid used to create and store image. - `os_type` (string) - OS Type ("Linux" or "Windows"). -Starting `v1.1.4` the Nutanix Packer Plugin supports Prism Central Service Accounts. To use a Service Account, you need to provide `X-ntnx-api-key` as the `nutanix_username` and the corresponding API Key as the `nutanix_password`. +Authentication requires either `nutanix_api_key` **or** both `nutanix_username` and `nutanix_password`. If both are provided, `nutanix_api_key` takes precedence. ### Optional + - `nutanix_username` (string) - User used for Prism Central login. + - `nutanix_password` (string) - Password of this user for Prism Central login. + - `nutanix_api_key` (string) - Prism Central API key. When set, the `X-ntnx-api-key` header is used instead of Basic auth. Falls back to the `NUTANIX_API_KEY` environment variable when unset. Use this for Prism Central Service Accounts (preferred over the legacy `nutanix_username = "X-ntnx-api-key"` form, which still works for backwards compatibility). + - `nutanix_custom_headers` (map of strings) - Additional HTTP headers attached to every request to Prism Central. Useful for environments that sit behind a reverse proxy that requires extra auth headers (e.g. Cloudflare Access service tokens). Headers can also be supplied via environment variables prefixed with `NUTANIX_HEADER_`: the prefix is stripped, underscores become dashes, and each segment is title-cased (e.g. `NUTANIX_HEADER_CF_ACCESS_CLIENT_ID` becomes `Cf-Access-Client-Id`). Values from `nutanix_custom_headers` take precedence over environment variables. - `nutanix_port` (number) - Port used for connection to Prism Central. - `nutanix_insecure` (bool) - Authorize connection to Prism Central without valid certificate. - `nutanix_transfer_timeout` (number) - Transfer read timeout in minutes for upload/download operations (`source_image_path` upload, image export download). Default is `30` for transfer APIs. Set `0` to use the default. diff --git a/example/source.nutanix.pkr.hcl b/example/source.nutanix.pkr.hcl index 0902f45..4053cc8 100644 --- a/example/source.nutanix.pkr.hcl +++ b/example/source.nutanix.pkr.hcl @@ -1,7 +1,9 @@ source "nutanix" "centos" { - nutanix_username = var.nutanix_username - nutanix_password = var.nutanix_password - nutanix_endpoint = var.nutanix_endpoint + nutanix_username = var.nutanix_username + nutanix_password = var.nutanix_password + nutanix_api_key = var.nutanix_api_key + nutanix_custom_headers = var.nutanix_custom_headers + nutanix_endpoint = var.nutanix_endpoint nutanix_port = var.nutanix_port nutanix_insecure = var.nutanix_insecure cluster_name = var.nutanix_cluster @@ -45,9 +47,11 @@ source "nutanix" "centos" { } source "nutanix" "ubuntu" { - nutanix_username = var.nutanix_username - nutanix_password = var.nutanix_password - nutanix_endpoint = var.nutanix_endpoint + nutanix_username = var.nutanix_username + nutanix_password = var.nutanix_password + nutanix_api_key = var.nutanix_api_key + nutanix_custom_headers = var.nutanix_custom_headers + nutanix_endpoint = var.nutanix_endpoint nutanix_port = var.nutanix_port nutanix_insecure = var.nutanix_insecure @@ -78,9 +82,11 @@ source "nutanix" "ubuntu" { } source "nutanix" "centos-kickstart" { - nutanix_username = var.nutanix_username - nutanix_password = var.nutanix_password - nutanix_endpoint = var.nutanix_endpoint + nutanix_username = var.nutanix_username + nutanix_password = var.nutanix_password + nutanix_api_key = var.nutanix_api_key + nutanix_custom_headers = var.nutanix_custom_headers + nutanix_endpoint = var.nutanix_endpoint nutanix_port = var.nutanix_port nutanix_insecure = var.nutanix_insecure cluster_name = var.nutanix_cluster @@ -114,9 +120,11 @@ source "nutanix" "centos-kickstart" { } source "nutanix" "ubuntu-autoinstall" { - nutanix_username = var.nutanix_username - nutanix_password = var.nutanix_password - nutanix_endpoint = var.nutanix_endpoint + nutanix_username = var.nutanix_username + nutanix_password = var.nutanix_password + nutanix_api_key = var.nutanix_api_key + nutanix_custom_headers = var.nutanix_custom_headers + nutanix_endpoint = var.nutanix_endpoint nutanix_port = var.nutanix_port nutanix_insecure = var.nutanix_insecure cluster_name = var.nutanix_cluster @@ -157,9 +165,11 @@ source "nutanix" "ubuntu-autoinstall" { } source "nutanix" "windows" { - nutanix_username = var.nutanix_username - nutanix_password = var.nutanix_password - nutanix_endpoint = var.nutanix_endpoint + nutanix_username = var.nutanix_username + nutanix_password = var.nutanix_password + nutanix_api_key = var.nutanix_api_key + nutanix_custom_headers = var.nutanix_custom_headers + nutanix_endpoint = var.nutanix_endpoint nutanix_insecure = var.nutanix_insecure cluster_name = var.nutanix_cluster diff --git a/example/variables.pkr.hcl b/example/variables.pkr.hcl index 6f4d019..fcc88d5 100644 --- a/example/variables.pkr.hcl +++ b/example/variables.pkr.hcl @@ -1,10 +1,29 @@ variable "nutanix_username" { - type = string + type = string + default = "" } variable "nutanix_password" { - type = string + type = string + sensitive = true + default = "" +} + +# Set nutanix_api_key (or the NUTANIX_API_KEY env var) instead of username/password +# to authenticate via Prism Central API key. If both are set, the api key wins. +variable "nutanix_api_key" { + type = string + sensitive = true + default = "" +} + +# Optional extra HTTP headers attached to every Prism Central request — useful +# behind reverse proxies like Cloudflare Access. Headers can also be supplied via +# NUTANIX_HEADER_* environment variables. +variable "nutanix_custom_headers" { + type = map(string) sensitive = true + default = {} } variable "nutanix_endpoint" { From 3ae1bf523fbf0e5ae47798eb1ddcbe48333c497a Mon Sep 17 00:00:00 2001 From: Andrew Sumner Date: Sun, 3 May 2026 13:58:37 +1200 Subject: [PATCH 2/4] feat(nutanix): propagate custom headers to VNC websocket and Objects Lite S3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends nutanix_api_key and nutanix_custom_headers support to the two remaining paths that were not covered by the initial PR: VNC console websocket (boot_command): step_vnc_connect.go now copies CustomHeaders into the WSS upgrade request and prefers the explicit APIKey field over the legacy Username == "X-ntnx-api-key" form. Without this, any service-token gateway in front of Prism Central (e.g. Cloudflare Access) rejects the websocket handshake and boot_command keystrokes never land. Objects Lite S3 upload (source_image_path / cd_content): CreateImageFile is reimplemented to build the AWS HTTP client through a headerInjectingTransport that carries nutanix_custom_headers, then calls v4Client.Images.Create separately. The upstream prism-go-client ImagesService.Upload uses an unconfigured HTTP client that silently drops any service-token headers. Note: Objects Lite still validates AWS V4 signatures against Prism Central's user table, so nutanix_username/nutanix_password are required for upload even when nutanix_api_key handles all other API calls. Documentation: - nutanix_custom_headers description updated to list all three paths (REST API, VNC websocket, Objects Lite S3) - user_data documented for both Linux (cloud-init) and Windows (Sysprep unattend.xml) — the code already handled both but the docs only mentioned Linux - Added caveat about Objects Lite requiring username/password --- builder/nutanix/driver.go | 124 +++++++++++++++++++++++++++- builder/nutanix/step_vnc_connect.go | 19 ++++- docs/builders/nutanix.mdx | 8 +- 3 files changed, 143 insertions(+), 8 deletions(-) diff --git a/builder/nutanix/driver.go b/builder/nutanix/driver.go index 4f82e77..9794b57 100644 --- a/builder/nutanix/driver.go +++ b/builder/nutanix/driver.go @@ -3,6 +3,7 @@ package nutanix import ( "context" "crypto/tls" + "encoding/base64" "fmt" "io" "log" @@ -15,6 +16,11 @@ import ( "strings" "time" + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + awscreds "github.com/aws/aws-sdk-go-v2/credentials" + s3manager "github.com/aws/aws-sdk-go-v2/feature/s3/manager" + "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/hashicorp/packer-plugin-sdk/multistep" client "github.com/nutanix-cloud-native/prism-go-client" "github.com/nutanix-cloud-native/prism-go-client/converged" @@ -1071,6 +1077,13 @@ func (d *NutanixDriver) CreateImageURL(ctx context.Context, disk VmDisk, vm VmCo } // CreateImageFile uploads a local file as a new image using Objects Lite. +// +// Reimplemented from prism-go-client's ImagesService.Upload so that the AWS +// S3 PutObject call honours nutanix_api_key and nutanix_custom_headers — the +// upstream version uses an unconfigured AWS HTTP client and silently drops +// any service-token headers (e.g. Cloudflare Access) needed to reach Prism +// Central. The image entity creation still goes through the converged client +// where AddDefaultHeader has already been applied. func (d *NutanixDriver) CreateImageFile(ctx context.Context, filePath string, vm VmConfig) (*nutanixImage, error) { v4Client, err := d.getV4TransferClient() if err != nil { @@ -1081,11 +1094,29 @@ func (d *NutanixDriver) CreateImageFile(ctx context.Context, filePath string, vm log.Printf("creating and uploading image: %s", file) - err = v4Client.Images.Upload(ctx, file, filePath) - if err != nil { + if err := d.uploadImageObject(ctx, file, filePath); err != nil { return nil, fmt.Errorf("error while uploading image: %s", err.Error()) } + imageType := imageModels.IMAGETYPE_DISK_IMAGE + if strings.EqualFold(filepath.Ext(filePath), ".iso") { + imageType = imageModels.IMAGETYPE_ISO_IMAGE + } + objectsSource := imageModels.NewObjectsLiteSource() + objectsSource.Key = &file + source := imageModels.NewOneOfImageSource() + if err := source.SetValue(*objectsSource); err != nil { + return nil, fmt.Errorf("error setting Objects Lite source: %s", err.Error()) + } + v4Image := imageModels.NewImage() + v4Image.Name = &file + v4Image.Type = imageType.Ref() + v4Image.Source = source + + if _, err := v4Client.Images.Create(ctx, v4Image); err != nil { + return nil, fmt.Errorf("error while creating image from Objects: %s", err.Error()) + } + createdImage, err := findImageByName(ctx, v4Client, file, d.Config.AllowDuplicateImages) if err != nil { return nil, fmt.Errorf("error while getting created image: %s", err.Error()) @@ -1096,6 +1127,95 @@ func (d *NutanixDriver) CreateImageFile(ctx context.Context, filePath string, vm return createdImage, nil } +// uploadImageObject uploads a local file to Prism Central's Objects Lite S3 +// endpoint. Mirrors prism-go-client's converged/v4/images.go awsConfig() but +// builds the AWS HTTP client with a header-injecting transport so any +// service-token gateway in front of Prism Central (e.g. Cloudflare Access) +// sees the same nutanix_custom_headers the converged client uses on REST API +// calls. +// +// Objects Lite validates the AWS V4 signature against Prism Central's user +// table, so nutanix_username/nutanix_password are required even when the rest +// of the build authenticates with nutanix_api_key. +func (d *NutanixDriver) uploadImageObject(ctx context.Context, key, filePath string) error { + endpoint := fmt.Sprintf("https://%s:%d/api/prism/v4.0/objects/", d.ClusterConfig.Endpoint, d.ClusterConfig.Port) + + username := strings.TrimSpace(d.ClusterConfig.Username) + password := strings.TrimSpace(d.ClusterConfig.Password) + if username == "" || password == "" { + return fmt.Errorf("username and password are required for Objects Lite auth") + } + + region := os.Getenv("AWS_REGION") + if region == "" { + region = "us-east-1" + } + encoded := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", username, password))) + + extraHeaders := http.Header{} + for k, v := range d.ClusterConfig.CustomHeaders { + extraHeaders.Set(k, v) + } + + httpClient := &http.Client{ + Transport: &headerInjectingTransport{ + base: &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: d.ClusterConfig.Insecure}, + }, + headers: extraHeaders, + }, + } + + awsCfg, err := awsconfig.LoadDefaultConfig(ctx, + awsconfig.WithRegion(region), + awsconfig.WithCredentialsProvider(awscreds.NewStaticCredentialsProvider(encoded, encoded, "")), + awsconfig.WithHTTPClient(httpClient), + ) + if err != nil { + return fmt.Errorf("failed to load AWS config: %w", err) + } + + s3Client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { + o.UsePathStyle = true + o.BaseEndpoint = aws.String(endpoint) + }) + + file, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("failed to open image file %q: %w", filePath, err) + } + defer func() { _ = file.Close() }() + + uploader := s3manager.NewUploader(s3Client) + if _, err := uploader.Upload(ctx, &s3.PutObjectInput{ + Bucket: aws.String("vmm-images"), + Key: aws.String(key), + Body: file, + ContentType: aws.String("application/octet-stream"), + }); err != nil { + return fmt.Errorf("failed to upload image file to Objects: %w", err) + } + return nil +} + +// headerInjectingTransport wraps an http.RoundTripper to set a fixed set of +// headers on every outgoing request. Used so that AWS SDK calls inherit the +// same custom headers and API key the converged client uses on its REST API +// calls. +type headerInjectingTransport struct { + base http.RoundTripper + headers http.Header +} + +func (t *headerInjectingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + for k, vs := range t.headers { + for _, v := range vs { + req.Header.Set(k, v) + } + } + return t.base.RoundTrip(req) +} + func (d *NutanixDriver) DeleteImage(ctx context.Context, imageUUID string) error { v4Client, err := d.getV4Client() if err != nil { diff --git a/builder/nutanix/step_vnc_connect.go b/builder/nutanix/step_vnc_connect.go index 2d431d3..0f849ea 100644 --- a/builder/nutanix/step_vnc_connect.go +++ b/builder/nutanix/step_vnc_connect.go @@ -75,11 +75,22 @@ func (s *stepVNCConnect) ConnectVNCOverWebsocketClient(ctx context.Context, stat Host: fmt.Sprintf("%s:%d", s.Config.ClusterConfig.Endpoint, s.Config.ClusterConfig.Port), } header := http.Header{} - // Include Basic Auth when not using API key - some IAM-enabled PCs require it for console - if s.Config.ClusterConfig.Username != "X-ntnx-api-key" { - header.Set("Authorization", "Basic "+basicAuth(s.Config.ClusterConfig.Username, s.Config.ClusterConfig.Password)) - } else { + // Auth: prefer the explicit APIKey field; fall back to the legacy + // Username == "X-ntnx-api-key" form; otherwise Basic Auth. IAM-enabled PCs + // require auth on the console upgrade request. + switch { + case s.Config.ClusterConfig.APIKey != "": + header.Set(ntnxAPIKeyHeaderKey, s.Config.ClusterConfig.APIKey) + case s.Config.ClusterConfig.Username == "X-ntnx-api-key": header.Set(ntnxAPIKeyHeaderKey, s.Config.ClusterConfig.Password) + default: + header.Set("Authorization", "Basic "+basicAuth(s.Config.ClusterConfig.Username, s.Config.ClusterConfig.Password)) + } + // Custom headers (e.g. Cloudflare Access service tokens) — same set the + // HTTP API path uses; without these, the WSS upgrade is rejected by any + // service-token gateway in front of Prism Central. + for k, v := range s.Config.ClusterConfig.CustomHeaders { + header.Set(k, v) } wsConfig := websocket.Config{ Location: u, diff --git a/docs/builders/nutanix.mdx b/docs/builders/nutanix.mdx index ce56fc5..5f3d4ff 100644 --- a/docs/builders/nutanix.mdx +++ b/docs/builders/nutanix.mdx @@ -22,11 +22,13 @@ These parameters allow to define information about platform and temporary VM use Authentication requires either `nutanix_api_key` **or** both `nutanix_username` and `nutanix_password`. If both are provided, `nutanix_api_key` takes precedence. +> **Note:** `source_image_path` and `cd_content`/`cd_files` upload images to Prism Central's Objects Lite S3 endpoint, which always validates using AWS V4 signing derived from `nutanix_username`/`nutanix_password`. Even when using `nutanix_api_key` for all other operations, valid Prism Central credentials are required for these upload paths. Use `source_image_name` or `source_image_uri` to avoid the Objects Lite path entirely. + ### Optional - `nutanix_username` (string) - User used for Prism Central login. - `nutanix_password` (string) - Password of this user for Prism Central login. - `nutanix_api_key` (string) - Prism Central API key. When set, the `X-ntnx-api-key` header is used instead of Basic auth. Falls back to the `NUTANIX_API_KEY` environment variable when unset. Use this for Prism Central Service Accounts (preferred over the legacy `nutanix_username = "X-ntnx-api-key"` form, which still works for backwards compatibility). - - `nutanix_custom_headers` (map of strings) - Additional HTTP headers attached to every request to Prism Central. Useful for environments that sit behind a reverse proxy that requires extra auth headers (e.g. Cloudflare Access service tokens). Headers can also be supplied via environment variables prefixed with `NUTANIX_HEADER_`: the prefix is stripped, underscores become dashes, and each segment is title-cased (e.g. `NUTANIX_HEADER_CF_ACCESS_CLIENT_ID` becomes `Cf-Access-Client-Id`). Values from `nutanix_custom_headers` take precedence over environment variables. + - `nutanix_custom_headers` (map of strings) - Additional HTTP headers attached to every request to Prism Central, including REST API calls, VNC console websocket connections (used by `boot_command`), and Objects Lite S3 image uploads (used by `source_image_path` and `cd_content`). Useful for environments that sit behind a reverse proxy that requires extra auth headers (e.g. Cloudflare Access service tokens). Headers can also be supplied via environment variables prefixed with `NUTANIX_HEADER_`: the prefix is stripped, underscores become dashes, and each segment is title-cased (e.g. `NUTANIX_HEADER_CF_ACCESS_CLIENT_ID` becomes `Cf-Access-Client-Id`). Values from `nutanix_custom_headers` take precedence over environment variables. - `nutanix_port` (number) - Port used for connection to Prism Central. - `nutanix_insecure` (bool) - Authorize connection to Prism Central without valid certificate. - `nutanix_transfer_timeout` (number) - Transfer read timeout in minutes for upload/download operations (`source_image_path` upload, image export download). Default is `30` for transfer APIs. Set `0` to use the default. @@ -81,7 +83,9 @@ These parameters allow to configure everything around image creation, from the t - `disable_stop_instance` (bool) - When `true`, prevents Packer from automatically stopping the build instance after provisioning completes. Your final provisioner must handle stopping the instance, or the build will timeout (default is false). ### Dedicated to Linux -- `user_data` (string) - cloud-init content base64 encoded. +- `user_data` (string) - Guest customization content, base64 encoded. Behaviour depends on `os_type`: + - **Linux** (`os_type = "Linux"`): treated as cloud-init user-data. AHV creates a ConfigDrive CD that cloud-init reads on first boot. + - **Windows** (`os_type = "Windows"`): treated as a Sysprep `unattend.xml`. AHV delivers it via Prism Central's guest customization mechanism, making it available to Windows Setup during both fresh installs (windowsPE phase) and post-sysprep OOBE. - `ssh_username` (string) - user for ssh connection initiated by Packer. - `ssh_password` (string) - password for the ssh user. From ad5e0d61903b9cdfc420fbacaf63ca4400f40efe Mon Sep 17 00:00:00 2001 From: Andrew Sumner Date: Thu, 7 May 2026 13:25:52 +1200 Subject: [PATCH 3/4] refactor: remove direct env var consumption, use PKR_VAR_ instead Remove NUTANIX_API_KEY env var fallback and NUTANIX_HEADER_* env var scanning from plugin code. Users should use PKR_VAR_nutanix_api_key and PKR_VAR_nutanix_custom_headers (or HCL env() function) instead, which aligns with Packer best practices and existing plugin behaviour. Updated docs with usage examples showing PKR_VAR_ approach. --- builder/nutanix/config.go | 56 ------------------ builder/nutanix/config_test.go | 100 --------------------------------- docs/builders/nutanix.mdx | 30 +++++++++- example/variables.pkr.hcl | 7 +-- 4 files changed, 31 insertions(+), 162 deletions(-) diff --git a/builder/nutanix/config.go b/builder/nutanix/config.go index 5cc5ab7..7b8fec6 100644 --- a/builder/nutanix/config.go +++ b/builder/nutanix/config.go @@ -7,7 +7,6 @@ import ( "fmt" "log" "net" - "os" "strings" "time" @@ -22,10 +21,6 @@ import ( "github.com/hashicorp/packer-plugin-sdk/template/interpolate" ) -// nutanixHeaderEnvPrefix is the prefix used to discover custom HTTP headers -// from environment variables (e.g. NUTANIX_HEADER_CF_ACCESS_CLIENT_ID). -const nutanixHeaderEnvPrefix = "NUTANIX_HEADER_" - const ( // NutanixIdentifierBootTypeLegacy is a resource identifier identifying the legacy boot type for virtual machines. NutanixIdentifierBootTypeLegacy string = "legacy" @@ -316,14 +311,6 @@ func (c *Config) Prepare(raws ...interface{}) ([]string, error) { } } - // Allow API key from NUTANIX_API_KEY env var when not set in config - if c.ClusterConfig.APIKey == "" { - c.ClusterConfig.APIKey = os.Getenv("NUTANIX_API_KEY") - } - - // Merge custom headers from NUTANIX_HEADER_* env vars (config wins) - c.ClusterConfig.CustomHeaders = mergeCustomHeaders(c.ClusterConfig.CustomHeaders) - // Validate authentication: need either API key or username+password hasAPIKey := c.ClusterConfig.APIKey != "" hasBasicAuth := c.ClusterConfig.Username != "" && c.ClusterConfig.Password != "" @@ -458,46 +445,3 @@ func (c *Config) Prepare(raws ...interface{}) ([]string, error) { return warnings, nil } -// mergeCustomHeaders builds the effective custom-header map by combining -// NUTANIX_HEADER_* environment variables with the config-provided map. -// Env-var names are converted to header names by stripping the prefix, -// replacing underscores with dashes, and title-casing each segment -// (e.g. NUTANIX_HEADER_CF_ACCESS_CLIENT_ID -> Cf-Access-Client-Id). -// Config values take precedence over environment variables. -func mergeCustomHeaders(configHeaders map[string]string) map[string]string { - merged := map[string]string{} - for _, env := range os.Environ() { - eq := strings.IndexByte(env, '=') - if eq < 0 { - continue - } - name, value := env[:eq], env[eq+1:] - if !strings.HasPrefix(name, nutanixHeaderEnvPrefix) { - continue - } - header := envSuffixToHeader(name[len(nutanixHeaderEnvPrefix):]) - if header != "" { - merged[header] = value - } - } - for k, v := range configHeaders { - merged[k] = v - } - if len(merged) == 0 { - return nil - } - return merged -} - -// envSuffixToHeader converts an env-var suffix (e.g. CF_ACCESS_CLIENT_ID) -// into a title-cased dash-separated header name (e.g. Cf-Access-Client-Id). -func envSuffixToHeader(suffix string) string { - parts := strings.Split(suffix, "_") - for i, p := range parts { - if p == "" { - continue - } - parts[i] = strings.ToUpper(p[:1]) + strings.ToLower(p[1:]) - } - return strings.Join(parts, "-") -} diff --git a/builder/nutanix/config_test.go b/builder/nutanix/config_test.go index 342dfac..5d9f970 100644 --- a/builder/nutanix/config_test.go +++ b/builder/nutanix/config_test.go @@ -1,61 +1,10 @@ package nutanix import ( - "reflect" "strings" "testing" ) -func TestEnvSuffixToHeader(t *testing.T) { - cases := map[string]string{ - "CF_ACCESS_CLIENT_ID": "Cf-Access-Client-Id", - "CF_ACCESS_CLIENT_SECRET": "Cf-Access-Client-Secret", - "X_API_TOKEN": "X-Api-Token", - "AUTHORIZATION": "Authorization", - "": "", - } - for in, want := range cases { - if got := envSuffixToHeader(in); got != want { - t.Errorf("envSuffixToHeader(%q) = %q, want %q", in, got, want) - } - } -} - -func TestMergeCustomHeadersConfigWinsOverEnv(t *testing.T) { - t.Setenv("NUTANIX_HEADER_CF_ACCESS_CLIENT_ID", "from-env") - t.Setenv("NUTANIX_HEADER_X_EXTRA", "env-only") - - got := mergeCustomHeaders(map[string]string{ - "Cf-Access-Client-Id": "from-config", - }) - - want := map[string]string{ - "Cf-Access-Client-Id": "from-config", - "X-Extra": "env-only", - } - if !reflect.DeepEqual(got, want) { - t.Errorf("mergeCustomHeaders mismatch\n got: %#v\n want: %#v", got, want) - } -} - -func TestMergeCustomHeadersReturnsNilWhenEmpty(t *testing.T) { - // No NUTANIX_HEADER_ env vars set in the parent test process; t.Setenv is - // scoped, but to be defensive ensure none leak into this case. - for _, k := range []string{"NUTANIX_HEADER_FOO", "NUTANIX_HEADER_BAR"} { - t.Setenv(k, "") - // Setting to "" still creates the env var; clear it instead. - } - if got := mergeCustomHeaders(nil); got != nil { - // Allow non-nil if NUTANIX_HEADER_ vars already exist outside the test. - // In CI we expect a clean env, so flag only the genuinely-empty case. - for k := range got { - if strings.HasPrefix(k, "NUTANIX_HEADER_") || k == "" { - t.Errorf("expected no headers, got %v", got) - } - } - } -} - // minimalValidConfig returns the smallest map of raws that Prepare will accept // when paired with the auth fields chosen in the test. Communicator is set to // "none" so vm_nics aren't required. @@ -80,7 +29,6 @@ func minimalValidConfig(extra map[string]interface{}) map[string]interface{} { } func TestPrepareAcceptsAPIKeyOnly(t *testing.T) { - t.Setenv("NUTANIX_API_KEY", "") c := &Config{} warnings, err := c.Prepare(minimalValidConfig(map[string]interface{}{ "nutanix_api_key": "key123", @@ -99,7 +47,6 @@ func TestPrepareAcceptsAPIKeyOnly(t *testing.T) { } func TestPrepareAcceptsUsernamePasswordOnly(t *testing.T) { - t.Setenv("NUTANIX_API_KEY", "") c := &Config{} _, err := c.Prepare(minimalValidConfig(map[string]interface{}{ "nutanix_username": "u", @@ -111,7 +58,6 @@ func TestPrepareAcceptsUsernamePasswordOnly(t *testing.T) { } func TestPrepareWarnsWhenBothAuthMethodsSet(t *testing.T) { - t.Setenv("NUTANIX_API_KEY", "") c := &Config{} warnings, err := c.Prepare(minimalValidConfig(map[string]interface{}{ "nutanix_username": "u", @@ -134,7 +80,6 @@ func TestPrepareWarnsWhenBothAuthMethodsSet(t *testing.T) { } func TestPrepareErrorsWhenNoAuth(t *testing.T) { - t.Setenv("NUTANIX_API_KEY", "") c := &Config{} _, err := c.Prepare(minimalValidConfig(nil)) if err == nil { @@ -144,48 +89,3 @@ func TestPrepareErrorsWhenNoAuth(t *testing.T) { t.Errorf("expected authentication error, got: %v", err) } } - -func TestPrepareReadsAPIKeyFromEnv(t *testing.T) { - t.Setenv("NUTANIX_API_KEY", "from-env") - c := &Config{} - _, err := c.Prepare(minimalValidConfig(nil)) - if err != nil { - t.Fatalf("expected Prepare to succeed with NUTANIX_API_KEY env var, got: %v", err) - } - if c.ClusterConfig.APIKey != "from-env" { - t.Errorf("APIKey not loaded from env: %q", c.ClusterConfig.APIKey) - } -} - -func TestPrepareReadsCustomHeadersFromEnv(t *testing.T) { - t.Setenv("NUTANIX_HEADER_CF_ACCESS_CLIENT_ID", "abc.id") - t.Setenv("NUTANIX_HEADER_CF_ACCESS_CLIENT_SECRET", "shh") - c := &Config{} - _, err := c.Prepare(minimalValidConfig(map[string]interface{}{ - "nutanix_api_key": "k", - })) - if err != nil { - t.Fatalf("expected Prepare to succeed, got: %v", err) - } - got := c.ClusterConfig.CustomHeaders - if got["Cf-Access-Client-Id"] != "abc.id" || got["Cf-Access-Client-Secret"] != "shh" { - t.Errorf("env headers not picked up: %#v", got) - } -} - -func TestPrepareConfigHeadersOverrideEnvHeaders(t *testing.T) { - t.Setenv("NUTANIX_HEADER_CF_ACCESS_CLIENT_ID", "from-env") - c := &Config{} - _, err := c.Prepare(minimalValidConfig(map[string]interface{}{ - "nutanix_api_key": "k", - "nutanix_custom_headers": map[string]string{ - "Cf-Access-Client-Id": "from-config", - }, - })) - if err != nil { - t.Fatalf("expected Prepare to succeed, got: %v", err) - } - if got := c.ClusterConfig.CustomHeaders["Cf-Access-Client-Id"]; got != "from-config" { - t.Errorf("expected config to win, got %q", got) - } -} diff --git a/docs/builders/nutanix.mdx b/docs/builders/nutanix.mdx index 5f3d4ff..4a613cd 100644 --- a/docs/builders/nutanix.mdx +++ b/docs/builders/nutanix.mdx @@ -24,11 +24,37 @@ Authentication requires either `nutanix_api_key` **or** both `nutanix_username` > **Note:** `source_image_path` and `cd_content`/`cd_files` upload images to Prism Central's Objects Lite S3 endpoint, which always validates using AWS V4 signing derived from `nutanix_username`/`nutanix_password`. Even when using `nutanix_api_key` for all other operations, valid Prism Central credentials are required for these upload paths. Use `source_image_name` or `source_image_uri` to avoid the Objects Lite path entirely. +Sample using API key and custom headers (e.g. Cloudflare Access): +```hcl +variable "nutanix_api_key" { + type = string + sensitive = true +} + +variable "nutanix_custom_headers" { + type = map(string) + sensitive = true + default = {} +} + +source "nutanix" "example" { + nutanix_api_key = var.nutanix_api_key + nutanix_custom_headers = var.nutanix_custom_headers + # ... +} +``` + +Values can be supplied via `PKR_VAR_` environment variables: +```bash +export PKR_VAR_nutanix_api_key='your-api-key' +export PKR_VAR_nutanix_custom_headers='{"Cf-Access-Client-Id":"your-client-id","Cf-Access-Client-Secret":"your-client-secret"}' +``` + ### Optional - `nutanix_username` (string) - User used for Prism Central login. - `nutanix_password` (string) - Password of this user for Prism Central login. - - `nutanix_api_key` (string) - Prism Central API key. When set, the `X-ntnx-api-key` header is used instead of Basic auth. Falls back to the `NUTANIX_API_KEY` environment variable when unset. Use this for Prism Central Service Accounts (preferred over the legacy `nutanix_username = "X-ntnx-api-key"` form, which still works for backwards compatibility). - - `nutanix_custom_headers` (map of strings) - Additional HTTP headers attached to every request to Prism Central, including REST API calls, VNC console websocket connections (used by `boot_command`), and Objects Lite S3 image uploads (used by `source_image_path` and `cd_content`). Useful for environments that sit behind a reverse proxy that requires extra auth headers (e.g. Cloudflare Access service tokens). Headers can also be supplied via environment variables prefixed with `NUTANIX_HEADER_`: the prefix is stripped, underscores become dashes, and each segment is title-cased (e.g. `NUTANIX_HEADER_CF_ACCESS_CLIENT_ID` becomes `Cf-Access-Client-Id`). Values from `nutanix_custom_headers` take precedence over environment variables. + - `nutanix_api_key` (string) - Prism Central API key. When set, the `X-ntnx-api-key` header is used instead of Basic auth. Use this for Prism Central Service Accounts (preferred over the legacy `nutanix_username = "X-ntnx-api-key"` form, which still works for backwards compatibility). + - `nutanix_custom_headers` (map of strings) - Additional HTTP headers attached to every request to Prism Central, including REST API calls, VNC console websocket connections (used by `boot_command`), and Objects Lite S3 image uploads (used by `source_image_path` and `cd_content`). Useful for environments that sit behind a reverse proxy that requires extra auth headers (e.g. Cloudflare Access service tokens). - `nutanix_port` (number) - Port used for connection to Prism Central. - `nutanix_insecure` (bool) - Authorize connection to Prism Central without valid certificate. - `nutanix_transfer_timeout` (number) - Transfer read timeout in minutes for upload/download operations (`source_image_path` upload, image export download). Default is `30` for transfer APIs. Set `0` to use the default. diff --git a/example/variables.pkr.hcl b/example/variables.pkr.hcl index fcc88d5..fb148ab 100644 --- a/example/variables.pkr.hcl +++ b/example/variables.pkr.hcl @@ -9,8 +9,8 @@ variable "nutanix_password" { default = "" } -# Set nutanix_api_key (or the NUTANIX_API_KEY env var) instead of username/password -# to authenticate via Prism Central API key. If both are set, the api key wins. +# Set nutanix_api_key instead of username/password to authenticate via Prism +# Central API key. If both are set, the api key wins. variable "nutanix_api_key" { type = string sensitive = true @@ -18,8 +18,7 @@ variable "nutanix_api_key" { } # Optional extra HTTP headers attached to every Prism Central request — useful -# behind reverse proxies like Cloudflare Access. Headers can also be supplied via -# NUTANIX_HEADER_* environment variables. +# behind reverse proxies like Cloudflare Access. variable "nutanix_custom_headers" { type = map(string) sensitive = true From e0dbe0960f570ee4dce263509f927a25098d0bdf Mon Sep 17 00:00:00 2001 From: Andrew Sumner Date: Tue, 12 May 2026 11:01:49 +1200 Subject: [PATCH 4/4] fix: clone request in RoundTrip and align example formatting - Clone request in headerInjectingTransport.RoundTrip() to comply with the http.RoundTripper contract (callers must not modify the request) - Align example file field formatting for consistency - Remove unused strings import from config.go --- builder/nutanix/config.go | 1 - builder/nutanix/driver.go | 5 +++-- example/source.nutanix.pkr.hcl | 31 ++++++++++++++----------------- 3 files changed, 17 insertions(+), 20 deletions(-) diff --git a/builder/nutanix/config.go b/builder/nutanix/config.go index 7b8fec6..f828c50 100644 --- a/builder/nutanix/config.go +++ b/builder/nutanix/config.go @@ -7,7 +7,6 @@ import ( "fmt" "log" "net" - "strings" "time" "github.com/hashicorp/packer-plugin-sdk/bootcommand" diff --git a/builder/nutanix/driver.go b/builder/nutanix/driver.go index 9794b57..61ae847 100644 --- a/builder/nutanix/driver.go +++ b/builder/nutanix/driver.go @@ -1208,12 +1208,13 @@ type headerInjectingTransport struct { } func (t *headerInjectingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) for k, vs := range t.headers { for _, v := range vs { - req.Header.Set(k, v) + clone.Header.Set(k, v) } } - return t.base.RoundTrip(req) + return t.base.RoundTrip(clone) } func (d *NutanixDriver) DeleteImage(ctx context.Context, imageUUID string) error { diff --git a/example/source.nutanix.pkr.hcl b/example/source.nutanix.pkr.hcl index 4053cc8..9c0c19b 100644 --- a/example/source.nutanix.pkr.hcl +++ b/example/source.nutanix.pkr.hcl @@ -4,9 +4,9 @@ source "nutanix" "centos" { nutanix_api_key = var.nutanix_api_key nutanix_custom_headers = var.nutanix_custom_headers nutanix_endpoint = var.nutanix_endpoint - nutanix_port = var.nutanix_port - nutanix_insecure = var.nutanix_insecure - cluster_name = var.nutanix_cluster + nutanix_port = var.nutanix_port + nutanix_insecure = var.nutanix_insecure + cluster_name = var.nutanix_cluster os_type = "Linux" vm_disks { @@ -52,12 +52,9 @@ source "nutanix" "ubuntu" { nutanix_api_key = var.nutanix_api_key nutanix_custom_headers = var.nutanix_custom_headers nutanix_endpoint = var.nutanix_endpoint - nutanix_port = var.nutanix_port - nutanix_insecure = var.nutanix_insecure - - # read_timeout_minutes = 30 - - cluster_name = var.nutanix_cluster + nutanix_port = var.nutanix_port + nutanix_insecure = var.nutanix_insecure + cluster_name = var.nutanix_cluster os_type = "Linux" vm_disks { @@ -87,9 +84,9 @@ source "nutanix" "centos-kickstart" { nutanix_api_key = var.nutanix_api_key nutanix_custom_headers = var.nutanix_custom_headers nutanix_endpoint = var.nutanix_endpoint - nutanix_port = var.nutanix_port - nutanix_insecure = var.nutanix_insecure - cluster_name = var.nutanix_cluster + nutanix_port = var.nutanix_port + nutanix_insecure = var.nutanix_insecure + cluster_name = var.nutanix_cluster os_type = "Linux" @@ -125,9 +122,9 @@ source "nutanix" "ubuntu-autoinstall" { nutanix_api_key = var.nutanix_api_key nutanix_custom_headers = var.nutanix_custom_headers nutanix_endpoint = var.nutanix_endpoint - nutanix_port = var.nutanix_port - nutanix_insecure = var.nutanix_insecure - cluster_name = var.nutanix_cluster + nutanix_port = var.nutanix_port + nutanix_insecure = var.nutanix_insecure + cluster_name = var.nutanix_cluster os_type = "Linux" @@ -170,8 +167,8 @@ source "nutanix" "windows" { nutanix_api_key = var.nutanix_api_key nutanix_custom_headers = var.nutanix_custom_headers nutanix_endpoint = var.nutanix_endpoint - nutanix_insecure = var.nutanix_insecure - cluster_name = var.nutanix_cluster + nutanix_insecure = var.nutanix_insecure + cluster_name = var.nutanix_cluster vm_disks { image_type = "ISO_IMAGE"