Skip to content
Merged
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
190 changes: 155 additions & 35 deletions pkg/utils/vault.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,27 @@
package utils

import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strings"
"time"

ocmutils "github.com/openshift/ocm-container/pkg/utils"

log "github.com/sirupsen/logrus"
"github.com/spf13/viper"
)

const (
vaultCallbackPortFile = "/tmp/vault_callback_port"
defaultVaultOIDCPort = "8250"
vaultLoginTimeout = 5 * time.Minute
)

const (
VaultAddrKey string = "vault_address"
)
Expand Down Expand Up @@ -41,17 +51,39 @@ func GetVaultRef(vaultPathKey string) (VaultRef, error) {
return VaultRef{Addr: vaultAddr, Path: vaultPath}, nil
}

// readFileFunc is the function used to read files. Replaced in tests
// to avoid filesystem access.
var readFileFunc = os.ReadFile

// readCallbackPort reads the dynamically-assigned host port from the
// portmap file written by ocm-container's ports feature.
func readCallbackPort() string {
data, err := readFileFunc(vaultCallbackPortFile)
if err != nil {
log.Debugf("could not read vault callback port file: %v", err)
return ""
}
port := strings.TrimSpace(string(data))
if port == "" {
log.Debugf("vault callback port file is empty")
return ""
}
log.Debugf("read vault callback port: %s", port)
return port
}

