Skip to content
Draft
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
36 changes: 6 additions & 30 deletions pkg/asset/installconfig/vsphere/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,14 @@ package vsphere

import (
"context"
"net/url"
"time"

"github.com/pkg/errors"
"github.com/vmware/govmomi"
"github.com/vmware/govmomi/find"
"github.com/vmware/govmomi/object"
"github.com/vmware/govmomi/vapi/rest"
"github.com/vmware/govmomi/vim25"
"github.com/vmware/govmomi/vim25/mo"
"github.com/vmware/govmomi/vim25/soap"
"github.com/vmware/govmomi/vim25/types"
)

Expand Down Expand Up @@ -46,37 +43,16 @@ func NewFinder(client *vim25.Client, all ...bool) Finder {
type ClientLogout func()

// CreateVSphereClients creates the SOAP and REST client to access
// different portions of the vSphere API
// e.g. tags are only available in REST
// different portions of the vSphere API.
//
// Deprecated: use NewSession instead, which provides unified session
// management with caching, lifecycle management, and proper cleanup.
func CreateVSphereClients(ctx context.Context, vcenter, username, password string) (*vim25.Client, *rest.Client, ClientLogout, error) {
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()

u, err := soap.ParseURL(vcenter)
if err != nil {
return nil, nil, nil, err
}
u.User = url.UserPassword(username, password)
c, err := govmomi.NewClient(ctx, u, false)

sess, cleanup, err := NewSession(ctx, vcenter, username, password)
if err != nil {
return nil, nil, nil, err
}

restClient := rest.NewClient(c.Client)
err = restClient.Login(ctx, u.User)
if err != nil {
logoutErr := c.Logout(context.TODO())
if logoutErr != nil {
err = logoutErr
}
return nil, nil, nil, err
}

return c.Client, restClient, func() {
c.Logout(context.TODO())
restClient.Logout(context.TODO())
}, nil
return sess.vim25, sess.rest, cleanup, nil
}

// getNetworks returns a slice of Managed Object references for networks in the given vSphere Cluster.
Expand Down
271 changes: 271 additions & 0 deletions pkg/asset/installconfig/vsphere/session.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
package vsphere

import (
"context"
"crypto/sha256"
"fmt"
"net/url"
"sync"
"time"

"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/vmware/govmomi"
"github.com/vmware/govmomi/cns"
"github.com/vmware/govmomi/find"
"github.com/vmware/govmomi/pbm"
"github.com/vmware/govmomi/vapi/rest"
"github.com/vmware/govmomi/vim25"
"github.com/vmware/govmomi/vim25/soap"
)

// Session manages a vCenter connection lifecycle including SOAP, REST, and
// auxiliary clients. It provides a single entry point for creating and
// reusing vSphere client sessions with proper caching and cleanup.
type Session struct {
server string
govmomi *govmomi.Client
vim25 *vim25.Client
rest *rest.Client
cns *cns.Client
pbm *pbm.Client
finder *find.Finder
logger logrus.FieldLogger
once sync.Once
closeErr error
}

// SessionOptions configures a vSphere session.
type SessionOptions struct {
// Timeout for the initial connection. Defaults to 60s if zero.
Timeout time.Duration
// Insecure skips TLS certificate verification. Defaults to false.
Insecure bool
// Logger for structured logging. If nil, uses logrus.StandardLogger().
Logger logrus.FieldLogger
}

// SessionOption configures a session via functional options.
type SessionOption func(*SessionOptions)

// WithTimeout sets the connection timeout.
func WithTimeout(timeout time.Duration) SessionOption {
return func(o *SessionOptions) { o.Timeout = timeout }
}

// WithInsecure skips TLS certificate verification.
func WithInsecure(insecure bool) SessionOption {
return func(o *SessionOptions) { o.Insecure = insecure }
}

// WithLogger sets the logger for structured logging.
func WithLogger(logger logrus.FieldLogger) SessionOption {
return func(o *SessionOptions) { o.Logger = logger }
}

// applyDefaults sets reasonable defaults for unset options.
func (o *SessionOptions) applyDefaults() {
if o.Timeout == 0 {
o.Timeout = 60 * time.Second
}
if o.Logger == nil {
o.Logger = logrus.StandardLogger()
}
}

// SessionCacheKey returns a deterministic key for session caching based on
// server, username, and password.
func SessionCacheKey(server, username, password string) string {
hash := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%s", server, username, password)))
return fmt.Sprintf("%x", hash[:16])
Comment on lines +78 to +80
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Encode the cache-key inputs unambiguously.

fmt.Sprintf("%s|%s|%s", ...) is not injective: values containing | can produce the same preimage string for different (server, username, password) tuples. That can make unrelated credentials reuse the same cached session. Hash length-prefixed fields instead of a delimiter-joined string.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/asset/installconfig/vsphere/session.go` around lines 78 - 80,
SessionCacheKey currently joins server, username, password with "|" which is
ambiguous; change it to hash length-prefixed fields instead: build the input to
sha256 by writing each field as a length (e.g. 4-byte big-endian or varint)
followed by the raw field bytes for server, then username, then password, then
compute sha256 and return the first 16 bytes hex as before. Update the
SessionCacheKey function to construct the length-prefixed byte sequence (instead
of fmt.Sprintf) before hashing to eliminate delimiter collision.

}

var (
sessionsMu sync.Mutex
sessions = make(map[string]*Session)
)

// redactHostname returns a short deterministic hash of the hostname, suitable
// for logging. The plain hostname is never exposed in logs.
func redactHostname(hostname string) string {
h := sha256.Sum256([]byte(hostname))
return fmt.Sprintf("%x", h[:16])
}

// NewSession creates or retrieves a cached vSphere session for the given
// server, username, and password. Returns the session and a cleanup function.
//
// The cleanup function must be called when the session is no longer needed.
// It is idempotent and safe to call multiple times.
//
// Options allow configuring timeout, TLS, and other settings.
func NewSession(ctx context.Context, server, username, password string, opts ...SessionOption) (*Session, func(), error) {
options := makeSessionOptions(opts...)
options.applyDefaults()

if server == "" || username == "" || password == "" {
return nil, nil, errors.New("vSphere server, username, and password are required")
}

key := SessionCacheKey(server, username, password)

sessionsMu.Lock()
if sess, ok := sessions[key]; ok {
if sess.Error() != nil {
// Cached session is closed — evict it so a fresh one is created.
delete(sessions, key)
sessionsMu.Unlock()
// Fall through to create a new session.
} else {
sessionsMu.Unlock()
return sess, func() { sess.Close() }, nil
}
Comment on lines +121 to +122
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Shared cached sessions need reference-counted cleanup.

Each caller gets a cleanup function that always calls Close(). On cache hits, one caller can close the shared session while another caller is still using it. This needs ref tracking (increment on acquire, decrement on cleanup, close on zero).

Also applies to: 142-147

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/asset/installconfig/vsphere/session.go` around lines 121 - 122, The
cleanup closure returned on cache hits always calls sess.Close(), which allows
one caller to close a shared cached session while others still use it; change
the cache to maintain a reference count per cached session (e.g., a struct with
sess and refCount) and update the acquire path to increment refCount when
returning a cached session and the cleanup closure to decrement refCount and
only call sess.Close() when refCount reaches zero; update any places that return
"sess, func() { sess.Close() }, nil" (the factory that currently returns sess
and the cleanup closure) to use the new acquire/release semantics so both cache
hits and misses follow the same ref-counted cleanup behavior.

}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
sessionsMu.Unlock()
Comment thread
coderabbitai[bot] marked this conversation as resolved.

ctx, cancel := context.WithTimeout(ctx, options.Timeout)
defer cancel()

sess, err := newSessionFromConn(ctx, server, username, password, options)
if err != nil {
return nil, nil, err
}

sessionsMu.Lock()
// Double check after acquiring the lock (another goroutine may have created
// the session in the meantime).
if s, ok := sessions[key]; ok {
// A cached session arrived in the meantime — close the one we just
// created to avoid leaking an authenticated client.
sess.Close()
sessionsMu.Unlock()
return s, func() { s.Close() }, nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
sessions[key] = sess
sessionsMu.Unlock()

return sess, func() { sess.Close() }, nil
}

func makeSessionOptions(opts ...SessionOption) *SessionOptions {
o := &SessionOptions{}
for _, opt := range opts {
opt(o)
}
return o
}

// Close releases all resources held by the session. It is safe to call
// multiple times. If any cleanup operation fails, the first error is
// stored and returned via Error().
func (s *Session) Close() {
s.once.Do(func() {
var firstErr error

s.logger.WithField("vcenter", redactHostname(s.server)).Debug("Closing vSphere session")

if s.rest != nil {
if err := s.rest.Logout(context.Background()); err != nil {
s.logger.WithError(err).Warn("Failed to logout REST client")
if firstErr == nil {
firstErr = err
}
}
}

if s.govmomi != nil {
if err := s.govmomi.Logout(context.Background()); err != nil {
Comment on lines +167 to +177
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and show the relevant code in session.go
FILE="pkg/asset/installconfig/vsphere/session.go"
if [ ! -f "$FILE" ]; then
  echo "Missing file: $FILE"
  exit 1
fi

nl -ba "$FILE" | sed -n '130,220p'

# Search for Close() and any existing timeout/context usage in the vsphere session code
rg -n "func \\(.*\\) Close\\(|Logout\\(context|WithTimeout\\(|Background\\(\\)" "$FILE"

# Show definitions of any session structs fields related to timeouts or contexts
rg -n "type .*Session|struct .*Session|timeout|ctx|context\\.Context" "$FILE"

Repository: openshift/installer

Length of output: 107


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="pkg/asset/installconfig/vsphere/session.go"
[ -f "$FILE" ] || { echo "Missing file: $FILE"; exit 1; }

# Show the relevant region with line numbers
cat -n "$FILE" | sed -n '130,220p'

# Find Close() and Logout usage plus any existing timeouts/context usage nearby
rg -n "func \\(.*\\) Close\\(|Logout\\(context\\.|Logout\\(|WithTimeout\\(|context\\.Background\\(" "$FILE"

# Inspect the Logout method signatures/types referenced in this file (if any)
rg -n "Logout\\(" "$FILE"

# Check whether session struct already carries a timeout or context
rg -n "type .*Session|struct .*Session|timeout|ctx\\b|context\\.Context" "$FILE"

Repository: openshift/installer

Length of output: 4817


🏁 Script executed:

#!/bin/bash
set -euo pipefail
FILE="pkg/asset/installconfig/vsphere/session.go"

# Print the top portion: Session/SessionOptions structs and timeout option definitions
cat -n "$FILE" | sed -n '1,140p'

# Print the rest around NewSession and any other context usage
cat -n "$FILE" | sed -n '140,280p'

Repository: openshift/installer

Length of output: 9840


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for Logout signatures in vendor (govmomi/vim25 and vapi/rest)
rg -n "func \\(.*\\) Logout\\(ctx context\\.Context\\)" vendor/github.com/vmware/govmomi -S || true
rg -n "Logout\\(ctx context\\.Context\\)" vendor/github.com/vmware/govmomi -S | head -n 50

# Also check for rest client Logout signature
rg -n "Logout\\(ctx context\\.Context\\)" vendor/github.com/vmware/govmomi/vapi/rest -S || true

# Show session.go Session struct fields to see if timeout is stored
rg -n "type Session struct \\{|type SessionOptions struct \\{|Timeout" pkg/asset/installconfig/vsphere/session.go

Repository: openshift/installer

Length of output: 1249


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show relevant parts around Logout implementations in vendored govmomi
for f in \
  vendor/github.com/vmware/govmomi/client.go \
  vendor/github.com/vmware/govmomi/vapi/rest/client.go \
  vendor/github.com/vmware/govmomi/session/manager.go
do
  echo "==== $f ===="
  # print around the first Logout occurrence
  start=$(rg -n "func \\(.*\\) Logout\\(ctx context\\.Context\\)" "$f" | head -n 1 | cut -d: -f1)
  if [ -z "${start:-}" ]; then
    echo "No Logout(ctx) found in $f"
    continue
  fi
  # show ~80 lines around it
  from=$((start-20))
  to=$((start+120))
  if [ $from -lt 1 ]; then from=1; fi
  sed -n "${from},${to}p" "$f" | nl -ba | sed 's/^ *[0-9]*\t//'
done

Repository: openshift/installer

Length of output: 178


🏁 Script executed:

#!/bin/bash
set -euo pipefail

for f in \
  vendor/github.com/vmware/govmomi/client.go \
  vendor/github.com/vmware/govmomi/vapi/rest/client.go \
  vendor/github.com/vmware/govmomi/session/manager.go
do
  echo "==== $f ===="
  # Find the first Logout(ctx ...) in the file
  line=$(rg -n "func \\(.*\\) Logout\\(ctx context\\.Context\\)" "$f" | head -n 1 | cut -d: -f1 || true)
  if [ -z "${line:-}" ]; then
    echo "No Logout(ctx) found"
    continue
  fi
  from=$((line-20))
  to=$((line+160))
  if [ $from -lt 1 ]; then from=1; fi
  cat -n "$f" | sed -n "${from},${to}p"
  echo
done

Repository: openshift/installer

Length of output: 8535


Bound vCenter logout RPCs with a timeout in Session.Close() (avoid context.Background()).
pkg/asset/installconfig/vsphere/session.go calls s.rest.Logout(context.Background()) and s.govmomi.Logout(context.Background()); govmomi’s REST/SOAP logout APIs accept a context.Context and use it for the underlying network RPC. SessionOptions.Timeout is currently only applied to the initial connection, so Close() can still block indefinitely on a broken connection—store a timeout on the Session (from SessionOptions) or derive a short context.WithTimeout locally and use it for both logout calls.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/asset/installconfig/vsphere/session.go` around lines 167 - 177,
Session.Close currently calls s.rest.Logout(context.Background()) and
s.govmomi.Logout(context.Background()) which can block; change Close to use a
bounded context derived from the session timeout (SessionOptions.Timeout) or a
stored timeout field on Session: create a context.WithTimeout(ctx, timeout)
before calling s.rest.Logout(...) and s.govmomi.Logout(...), cancel the context
when done, and fall back to a sensible default if no timeout is set; update the
Session struct to store the timeout from SessionOptions if needed and ensure
both Logout calls use the same bounded context in Session.Close().

s.logger.WithError(err).Warn("Failed to logout SOAP client")
if firstErr == nil {
firstErr = err
}
}
}

s.closeErr = firstErr
s.logger.WithField("vcenter", redactHostname(s.server)).Debug("vSphere session closed")
})
}

// Error returns any error that occurred during Close.
func (s *Session) Error() error {
return s.closeErr
}

func newSessionFromConn(ctx context.Context, server, username, password string, opts *SessionOptions) (*Session, error) {
s := &Session{
server: server,
logger: opts.Logger,
}

u, err := soap.ParseURL(server)
if err != nil {
return nil, errors.Wrap(err, "parse vCenter URL")
}
u.User = url.UserPassword(username, password)

c, err := govmomi.NewClient(ctx, u, opts.Insecure)
if err != nil {
return nil, errors.Wrap(err, "create SOAP client")
}
s.govmomi = c
s.vim25 = c.Client
s.finder = find.NewFinder(s.vim25, true)

restClient := rest.NewClient(c.Client)
if err := restClient.Login(ctx, u.User); err != nil {
// Log out SOAP client before returning error — this replaces the
// buggy pattern in CreateVSphereClients where the logout error
// was swallowed and could mask the original error.
if logoutErr := c.Logout(context.Background()); logoutErr != nil {
opts.Logger.WithError(logoutErr).Warn("Failed to logout SOAP client after REST login failure")
}
return nil, errors.Wrap(err, "REST client login")
}
s.rest = restClient

if cnsClient, err := cns.NewClient(context.Background(), s.vim25); err == nil {
s.cns = cnsClient
} else {
opts.Logger.WithError(err).Debug("CNS client creation failed (optional)")
}

if pbmClient, err := pbm.NewClient(context.Background(), s.vim25); err == nil {
s.pbm = pbmClient
} else {
opts.Logger.WithError(err).Debug("PBM client creation failed (optional)")
}

s.logger.WithField("vcenter", redactHostname(server)).Info("vSphere session created")
return s, nil
}

// Vim25Client returns the underlying SOAP client for general vSphere API calls.
func (s *Session) Vim25Client() *vim25.Client {
return s.vim25
}

// RestClient returns the REST client for vSphere tag and policy operations.
func (s *Session) RestClient() *rest.Client {
return s.rest
}

// CNSClient returns the CNS (vSAN) client for storage volume operations.
func (s *Session) CNSClient() *cns.Client {
return s.cns
}

// PBMClient returns the PBM (storage policy) client.
func (s *Session) PBMClient() *pbm.Client {
return s.pbm
}

// Finder returns a finder for locating vSphere inventory objects.
func (s *Session) Finder() *find.Finder {
return s.finder
}

// Server returns the vCenter server URL.
func (s *Session) Server() string {
return s.server
}
Loading