Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
589003e
Add ptd verify command for post-deploy VIP testing
ian-flores Feb 23, 2026
b9d1aa4
Address review findings (job 53)
ian-flores Feb 23, 2026
4854af6
Fix YAML injection in Job spec by using JSON serialization
ian-flores Feb 23, 2026
fa47672
Address review findings (job 58)
ian-flores Feb 23, 2026
a905413
Address review findings (job 60)
ian-flores Feb 23, 2026
519f228
Address review findings (job 65)
ian-flores Feb 23, 2026
647f355
Move test username out of credential-handling code
ian-flores Feb 23, 2026
af671aa
Address review findings (job 74)
ian-flores Feb 23, 2026
d2e2bbe
Address review findings (job 77)
ian-flores Feb 23, 2026
476ef34
Address review findings (job 84)
ian-flores Feb 23, 2026
119cae1
Address review findings (job 90)
ian-flores Feb 23, 2026
e3a2f2e
Address review findings (job 92)
ian-flores Feb 23, 2026
29c56d2
Address review findings (job 95)
ian-flores Feb 23, 2026
4f87411
Address review findings (job 100)
ian-flores Feb 23, 2026
6c94399
Address review findings (job 103)
ian-flores Feb 23, 2026
081fa17
Address review findings (job 107)
ian-flores Feb 23, 2026
5306826
Address review findings (job 112)
ian-flores Feb 23, 2026
4bc3dc3
Address review findings (job 114)
ian-flores Feb 23, 2026
84d1a2c
Address review findings (job 118)
ian-flores Feb 23, 2026
822c358
Address review findings (job 121)
ian-flores Feb 23, 2026
f8430a5
Address review findings (job 124)
ian-flores Feb 23, 2026
3d239f2
Address review findings (job 125)
ian-flores Feb 23, 2026
b17ff7d
Add ptd verify documentation and auth mode guide
ian-flores Feb 24, 2026
5148af9
Add --interactive-auth flag and cleanup subcommand to ptd verify
ian-flores Mar 6, 2026
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
2 changes: 1 addition & 1 deletion cmd/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ go 1.25.3
replace github.com/posit-dev/ptd/lib => ../lib

require (
github.com/BurntSushi/toml v1.6.0
github.com/charmbracelet/log v0.4.2
github.com/posit-dev/ptd/lib v0.0.0-00010101000000-000000000000
github.com/spf13/cobra v1.9.1
Expand All @@ -28,7 +29,6 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/agext/levenshtein v1.2.3 // indirect
Expand Down
4 changes: 2 additions & 2 deletions cmd/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJ
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/HdrHistogram/hdrhistogram-go v1.1.2 h1:5IcZpTvzydCQeHzK4Ef/D5rrSqwxob0t8PQPMybUNFM=
github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
Expand Down
149 changes: 149 additions & 0 deletions cmd/internal/verify/cleanup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package verify

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strings"
"time"
)

// CleanupCredentials deletes VIP test credentials and resources.
func CleanupCredentials(ctx context.Context, env []string, namespace, connectURL string) error {
// Read vip-test-credentials Secret to get the Connect API key and key name
cmd := exec.CommandContext(ctx, "kubectl", "get", "secret", vipTestCredentialsSecret,
"-n", namespace,
"-o", "json")
cmd.Env = env

output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
// Secret doesn't exist, nothing to clean up
if strings.Contains(string(exitErr.Stderr), "not found") {
fmt.Fprintf(os.Stderr, "No credentials secret found, nothing to clean up\n")
return nil
}
return fmt.Errorf("failed to get credentials secret: %s", string(exitErr.Stderr))
}
return fmt.Errorf("failed to get credentials secret: %w", err)
}

var secret struct {
Data map[string]string `json:"data"`
}
if err := json.Unmarshal(output, &secret); err != nil {
return fmt.Errorf("failed to parse secret: %w", err)
}

// Extract and decode the Connect API key and key name
apiKeyB64, hasAPIKey := secret.Data["VIP_CONNECT_API_KEY"]
keyNameB64, hasKeyName := secret.Data["VIP_CONNECT_KEY_NAME"]

