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
125 changes: 108 additions & 17 deletions builder/nutanix/cache.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
53 changes: 53 additions & 0 deletions builder/nutanix/cache_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
33 changes: 18 additions & 15 deletions builder/nutanix/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,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 {
Expand Down Expand Up @@ -308,16 +310,16 @@ 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"))
// 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"))
}

// Validate Cluster Password
if c.ClusterConfig.Password == "" {
log.Println("Nutanix Password missing from configuration")
errs = packersdk.MultiErrorAppend(errs, fmt.Errorf("missing 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 == "" {
Expand Down Expand Up @@ -441,3 +443,4 @@ func (c *Config) Prepare(raws ...interface{}) ([]string, error) {

return warnings, nil
}

20 changes: 14 additions & 6 deletions builder/nutanix/config.hcl2spec.go

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

91 changes: 91 additions & 0 deletions builder/nutanix/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package nutanix

import (
"strings"
"testing"
)

// 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) {
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) {
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) {
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) {
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)
}
}
Loading