// setupVaultToken ensures a valid Vault token exists by checking the current
// token and requesting a new one via OIDC if needed. In container environments,
// it configures authentication to work without browser auto-launch.
// it uses the dynamically-assigned callback port from ocm-container's ports
// feature and falls back to in-process token capture if the token file is
// not writable.
func setupVaultToken(vaultAddr string) error {
err := os.Setenv("VAULT_ADDR", vaultAddr)
if err != nil {
return fmt.Errorf("error setting environment variable: %v", err)
}

versionCheckCmd := exec.Command("vault", "version")

versionCheckCmd.Stdout = os.Stderr
versionCheckCmd.Stderr = os.Stderr

Expand All @@ -62,51 +94,139 @@ func setupVaultToken(vaultAddr string) error {
tokenCheckCmd := exec.Command("vault", "token", "lookup")
tokenCheckCmd.Stdout = nil
tokenCheckCmd.Stderr = nil
// get new token since old token has expired
if err = tokenCheckCmd.Run(); err != nil {
log.Infoln("Vault token no longer valid, requesting new token")

// Check if we're in a container environment (OCM_CONTAINER env var is set)
// If so, skip automatic browser launch and print the URL for manual authentication
loginArgs := []string{"login", "-method=oidc", "-no-print"}
if ocmutils.IsRunningInOcmContainer() {
log.Infoln("\nNOTE: Running in container mode - OIDC authentication requires port forwarding.")
log.Infoln("Ensure port 8250 is exposed in your ocm-container configuration:")
log.Infoln(" Add 'launch-opts: \"-p 8250:8250\"' to ~/.config/ocm-container/ocm-container.yaml")
log.Infoln("Then restart your ocm-container for the change to take effect.")

// In container: skip browser launch and listen on all interfaces (0.0.0.0)
// so the callback can be reached from the host browser via localhost:8250
loginArgs = []string{"login", "-method=oidc", "skip_browser=true", "listenaddress=0.0.0.0"}
return setupVaultTokenContainer()
}
loginCmd := exec.Command("vault", loginArgs...)

// Show output when using skip_browser so user can see the authentication URL
if ocmutils.IsRunningInOcmContainer() {
loginCmd.Stdout = os.Stderr
loginCmd.Stderr = os.Stderr
} else {
loginCmd.Stdout = nil
loginCmd.Stderr = nil
}
return setupVaultTokenLocal()
}

return nil
}

// setupVaultTokenLocal handles vault OIDC login outside of a container.
func setupVaultTokenLocal() error {
ctx, cancel := context.WithTimeout(context.Background(), vaultLoginTimeout)
defer cancel()

loginCmd := exec.CommandContext(ctx, "vault", "login", "-method=oidc", "-no-print")
loginCmd.Stdout = nil
loginCmd.Stderr = nil

if err = loginCmd.Run(); err != nil {
if ocmutils.IsRunningInOcmContainer() {
return fmt.Errorf("vault login failed: %v\n\n"+
"If authentication timed out or the callback failed, this is likely because:\n"+
" 1. Port 8250 is not exposed in your ocm-container configuration\n"+
" 2. Your ocm-container was not restarted after adding the port\n\n"+
"To fix:\n"+
" - Add 'launch-opts: \"-p 8250:8250\"' to ~/.config/ocm-container/ocm-container.yaml\n"+
" - Exit and restart your ocm-container\n"+
" - Try the authentication again", err)
}
return fmt.Errorf("error running 'vault login': %v", err)
if err := loginCmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("vault login timed out after %s", vaultLoginTimeout)
}
return fmt.Errorf("error running 'vault login': %v", err)
}

log.Infoln("Acquired vault token")
return nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// buildOIDCArgs builds the vault login args for container mode,
// including the dynamic callback port if available.
func buildOIDCArgs(noStore bool, callbackPort string) []string {
args := []string{"login", "-method=oidc"}
if noStore {
args = append(args, "-no-store", "-field=token")
}
args = append(args, "skip_browser=true", "listenaddress=0.0.0.0")

if callbackPort != "" {
args = append(args,
fmt.Sprintf("port=%s", defaultVaultOIDCPort),
fmt.Sprintf("callbackport=%s", callbackPort),
)
log.Infof("Using dynamic vault OIDC callback port: %s", callbackPort)
} else {
log.Infoln("No dynamic callback port found, using default port 8250.")
log.Infoln("If running multiple containers, ensure ocm-container has the ports feature enabled.")
}

return args
}

// isTokenFileError checks whether a vault login error is related to
// writing the token file (e.g., bind mount rename failures).
func isTokenFileError(err error) bool {
msg := err.Error()
return strings.Contains(msg, "rename") ||
strings.Contains(msg, "device or resource busy") ||
strings.Contains(msg, "read-only file system") ||
strings.Contains(msg, "permission denied")
}

// setupVaultTokenContainer handles vault OIDC login inside an ocm-container.
// It first tries a normal vault login that writes ~/.vault-token. If that
// fails due to a token file write error (e.g., read-only bind mount), it
// falls back to capturing the token in-process via VAULT_TOKEN.
func setupVaultTokenContainer() error {
callbackPort := readCallbackPort()

ctx, cancel := context.WithTimeout(context.Background(), vaultLoginTimeout)
defer cancel()

log.Infoln("Complete the login via the URL printed below.")

// First attempt: normal login that writes ~/.vault-token
loginArgs := buildOIDCArgs(false, callbackPort)
loginCmd := exec.CommandContext(ctx, "vault", loginArgs...)
loginCmd.Stdout = os.Stderr
loginCmd.Stderr = os.Stderr

if err := loginCmd.Run(); err == nil {
log.Infoln("Acquired vault token")
return nil
} else if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("vault login timed out after %s", vaultLoginTimeout)
} else if !isTokenFileError(err) {
return fmt.Errorf("vault login failed: %v\n\n"+
"If authentication timed out or the callback failed:\n"+
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you make this less ocm-container focused? Seems like it would be a general UX issue?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@dustman9000 Same as the other one: this is an ocm-container only problem & this code isn't reachable by normal osdctl users outside of ocm-container.

" 1. Ensure ocm-container has the vault port enabled in the ports feature\n"+
" 2. Restart your ocm-container for the change to take effect\n"+
" 3. Try the authentication again", err)
}

// Token file write failed (bind mount rename issue). Fall back to
// capturing the token in-process.
log.Infof("Token file write failed (%v), capturing token in-process instead.", ctx.Err())
log.Infoln("Complete the login via the URL printed below.")

ctx2, cancel2 := context.WithTimeout(context.Background(), vaultLoginTimeout)
defer cancel2()

loginArgs = buildOIDCArgs(true, callbackPort)
loginCmd = exec.CommandContext(ctx2, "vault", loginArgs...)

var tokenBuf bytes.Buffer
loginCmd.Stdout = &tokenBuf
loginCmd.Stderr = os.Stderr

if err := loginCmd.Run(); err != nil {
if ctx2.Err() == context.DeadlineExceeded {
return fmt.Errorf("vault login timed out after %s", vaultLoginTimeout)
}
return fmt.Errorf("vault login failed: %v\n\n"+
"If authentication timed out or the callback failed:\n"+
" 1. Ensure ocm-container has the vault port enabled in the ports feature\n"+
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Same UX issue here, please make more generic.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@dustman9000 this is an ocm-container only problem, and this function is not reachable unless you are in ocm-container. Normal osdctl users outside of ocm-container won't ever see this message.

" 2. Restart your ocm-container for the change to take effect\n"+
" 3. Try the authentication again", err)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

token := strings.TrimSpace(tokenBuf.String())
if token == "" {
return fmt.Errorf("vault login succeeded but returned empty token")
}

if err := os.Setenv("VAULT_TOKEN", token); err != nil {
return fmt.Errorf("error setting VAULT_TOKEN: %v", err)
}

log.Infoln("Acquired vault token (in-process)")
return nil
}

Expand Down
Loading