if hasAPIKey && hasKeyName && connectURL != "" {
// Decode base64 values
apiKeyBytes, err := base64.StdEncoding.DecodeString(apiKeyB64)
if err != nil {
return fmt.Errorf("failed to decode API key: %w", err)
}
apiKey := string(apiKeyBytes)

keyNameBytes, err := base64.StdEncoding.DecodeString(keyNameB64)
if err != nil {
return fmt.Errorf("failed to decode key name: %w", err)
}
keyName := string(keyNameBytes)

// Delete the Connect API key via the Connect API
if err := deleteConnectAPIKey(ctx, connectURL, apiKey, keyName); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to delete Connect API key: %v\n", err)
// Continue with cleanup even if API key deletion fails
} else {
fmt.Fprintf(os.Stderr, "Deleted Connect API key: %s\n", keyName)
}
}

// Delete the vip-test-credentials K8s Secret
deleteCmd := exec.CommandContext(ctx, "kubectl", "delete", "secret", vipTestCredentialsSecret,
"-n", namespace,
"--ignore-not-found")
deleteCmd.Env = env

if err := deleteCmd.Run(); err != nil {
return fmt.Errorf("failed to delete credentials secret: %w", err)
}

fmt.Fprintf(os.Stderr, "Deleted credentials secret: %s\n", vipTestCredentialsSecret)
return nil
}

// deleteConnectAPIKey deletes a Connect API key by name using the Connect API.
func deleteConnectAPIKey(ctx context.Context, connectURL, apiKey, keyName string) error {
client := &http.Client{Timeout: 30 * time.Second}

// GET all API keys for the user
listURL := fmt.Sprintf("%s/__api__/v1/user/api_keys", connectURL)
req, err := http.NewRequestWithContext(ctx, "GET", listURL, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Key "+apiKey)

resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("list API keys failed with status %d: %s", resp.StatusCode, string(body))
}

var apiKeys []struct {
ID string `json:"id"`
Name string `json:"name"`
}
if err := json.NewDecoder(resp.Body).Decode(&apiKeys); err != nil {
return fmt.Errorf("failed to parse API keys response: %w", err)
}

// Find the key by name
var keyID string
for _, key := range apiKeys {
if key.Name == keyName {
keyID = key.ID
break
}
}

if keyID == "" {
return fmt.Errorf("API key with name %q not found", keyName)
}

// DELETE the API key by ID
deleteURL := fmt.Sprintf("%s/__api__/v1/user/api_keys/%s", connectURL, keyID)
req, err = http.NewRequestWithContext(ctx, "DELETE", deleteURL, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Key "+apiKey)

resp, err = client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("delete API key failed with status %d: %s", resp.StatusCode, string(body))
}

return nil
}
175 changes: 175 additions & 0 deletions cmd/internal/verify/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package verify

import (
"bytes"
"fmt"

"github.com/BurntSushi/toml"
)

// VIPConfig represents the vip.toml configuration structure
type VIPConfig struct {
General GeneralConfig `toml:"general"`
Connect ProductConfig `toml:"connect"`
Workbench ProductConfig `toml:"workbench"`
PackageManager ProductConfig `toml:"package_manager"`
Auth AuthConfig `toml:"auth"`
Email DisableableConfig `toml:"email"`
Monitoring DisableableConfig `toml:"monitoring"`
Security SecurityConfig `toml:"security"`
}

type GeneralConfig struct {
DeploymentName string `toml:"deployment_name"`
}

type ProductConfig struct {
Enabled bool `toml:"enabled"`
URL string `toml:"url,omitempty"`
}

type AuthConfig struct {
Provider string `toml:"provider"`
}

type DisableableConfig struct {
Enabled bool `toml:"enabled"`
}

type SecurityConfig struct {
PolicyChecksEnabled bool `toml:"policy_checks_enabled"`
}

// SiteCR represents the Kubernetes Site custom resource
type SiteCR struct {
Spec SiteSpec `yaml:"spec"`
}

type SiteSpec struct {
Domain string `yaml:"domain"`
Connect *ProductSpec `yaml:"connect,omitempty"`
Workbench *ProductSpec `yaml:"workbench,omitempty"`
PackageManager *ProductSpec `yaml:"packageManager,omitempty"`
Keycloak *KeycloakSpec `yaml:"keycloak,omitempty"`
}

type ProductSpec struct {
DomainPrefix string `yaml:"domainPrefix,omitempty"`
// BaseDomain is the bare parent domain for this product (e.g. "example.com").
// It must NOT include the product subdomain; buildProductURL always prepends the
// product prefix (DomainPrefix or the default) to form the final URL.
// For example, BaseDomain="example.com" with default prefix "connect" yields
// "https://connect.example.com".
BaseDomain string `yaml:"baseDomain,omitempty"`
Auth *AuthSpec `yaml:"auth,omitempty"`
}

type AuthSpec struct {
Type string `yaml:"type"`
}

type KeycloakSpec struct {
Enabled bool `yaml:"enabled"`
}

// GenerateConfig generates a vip.toml configuration from a parsed Site CR
func GenerateConfig(site *SiteCR, targetName string) (string, error) {
if site == nil {
return "", fmt.Errorf("site cannot be nil")
}

needsDomain := (site.Spec.Connect != nil && site.Spec.Connect.BaseDomain == "") ||
(site.Spec.Workbench != nil && site.Spec.Workbench.BaseDomain == "") ||
(site.Spec.PackageManager != nil && site.Spec.PackageManager.BaseDomain == "")
if site.Spec.Domain == "" && needsDomain {
return "", fmt.Errorf("site domain is required when products are configured without a per-product baseDomain")
}

config := VIPConfig{
General: GeneralConfig{
DeploymentName: targetName,
},
Email: DisableableConfig{
Enabled: false,
},
Monitoring: DisableableConfig{
Enabled: false,
},
Security: SecurityConfig{
PolicyChecksEnabled: false,
},
}

// Determine auth provider. PackageManager is intentionally excluded: it does not
// support authentication types that VIP tests against, so its auth spec is not consulted.
authProvider := "oidc" // default
if site.Spec.Connect != nil && site.Spec.Connect.Auth != nil && site.Spec.Connect.Auth.Type != "" {
authProvider = site.Spec.Connect.Auth.Type
} else if site.Spec.Workbench != nil && site.Spec.Workbench.Auth != nil && site.Spec.Workbench.Auth.Type != "" {
authProvider = site.Spec.Workbench.Auth.Type
}
config.Auth = AuthConfig{Provider: authProvider}

// Configure Connect
if site.Spec.Connect != nil {
productURL := buildProductURL(site.Spec.Connect, "connect", site.Spec.Domain)
config.Connect = ProductConfig{
Enabled: true,
URL: productURL,
}
} else {
config.Connect = ProductConfig{Enabled: false}
}

// Configure Workbench
if site.Spec.Workbench != nil {
productURL := buildProductURL(site.Spec.Workbench, "workbench", site.Spec.Domain)
config.Workbench = ProductConfig{
Enabled: true,
URL: productURL,
}
} else {
config.Workbench = ProductConfig{Enabled: false}
}

// Configure Package Manager
if site.Spec.PackageManager != nil {
productURL := buildProductURL(site.Spec.PackageManager, "packagemanager", site.Spec.Domain)
config.PackageManager = ProductConfig{
Enabled: true,
URL: productURL,
}
} else {
config.PackageManager = ProductConfig{Enabled: false}
}

// Encode to TOML
var buf bytes.Buffer
encoder := toml.NewEncoder(&buf)
if err := encoder.Encode(config); err != nil {
return "", fmt.Errorf("failed to encode TOML: %w", err)
}

return buf.String(), nil
}

// buildProductURL constructs the product URL from the product spec.
// The prefix (DomainPrefix or defaultPrefix) is always prepended to the domain, so
// ProductSpec.BaseDomain must be a bare parent domain (e.g. "example.com"), not a
// fully-qualified hostname that already includes the product subdomain.
func buildProductURL(spec *ProductSpec, defaultPrefix, baseDomain string) string {
if spec == nil {
return fmt.Sprintf("https://%s.%s", defaultPrefix, baseDomain)
}
prefix := defaultPrefix
if spec.DomainPrefix != "" {
prefix = spec.DomainPrefix
}

domain := baseDomain
if spec.BaseDomain != "" {
domain = spec.BaseDomain
}

return fmt.Sprintf("https://%s.%s", prefix, domain)
}
Loading
Loading