Skip to content

Instantly share code, notes, and snippets.

@jparrill
Created August 18, 2025 09:53
Show Gist options
  • Select an option

  • Save jparrill/74939f12410404b53b0611049d26e5f2 to your computer and use it in GitHub Desktop.

Select an option

Save jparrill/74939f12410404b53b0611049d26e5f2 to your computer and use it in GitHub Desktop.
OIDC Tools

OIDC Tools

Tools for managing OIDC (OpenID Connect) documents in Hypershift HostedClusters. These tools are useful for troubleshooting authentication issues after cluster restoration or when OIDC documents are missing from S3.

Installation

# Clone or download the files
cd ~/oidc-tools

# Initialize Go module
go mod init oidc-tools

# Download dependencies
go mod tidy

# Compile
go build -o oidc-tools .

# Make executable
chmod +x oidc-tools

Commands

regenerate

Regenerates OIDC documents for a HostedCluster by creating the necessary documents in S3 and optionally waiting for nodes to be ready.

Usage:

./oidc-tools regenerate --name <cluster-name> [options]

Options:

  • --name: HostedCluster name (required)
  • --namespace: HostedCluster namespace (default: "clusters")
  • --kubeconfig: Path to kubeconfig file
  • --wait-for-nodes: Wait for nodes to be ready after OIDC regeneration (default: true)
  • --timeout: Timeout for the operation (default: 10m)

Example:

./oidc-tools regenerate --name jparrill-hosted --namespace clusters --wait-for-nodes

delete

Deletes OIDC documents for a HostedCluster from S3. Use with caution as this will break node authentication.

Usage:

./oidc-tools delete --name <cluster-name> --confirm [options]

Options:

  • --name: HostedCluster name (required)
  • --namespace: HostedCluster namespace (default: "clusters")
  • --kubeconfig: Path to kubeconfig file
  • --confirm: Confirm deletion of OIDC documents (required)

Example:

./oidc-tools delete --name jparrill-hosted --namespace clusters --confirm

Use Cases

Scenario 1: Cluster Restoration Issues

After restoring a HostedCluster with Velero, you may encounter authentication errors such as:

WebIdentityErr: failed to retrieve credentials
InvalidIdentityToken: Couldn't retrieve verification key from your identity provider

This typically happens because:

  1. The restored cluster has a different infraID than the original
  2. The OIDC documents in S3 correspond to the previous infraID
  3. The issuerURL points to the new infraID but the documents don't exist

Solution:

# Regenerate OIDC documents for the restored cluster
./oidc-tools regenerate --name <cluster-name> --wait-for-nodes

Scenario 2: Testing OIDC Regeneration

To test the OIDC regeneration process:

# 1. Delete OIDC documents (simulates missing documents)
./oidc-tools delete --name <cluster-name> --confirm

# 2. Regenerate OIDC documents
./oidc-tools regenerate --name <cluster-name> --wait-for-nodes

How It Works

regenerate Process

  1. Load kubeconfig and create Kubernetes client
  2. Get HostedCluster using dynamic client
  3. Check if OIDC documents exist in S3 for the cluster's infraID
  4. If documents don't exist:
    • Get the signing key from the cluster's service account
    • Generate OIDC configuration document
    • Generate JWKS (JSON Web Key Set) document
    • Upload both documents to S3
  5. Optionally wait for nodes to be ready

delete Process

  1. Load kubeconfig and create Kubernetes client
  2. Get HostedCluster using dynamic client
  3. Parse issuer URL to extract bucket name, region, and infraID
  4. Delete OIDC documents from S3:
    • {infraID}/.well-known/openid-configuration
    • {infraID}/openid/v1/jwks

OIDC Document Structure

OIDC documents are stored in S3 with the following structure:

s3://{bucket-name}/
├── {infraID}/
│   ├── .well-known/
│   │   └── openid-configuration
│   └── openid/
│       └── v1/
│           └── jwks

openid-configuration

Contains the OIDC discovery information:

{
  "issuer": "https://bucket.s3.region.amazonaws.com/infraID",
  "jwks_uri": "https://bucket.s3.region.amazonaws.com/infraID/openid/v1/jwks",
  "response_types_supported": ["id_token"],
  "subject_types_supported": ["public"],
  "id_token_signing_alg_values_supported": ["RS256"]
}

jwks

Contains the JSON Web Key Set with the public key:

{
  "keys": [
    {
      "use": "sig",
      "kty": "RSA",
      "kid": "base64-encoded-sha256-hash",
      "alg": "RS256",
      "n": "base64-encoded-modulus",
      "e": "base64-encoded-exponent"
    }
  ]
}

Troubleshooting

Common Issues

  1. Permission denied accessing S3

    • Ensure AWS credentials are configured correctly
    • Verify that the bucket exists and is accessible
  2. Error getting service account signing key

    • Verify that the cluster is running
    • Verify that the secret exists in the correct namespace
  3. Nodes don't become ready

    • Review ControlPlaneOperator logs for authentication errors
    • Verify that the OIDC provider in AWS IAM is configured correctly

Debugging

To debug OIDC issues:

# Check if OIDC documents exist
aws s3 ls s3://{bucket-name}/{infraID}/.well-known/openid-configuration

# Check OIDC configuration
curl https://{bucket-name}.s3.{region}.amazonaws.com/{infraID}/.well-known/openid-configuration

# Check JWKS
curl https://{bucket-name}.s3.{region}.amazonaws.com/{infraID}/openid/v1/jwks

Example Script

An example script (example-usage.sh) is included that demonstrates how to use the tools:

# Make executable
chmod +x example-usage.sh

# Run example
./example-usage.sh

Requirements

  • Go 1.21 or higher
  • AWS credentials configured
  • Access to the Kubernetes cluster where the HostedCluster is located
  • Permissions to access the OIDC S3 bucket

Dependencies

  • github.com/aws/aws-sdk-go - For interacting with AWS S3
  • github.com/spf13/cobra - For command line interface
  • k8s.io/client-go - For interacting with Kubernetes
  • k8s.io/apimachinery - For Kubernetes types
package main
import (
"context"
"fmt"
"strings"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/spf13/cobra"
flag "github.com/spf13/pflag"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
)
type DeleteOptions struct {
HostedClusterName string
HostedClusterNamespace string
Kubeconfig string
Confirm bool
}
func newDeleteCommand() *cobra.Command {
opts := &DeleteOptions{
HostedClusterNamespace: "clusters",
Confirm: false,
}
cmd := &cobra.Command{
Use: "delete",
Short: "Deletes OIDC documents for a HostedCluster",
Long: "Deletes OIDC documents for a HostedCluster from S3. Use with caution as this will break node authentication.",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
return opts.Run(cmd.Context())
},
}
opts.BindFlags(cmd.Flags())
_ = cmd.MarkFlagRequired("name")
return cmd
}
func (o *DeleteOptions) BindFlags(flags *flag.FlagSet) {
flags.StringVar(&o.HostedClusterName, "name", o.HostedClusterName, "HostedCluster name (required)")
flags.StringVar(&o.HostedClusterNamespace, "namespace", o.HostedClusterNamespace, "HostedCluster namespace")
flags.StringVar(&o.Kubeconfig, "kubeconfig", o.Kubeconfig, "Path to kubeconfig file")
flags.BoolVar(&o.Confirm, "confirm", o.Confirm, "Confirm deletion of OIDC documents")
}
func (o *DeleteOptions) Run(ctx context.Context) error {
fmt.Printf("Starting OIDC document deletion for cluster: %s in namespace: %s\n", o.HostedClusterName, o.HostedClusterNamespace)
if !o.Confirm {
return fmt.Errorf("use --confirm to acknowledge that you want to delete OIDC documents")
}
// Load kubeconfig
config, err := clientcmd.BuildConfigFromFlags("", o.Kubeconfig)
if err != nil {
return fmt.Errorf("failed to load kubeconfig: %w", err)
}
// Get HostedCluster
hostedCluster, err := o.getHostedCluster(ctx, config)
if err != nil {
return fmt.Errorf("failed to get hosted cluster: %w", err)
}
fmt.Printf("Found HostedCluster: name=%s, infraID=%s, issuerURL=%s\n",
hostedCluster.Name, hostedCluster.Spec.InfraID, hostedCluster.Spec.IssuerURL)
// Delete OIDC documents from S3
if err := o.deleteOIDCDocuments(hostedCluster); err != nil {
return fmt.Errorf("failed to delete OIDC documents: %w", err)
}
fmt.Println("Successfully deleted OIDC documents")
return nil
}
func (o *DeleteOptions) getHostedCluster(ctx context.Context, config *rest.Config) (*HostedCluster, error) {
// Create a dynamic client to access the hosted cluster
dynamicClient, err := dynamic.NewForConfig(config)
if err != nil {
return nil, fmt.Errorf("failed to create dynamic client: %w", err)
}
// Define the GVR for HostedCluster
gvr := schema.GroupVersionResource{
Group: "hypershift.openshift.io",
Version: "v1beta1",
Resource: "hostedclusters",
}
// Get the hosted cluster
obj, err := dynamicClient.Resource(gvr).
Namespace(o.HostedClusterNamespace).
Get(ctx, o.HostedClusterName, metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("failed to get hosted cluster %s/%s: %w", o.HostedClusterNamespace, o.HostedClusterName, err)
}
// Convert to HostedCluster
hostedCluster := &HostedCluster{}
err = runtime.DefaultUnstructuredConverter.FromUnstructured(obj.UnstructuredContent(), hostedCluster)
if err != nil {
return nil, fmt.Errorf("failed to convert to HostedCluster: %w", err)
}
return hostedCluster, nil
}
func (o *DeleteOptions) deleteOIDCDocuments(hostedCluster *HostedCluster) error {
// Parse issuer URL to get bucket and region
issuerURL := hostedCluster.Spec.IssuerURL
if !strings.HasPrefix(issuerURL, "https://") {
return fmt.Errorf("invalid issuer URL format: %s", issuerURL)
}
// Extract bucket and region from issuer URL
// Format: https://bucket.s3.region.amazonaws.com/infraID
parts := strings.Split(strings.TrimPrefix(issuerURL, "https://"), "/")
if len(parts) < 2 {
return fmt.Errorf("invalid issuer URL format: %s", issuerURL)
}
bucketParts := strings.Split(parts[0], ".")
if len(bucketParts) < 4 {
return fmt.Errorf("invalid S3 URL format: %s", issuerURL)
}
bucketName := bucketParts[0]
region := bucketParts[2]
infraID := parts[1]
// Create S3 client
sess := session.Must(session.NewSession(&aws.Config{
Region: aws.String(region),
}))
s3Client := s3.New(sess)
// Define objects to delete
objectsToDelete := []*s3.ObjectIdentifier{
{
Key: aws.String(infraID + "/.well-known/openid-configuration"),
},
{
Key: aws.String(infraID + "/openid/v1/jwks"),
},
}
// Delete objects from S3
_, err := s3Client.DeleteObjects(&s3.DeleteObjectsInput{
Bucket: aws.String(bucketName),
Delete: &s3.Delete{
Objects: objectsToDelete,
},
})
if err != nil {
if aerr, ok := err.(awserr.Error); ok && aerr.Code() == s3.ErrCodeNoSuchBucket {
fmt.Printf("Bucket does not exist, nothing to delete: %s\n", bucketName)
return nil
}
return fmt.Errorf("failed to delete OIDC objects from %s S3 bucket: %w", bucketName, err)
}
fmt.Printf("Successfully deleted OIDC documents from bucket: %s, infraID: %s\n", bucketName, infraID)
return nil
}
#!/bin/bash
# OIDC Tools Example Script
# This script demonstrates how to use OIDC tools to troubleshoot authentication issues
set -e
# Configuration
CLUSTER_NAME="jparrill-hosted"
CLUSTER_NAMESPACE="clusters"
KUBECONFIG_PATH="/path/to/your/kubeconfig"
echo "=== OIDC Documents Management Example ==="
echo "Cluster: $CLUSTER_NAME"
echo "Namespace: $CLUSTER_NAMESPACE"
echo "Kubeconfig: $KUBECONFIG_PATH"
echo
# Function to check if cluster exists
check_cluster_exists() {
echo "Checking if cluster exists..."
if ! ./oidc-tools regenerate --name "$CLUSTER_NAME" --namespace "$CLUSTER_NAMESPACE" --kubeconfig "$KUBECONFIG_PATH" --wait-for-nodes=false > /dev/null 2>&1; then
echo "❌ Cluster $CLUSTER_NAME not found in namespace $CLUSTER_NAMESPACE"
exit 1
fi
echo "✅ Cluster found"
}
# Function to check OIDC documents
check_oidc_documents() {
echo "Checking OIDC documents..."
# Get cluster information to extract issuer URL
CLUSTER_INFO=$(./oidc-tools regenerate --name "$CLUSTER_NAME" --namespace "$CLUSTER_NAMESPACE" --kubeconfig "$KUBECONFIG_PATH" --wait-for-nodes=false 2>&1 || true)
if echo "$CLUSTER_INFO" | grep -q "OIDC documents already exist"; then
echo "✅ OIDC documents exist"
return 0
elif echo "$CLUSTER_INFO" | grep -q "OIDC documents not found"; then
echo "❌ OIDC documents missing"
return 1
else
echo "❓ Could not determine OIDC documents status"
return 2
fi
}
# Function to simulate missing OIDC documents
simulate_missing_oidc() {
echo "Simulating missing OIDC documents..."
echo "⚠️ This will temporarily break node authentication"
read -p "Continue? (y/N): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
./oidc-tools delete --name "$CLUSTER_NAME" --namespace "$CLUSTER_NAMESPACE" --kubeconfig "$KUBECONFIG_PATH" --confirm
echo "✅ OIDC documents deleted"
else
echo "❌ Operation cancelled"
exit 1
fi
}
# Function to regenerate OIDC documents
regenerate_oidc() {
echo "Regenerating OIDC documents..."
./oidc-tools regenerate --name "$CLUSTER_NAME" --namespace "$CLUSTER_NAMESPACE" --kubeconfig "$KUBECONFIG_PATH" --wait-for-nodes
echo "✅ OIDC documents regenerated"
}
# Function to check node status
check_nodes() {
echo "Checking node status..."
kubectl --kubeconfig "$KUBECONFIG_PATH" get nodes -o wide
}
# Function to show ControlPlaneOperator logs
show_controlplane_logs() {
echo "Showing ControlPlaneOperator logs..."
kubectl --kubeconfig "$KUBECONFIG_PATH" logs -n clusters-$CLUSTER_NAME deployment/control-plane-operator --tail=50 | grep -i "oidc\|auth\|identity" || echo "No relevant logs found"
}
# Function to check OIDC documents in S3
check_s3_oidc() {
echo "Checking OIDC documents in S3..."
# Get cluster information
CLUSTER_INFO=$(./oidc-tools regenerate --name "$CLUSTER_NAME" --namespace "$CLUSTER_NAMESPACE" --kubeconfig "$KUBECONFIG_PATH" --wait-for-nodes=false 2>&1 || true)
# Extract issuer URL (this is simplified)
if echo "$CLUSTER_INFO" | grep -q "issuerURL="; then
ISSUER_URL=$(echo "$CLUSTER_INFO" | grep "issuerURL=" | cut -d'=' -f2)
echo "Issuer URL: $ISSUER_URL"
# Check documents in S3
if [[ $ISSUER_URL =~ https://([^.]+)\.s3\.([^.]+)\.amazonaws\.com/([^/]+) ]]; then
BUCKET="${BASH_REMATCH[1]}"
REGION="${BASH_REMATCH[2]}"
INFRA_ID="${BASH_REMATCH[3]}"
echo "Bucket: $BUCKET"
echo "Region: $REGION"
echo "InfraID: $INFRA_ID"
# Check OIDC configuration
if aws s3 ls "s3://$BUCKET/$INFRA_ID/.well-known/openid-configuration" > /dev/null 2>&1; then
echo "✅ OIDC configuration exists in S3"
else
echo "❌ OIDC configuration does not exist in S3"
fi
# Check JWKS
if aws s3 ls "s3://$BUCKET/$INFRA_ID/openid/v1/jwks" > /dev/null 2>&1; then
echo "✅ JWKS exists in S3"
else
echo "❌ JWKS does not exist in S3"
fi
fi
fi
}
# Main function
main() {
echo "Step 1: Verifying that cluster exists"
check_cluster_exists
echo
echo "Step 2: Checking current status of OIDC documents"
if check_oidc_documents; then
echo "OIDC documents are present. To test regeneration:"
echo "1. Execute: ./oidc-tools delete --name $CLUSTER_NAME --confirm"
echo "2. Execute: ./oidc-tools regenerate --name $CLUSTER_NAME --wait-for-nodes"
else
echo "OIDC documents are missing. Regenerating..."
regenerate_oidc
fi
echo
echo "Step 3: Checking node status"
check_nodes
echo
echo "Step 4: Checking OIDC documents in S3"
check_s3_oidc
echo
echo "Step 5: Showing ControlPlaneOperator logs"
show_controlplane_logs
echo
echo "=== Summary ==="
echo "✅ Cluster verification completed"
echo "✅ OIDC documents status verified"
echo "✅ Node status verified"
echo "✅ S3 documents verified"
echo "✅ ControlPlaneOperator logs reviewed"
echo
echo "If you still experience authentication issues:"
echo "1. Review ControlPlaneOperator logs for OIDC errors"
echo "2. Verify OIDC provider configuration in AWS IAM"
echo "3. Ensure S3 bucket permissions are correct"
echo "4. Verify that AWS credentials are configured"
}
# Help function
show_help() {
echo "Usage: $0 [options]"
echo
echo "Options:"
echo " --cluster-name NAME Cluster name (default: $CLUSTER_NAME)"
echo " --namespace NAMESPACE Cluster namespace (default: $CLUSTER_NAMESPACE)"
echo " --kubeconfig PATH Path to kubeconfig (default: $KUBECONFIG_PATH)"
echo " --simulate-missing Simulate missing OIDC documents"
echo " --help Show this help"
echo
echo "Examples:"
echo " $0"
echo " $0 --cluster-name my-cluster --kubeconfig /path/to/kubeconfig"
echo " $0 --simulate-missing"
}
# Process arguments
while [[ $# -gt 0 ]]; do
case $1 in
--cluster-name)
CLUSTER_NAME="$2"
shift 2
;;
--namespace)
CLUSTER_NAMESPACE="$2"
shift 2
;;
--kubeconfig)
KUBECONFIG_PATH="$2"
shift 2
;;
--simulate-missing)
echo "Simulation mode activated"
main
simulate_missing_oidc
regenerate_oidc
exit 0
;;
--help)
show_help
exit 0
;;
*)
echo "Unknown option: $1"
show_help
exit 1
;;
esac
done
# Execute main function
main "$@"
module oidc-tools
go 1.21
require (
github.com/aws/aws-sdk-go v1.50.0
github.com/spf13/cobra v1.8.0
github.com/spf13/pflag v1.0.5
k8s.io/api v0.29.0
k8s.io/apimachinery v0.29.0
k8s.io/client-go v0.29.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
github.com/go-logr/logr v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.19.6 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.22.3 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/gnostic-models v0.6.8 // indirect
github.com/google/gofuzz v1.2.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/imdario/mergo v0.3.6 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/oauth2 v0.10.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/term v0.13.0 // indirect
golang.org/x/text v0.13.0 // indirect
golang.org/x/time v0.3.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/klog/v2 v2.110.1 // indirect
k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 // indirect
k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect
sigs.k8s.io/yaml v1.3.0 // indirect
)
github.com/aws/aws-sdk-go v1.50.0 h1:HBtrLeO+QyDKnc3t1+5DR1RxodOHCGr8ZcrHudpv7jI=
github.com/aws/aws-sdk-go v1.50.0/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY=
github.com/go-logr/logr v1.3.0/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec=
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28=
github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/onsi/ginkgo/v2 v2.13.0 h1:0jY9lJquiL8fcf3M4LAXN5aMlS/b2BV86HFFPCPMgE4=
github.com/onsi/ginkgo/v2 v2.13.0/go.mod h1:TE309ZR8s5FsKKpuB1YAQYBzCaAfUgatB/xlT/ETL/o=
github.com/onsi/gomega v1.29.0 h1:KIA/t2t5UBzoirT4H9tsML45GEbo3ouUnBHsCfD2tVg=
github.com/onsi/gomega v1.29.0/go.mod h1:9sxs+SwGrKI0+PWe4Fxa9tFQQBG5xSsSbMXOI8PPpoQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/oauth2 v0.10.0 h1:zHCpF2Khkwy4mMB4bv0U37YtJdTGW8jI0glAApi0Kh8=
golang.org/x/oauth2 v0.10.0/go.mod h1:kTpgurOux7LqtuxjuyZa4Gj2gdezIt/jQtGnNFfypQI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.12.0 h1:YW6HUoUmYBpwSgyaGaZq1fHjrBjX1rlpZ54T6mu2kss=
golang.org/x/tools v0.12.0/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/api v0.29.0 h1:NiCdQMY1QOp1H8lfRyeEf8eOwV6+0xA6XEE44ohDX2A=
k8s.io/api v0.29.0/go.mod h1:sdVmXoz2Bo/cb77Pxi71IPTSErEW32xa4aXwKH7gfBA=
k8s.io/apimachinery v0.29.0 h1:+ACVktwyicPz0oc6MTMLwa2Pw3ouLAfAon1wPLtG48o=
k8s.io/apimachinery v0.29.0/go.mod h1:eVBxQ/cwiJxH58eK/jd/vAk4mrxmVlnpBH5J2GbMeis=
k8s.io/client-go v0.29.0 h1:KmlDtFcrdUzOYrBhXHgKw5ycWzc3ryPX5mQe0SkG3y8=
k8s.io/client-go v0.29.0/go.mod h1:yLkXH4HKMAywcrD82KMSmfYg2DlE8mepPR4JGSo5n38=
k8s.io/klog/v2 v2.110.1 h1:U/Af64HJf7FcwMcXyKm2RPM22WZzyR7OSpYj5tg3cL0=
k8s.io/klog/v2 v2.110.1/go.mod h1:YGtd1984u+GgbuZ7e08/yBuAfKLSO0+uR1Fhi6ExXjo=
k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00 h1:aVUu9fTY98ivBPKR9Y5w/AuzbMm96cd3YHRTU83I780=
k8s.io/kube-openapi v0.0.0-20231010175941-2dd684a91f00/go.mod h1:AsvuZPBlUDVuCdzJ87iajxtXuR9oktsTctW/R9wwouA=
k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI=
k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4=
sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08=
sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo=
sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8=
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
func main() {
cmd := &cobra.Command{
Use: "oidc-tools",
Short: "Tools for managing OIDC documents in Hypershift HostedClusters",
Long: `OIDC Tools provides commands to manage OIDC (OpenID Connect) documents
for Hypershift HostedClusters. These tools are useful for troubleshooting
authentication issues after cluster restoration or when OIDC documents are missing from S3.`,
}
cmd.AddCommand(newRegenerateCommand())
cmd.AddCommand(newDeleteCommand())
if err := cmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
# Makefile for OIDC Tools
.PHONY: help build clean test install run-example
# Variables
BINARY_NAME=oidc-tools
VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
help: ## Show this help
@echo "OIDC Tools - Tools for managing OIDC documents in Hypershift HostedClusters"
@echo ""
@echo "Available commands:"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
build: ## Compile the binary
@echo "Compiling $(BINARY_NAME)..."
go build -o $(BINARY_NAME) .
@echo "✅ Compilation completed"
clean: ## Clean generated files
@echo "Cleaning generated files..."
rm -f $(BINARY_NAME)
go clean
@echo "✅ Cleanup completed"
test: ## Run tests
@echo "Running tests..."
go test -v ./...
@echo "✅ Tests completed"
install: build ## Install the binary in PATH
@echo "Installing $(BINARY_NAME)..."
cp $(BINARY_NAME) /usr/local/bin/
@echo "✅ Installation completed"
deps: ## Download dependencies
@echo "Downloading dependencies..."
go mod tidy
go mod download
@echo "✅ Dependencies downloaded"
run-example: build ## Run the example script
@echo "Running example script..."
@echo "⚠️ Make sure to configure the variables in example-usage.sh"
./example-usage.sh
# OIDC specific commands
regenerate-example: build ## OIDC regeneration example
@echo "OIDC regeneration example:"
@echo "./$(BINARY_NAME) regenerate --name <cluster-name> --namespace clusters --wait-for-nodes"
delete-example: build ## OIDC deletion example
@echo "OIDC deletion example:"
@echo "./$(BINARY_NAME) delete --name <cluster-name> --namespace clusters --confirm"
# Development commands
fmt: ## Format code
@echo "Formatting code..."
go fmt ./...
@echo "✅ Formatting completed"
lint: ## Run linter
@echo "Running linter..."
golangci-lint run
@echo "✅ Linter completed"
vet: ## Run go vet
@echo "Running go vet..."
go vet ./...
@echo "✅ Go vet completed"
# Information commands
version: ## Show version
@echo "Version: $(VERSION)"
info: ## Show project information
@echo "=== Project Information ==="
@echo "Name: $(BINARY_NAME)"
@echo "Version: $(VERSION)"
@echo "Go version: $(shell go version)"
@echo "Files:"
@ls -la *.go *.mod *.md *.sh Makefile 2>/dev/null || true
# User help commands
setup: deps build ## Setup the project (download deps and compile)
@echo "✅ Setup completed"
all: clean deps build test ## Run the entire pipeline (clean, deps, build, test)
@echo "✅ Pipeline completed"
# Debugging commands
debug-info: ## Show debugging information
@echo "=== Debugging Information ==="
@echo "Go modules:"
@go list -m all
@echo ""
@echo "Direct dependencies:"
@go mod graph | head -10
@echo ""
@echo "Environment variables:"
@echo "GOPATH: $(GOPATH)"
@echo "GOROOT: $(GOROOT)"
@echo "GO111MODULE: $(GO111MODULE)"
# Documentation commands
docs: ## Generate documentation
@echo "Generating documentation..."
@echo "📖 README.md is already available"
@echo "📖 Use 'make help' to see all available commands"
# Default target
.DEFAULT_GOAL := help
package main
import (
"context"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/spf13/cobra"
flag "github.com/spf13/pflag"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
)
type RegenerateOptions struct {
HostedClusterName string
HostedClusterNamespace string
Kubeconfig string
WaitForNodes bool
Timeout time.Duration
Local bool
}
func newRegenerateCommand() *cobra.Command {
opts := &RegenerateOptions{
HostedClusterNamespace: "clusters",
WaitForNodes: true,
Timeout: 10 * time.Minute,
}
cmd := &cobra.Command{
Use: "regenerate",
Short: "Regenerates OIDC documents for a HostedCluster",
Long: "Regenerates OIDC documents for a HostedCluster by creating the necessary documents in S3 and verifying node readiness",
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
return opts.Run(cmd.Context())
},
}
opts.BindFlags(cmd.Flags())
_ = cmd.MarkFlagRequired("name")
return cmd
}
func (o *RegenerateOptions) BindFlags(flags *flag.FlagSet) {
flags.StringVar(&o.HostedClusterName, "name", o.HostedClusterName, "HostedCluster name (required)")
flags.StringVar(&o.HostedClusterNamespace, "namespace", o.HostedClusterNamespace, "HostedCluster namespace")
flags.StringVar(&o.Kubeconfig, "kubeconfig", o.Kubeconfig, "Path to kubeconfig file")
flags.BoolVar(&o.WaitForNodes, "wait-for-nodes", o.WaitForNodes, "Wait for nodes to become ready after OIDC regeneration")
flags.DurationVar(&o.Timeout, "timeout", o.Timeout, "Timeout for the operation")
flags.BoolVar(&o.Local, "local", o.Local, "Generate OIDC documents locally without uploading to S3")
}
func (o *RegenerateOptions) Run(ctx context.Context) error {
fmt.Printf("Starting OIDC document regeneration for cluster: %s in namespace: %s\n", o.HostedClusterName, o.HostedClusterNamespace)
// Load kubeconfig
config, err := clientcmd.BuildConfigFromFlags("", o.Kubeconfig)
if err != nil {
return fmt.Errorf("failed to load kubeconfig: %w", err)
}
// Create Kubernetes client
kubeClient, err := kubernetes.NewForConfig(config)
if err != nil {
return fmt.Errorf("failed to create kubernetes client: %w", err)
}
// Get HostedCluster
hostedCluster, err := o.getHostedCluster(ctx, config)
if err != nil {
return fmt.Errorf("failed to get hosted cluster: %w", err)
}
fmt.Printf("Found HostedCluster: name=%s, infraID=%s, issuerURL=%s\n",
hostedCluster.Name, hostedCluster.Spec.InfraID, hostedCluster.Spec.IssuerURL)
if o.Local {
// Local generation mode
fmt.Println("Generating OIDC documents locally...")
// Get service account signing key
signingKey, err := o.getServiceAccountSigningKey(ctx, kubeClient, hostedCluster)
if err != nil {
return fmt.Errorf("failed to get service account signing key: %w", err)
}
// Generate OIDC documents locally
if err := o.generateLocalOIDCDocuments(hostedCluster, signingKey); err != nil {
return fmt.Errorf("failed to generate local OIDC documents: %w", err)
}
fmt.Println("Successfully generated OIDC documents locally")
return nil
}
// Check if OIDC documents exist in S3
exists, err := o.checkOIDCDocumentsExist(hostedCluster)
if err != nil {
return fmt.Errorf("failed to check OIDC documents: %w", err)
}
if exists {
fmt.Printf("OIDC documents already exist in S3 for infraID: %s\n", hostedCluster.Spec.InfraID)
} else {
fmt.Printf("OIDC documents not found in S3 for infraID: %s, regenerating...\n", hostedCluster.Spec.InfraID)
// Get service account signing key
signingKey, err := o.getServiceAccountSigningKey(ctx, kubeClient, hostedCluster)
if err != nil {
return fmt.Errorf("failed to get service account signing key: %w", err)
}
// Generate and upload OIDC documents
if err := o.generateAndUploadOIDCDocuments(hostedCluster, signingKey); err != nil {
return fmt.Errorf("failed to generate and upload OIDC documents: %w", err)
}
fmt.Println("Successfully regenerated OIDC documents")
}
// Wait for nodes to become ready if requested
if o.WaitForNodes {
fmt.Println("Waiting for nodes to become ready...")
if err := o.waitForNodesReady(ctx, kubeClient, hostedCluster); err != nil {
return fmt.Errorf("failed to wait for nodes ready: %w", err)
}
fmt.Println("All nodes are ready")
}
fmt.Println("OIDC regeneration completed successfully")
return nil
}
func (o *RegenerateOptions) getHostedCluster(ctx context.Context, config *rest.Config) (*HostedCluster, error) {
// Create a dynamic client to access the hosted cluster
dynamicClient, err := dynamic.NewForConfig(config)
if err != nil {
return nil, fmt.Errorf("failed to create dynamic client: %w", err)
}
// Define the GVR for HostedCluster
gvr := schema.GroupVersionResource{
Group: "hypershift.openshift.io",
Version: "v1beta1",
Resource: "hostedclusters",
}
// Get the hosted cluster
obj, err := dynamicClient.Resource(gvr).
Namespace(o.HostedClusterNamespace).
Get(ctx, o.HostedClusterName, metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("failed to get hosted cluster %s/%s: %w", o.HostedClusterNamespace, o.HostedClusterName, err)
}
// Convert to HostedCluster
hostedCluster := &HostedCluster{}
err = runtime.DefaultUnstructuredConverter.FromUnstructured(obj.UnstructuredContent(), hostedCluster)
if err != nil {
return nil, fmt.Errorf("failed to convert to HostedCluster: %w", err)
}
return hostedCluster, nil
}
func (o *RegenerateOptions) checkOIDCDocumentsExist(hostedCluster *HostedCluster) (bool, error) {
// Parse issuer URL to get bucket and key
issuerURL := hostedCluster.Spec.IssuerURL
if !strings.HasPrefix(issuerURL, "https://") {
return false, fmt.Errorf("invalid issuer URL format: %s", issuerURL)
}
// Extract bucket and region from issuer URL
// Format: https://bucket.s3.region.amazonaws.com/infraID
parts := strings.Split(strings.TrimPrefix(issuerURL, "https://"), "/")
if len(parts) < 2 {
return false, fmt.Errorf("invalid issuer URL format: %s", issuerURL)
}
bucketParts := strings.Split(parts[0], ".")
if len(bucketParts) < 4 {
return false, fmt.Errorf("invalid S3 URL format: %s", issuerURL)
}
bucketName := bucketParts[0]
region := bucketParts[2]
infraID := parts[1]
// Create S3 client
sess := session.Must(session.NewSession(&aws.Config{
Region: aws.String(region),
}))
s3Client := s3.New(sess)
// Check if OIDC configuration document exists
configKey := infraID + "/.well-known/openid-configuration"
_, err := s3Client.HeadObject(&s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(configKey),
})
if err != nil {
if aerr, ok := err.(awserr.Error); ok && (aerr.Code() == s3.ErrCodeNoSuchKey || aerr.Code() == "NotFound") {
return false, nil
}
return false, fmt.Errorf("failed to check OIDC configuration: %w", err)
}
// Check if JWKS document exists
jwksKey := infraID + "/openid/v1/jwks"
_, err = s3Client.HeadObject(&s3.HeadObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(jwksKey),
})
if err != nil {
if aerr, ok := err.(awserr.Error); ok && (aerr.Code() == s3.ErrCodeNoSuchKey || aerr.Code() == "NotFound") {
return false, nil
}
return false, fmt.Errorf("failed to check OIDC JWKS: %w", err)
}
return true, nil
}
func (o *RegenerateOptions) getServiceAccountSigningKey(ctx context.Context, kubeClient *kubernetes.Clientset, hostedCluster *HostedCluster) ([]byte, error) {
// Get the service account signing key secret
secretName := "sa-signing-key"
namespace := fmt.Sprintf("%s-%s", o.HostedClusterNamespace, hostedCluster.Name)
secret, err := kubeClient.CoreV1().Secrets(namespace).Get(ctx, secretName, metav1.GetOptions{})
if err != nil {
return nil, fmt.Errorf("failed to get service account signing key secret: %w", err)
}
publicKeyBytes, exists := secret.Data["service-account.pub"]
if !exists {
return nil, fmt.Errorf("service account public key not found in secret")
}
return publicKeyBytes, nil
}
func (o *RegenerateOptions) generateAndUploadOIDCDocuments(hostedCluster *HostedCluster, publicKeyBytes []byte) error {
// Parse issuer URL to get bucket and region
issuerURL := hostedCluster.Spec.IssuerURL
parts := strings.Split(strings.TrimPrefix(issuerURL, "https://"), "/")
bucketParts := strings.Split(parts[0], ".")
bucketName := bucketParts[0]
region := bucketParts[2]
infraID := parts[1]
// Create S3 client
sess := session.Must(session.NewSession(&aws.Config{
Region: aws.String(region),
}))
s3Client := s3.New(sess)
// Generate OIDC documents
configDoc, jwksDoc, err := o.generateOIDCDocuments(issuerURL, publicKeyBytes)
if err != nil {
return fmt.Errorf("failed to generate OIDC documents: %w", err)
}
// Upload configuration document
configKey := infraID + "/.well-known/openid-configuration"
_, err = s3Client.PutObject(&s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(configKey),
Body: strings.NewReader(configDoc),
ContentType: aws.String("application/json"),
})
if err != nil {
return fmt.Errorf("failed to upload OIDC configuration: %w", err)
}
// Upload JWKS document
jwksKey := infraID + "/openid/v1/jwks"
_, err = s3Client.PutObject(&s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(jwksKey),
Body: strings.NewReader(jwksDoc),
ContentType: aws.String("application/json"),
})
if err != nil {
return fmt.Errorf("failed to upload OIDC JWKS: %w", err)
}
fmt.Printf("Successfully uploaded OIDC documents to bucket: %s, infraID: %s\n", bucketName, infraID)
return nil
}
func (o *RegenerateOptions) generateLocalOIDCDocuments(hostedCluster *HostedCluster, publicKeyBytes []byte) error {
// Generate OIDC documents
configDoc, jwksDoc, err := o.generateOIDCDocuments(hostedCluster.Spec.IssuerURL, publicKeyBytes)
if err != nil {
return fmt.Errorf("failed to generate OIDC documents: %w", err)
}
// Create workspace directory
workspaceDir := "workspace"
if err := os.MkdirAll(workspaceDir, 0755); err != nil {
return fmt.Errorf("failed to create workspace directory: %w", err)
}
// Create cluster-specific directory inside workspace
clusterDir := fmt.Sprintf("%s/%s", workspaceDir, hostedCluster.Spec.InfraID)
if err := os.MkdirAll(clusterDir, 0755); err != nil {
return fmt.Errorf("failed to create cluster directory: %w", err)
}
// Write configuration document
configPath := fmt.Sprintf("%s/.well-known/openid-configuration", clusterDir)
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
if err := os.WriteFile(configPath, []byte(configDoc), 0644); err != nil {
return fmt.Errorf("failed to write configuration document: %w", err)
}
// Write JWKS document
jwksPath := fmt.Sprintf("%s/openid/v1/jwks", clusterDir)
if err := os.MkdirAll(filepath.Dir(jwksPath), 0755); err != nil {
return fmt.Errorf("failed to create jwks directory: %w", err)
}
if err := os.WriteFile(jwksPath, []byte(jwksDoc), 0644); err != nil {
return fmt.Errorf("failed to write JWKS document: %w", err)
}
fmt.Printf("Successfully generated OIDC documents locally in workspace: %s\n", clusterDir)
fmt.Printf("Configuration document: %s\n", configPath)
fmt.Printf("JWKS document: %s\n", jwksPath)
return nil
}
func (o *RegenerateOptions) generateOIDCDocuments(issuerURL string, publicKeyBytes []byte) (string, string, error) {
// Parse the public key
block, _ := pem.Decode(publicKeyBytes)
if block == nil {
return "", "", fmt.Errorf("failed to decode PEM block")
}
pubKey, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return "", "", fmt.Errorf("failed to parse public key: %w", err)
}
rsaPubKey, ok := pubKey.(*rsa.PublicKey)
if !ok {
return "", "", fmt.Errorf("public key is not RSA")
}
// Generate key ID (kid)
kid := o.generateKeyID(publicKeyBytes)
// Generate JWKS
jwks := map[string]interface{}{
"keys": []map[string]interface{}{
{
"use": "sig",
"kty": "RSA",
"kid": kid,
"alg": "RS256",
"n": base64.RawURLEncoding.EncodeToString(rsaPubKey.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString([]byte{byte(rsaPubKey.E)}),
},
},
}
jwksBytes, err := json.MarshalIndent(jwks, "", " ")
if err != nil {
return "", "", fmt.Errorf("failed to marshal JWKS: %w", err)
}
// Generate configuration document
config := map[string]interface{}{
"issuer": issuerURL,
"jwks_uri": issuerURL + "/openid/v1/jwks",
"response_types_supported": []string{"id_token"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"},
}
configBytes, err := json.MarshalIndent(config, "", " ")
if err != nil {
return "", "", fmt.Errorf("failed to marshal configuration: %w", err)
}
return string(configBytes), string(jwksBytes), nil
}
func (o *RegenerateOptions) generateKeyID(publicKeyBytes []byte) string {
hash := sha256.Sum256(publicKeyBytes)
return base64.StdEncoding.EncodeToString(hash[:])
}
func (o *RegenerateOptions) waitForNodesReady(ctx context.Context, kubeClient *kubernetes.Clientset, hostedCluster *HostedCluster) error {
timeout := time.After(o.Timeout)
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-timeout:
return fmt.Errorf("timeout waiting for nodes to become ready")
case <-ticker.C:
// Check node status
nodes, err := kubeClient.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
if err != nil {
fmt.Printf("Failed to list nodes: %v\n", err)
continue
}
readyNodes := 0
totalNodes := len(nodes.Items)
for _, node := range nodes.Items {
for _, condition := range node.Status.Conditions {
if condition.Type == corev1.NodeReady && condition.Status == corev1.ConditionTrue {
readyNodes++
break
}
}
}
fmt.Printf("Node status: %d/%d ready\n", readyNodes, totalNodes)
if readyNodes == totalNodes && totalNodes > 0 {
return nil
}
}
}
}
// HostedCluster represents a simplified version of the HostedCluster resource
type HostedCluster struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec HostedClusterSpec `json:"spec,omitempty"`
}
type HostedClusterSpec struct {
InfraID string `json:"infraID,omitempty"`
IssuerURL string `json:"issuerURL,omitempty"`
}

Quick Usage Guide - OIDC Tools

Quick Installation

cd ~/oidc-tools
go mod tidy
go build -o oidc-tools .

Basic Usage

1. Regenerate OIDC documents (most common case)

./oidc-tools regenerate --name <cluster-name> --kubeconfig <kubeconfig-path>

Example:

./oidc-tools regenerate --name jparrill-hosted --kubeconfig /path/to/kubeconfig --wait-for-nodes

2. Delete OIDC documents (for testing)

./oidc-tools delete --name <cluster-name> --kubeconfig <kubeconfig-path> --confirm

Example:

./oidc-tools delete --name jparrill-hosted --kubeconfig /path/to/kubeconfig --confirm

Typical Use Case: Cluster Restoration Problem

Problem

After restoring a HostedCluster with Velero, nodes are in "Not Ready" status and errors appear like:

WebIdentityErr: failed to retrieve credentials
InvalidIdentityToken: Couldn't retrieve verification key from your identity provider

Solution

# 1. Regenerate OIDC documents
./oidc-tools regenerate --name <cluster-name> --kubeconfig <kubeconfig> --wait-for-nodes

# 2. Verify that nodes are ready
kubectl --kubeconfig <kubeconfig> get nodes

Manual Verification

1. Verify OIDC documents in S3

# Get cluster information
kubectl --kubeconfig <kubeconfig> get hostedcluster <cluster-name> -o jsonpath='{.spec.issuerURL}'

# Check documents in S3
aws s3 ls s3://<bucket>/<infraID>/.well-known/openid-configuration
aws s3 ls s3://<bucket>/<infraID>/openid/v1/jwks

2. Check ControlPlaneOperator logs

kubectl --kubeconfig <kubeconfig> logs -n clusters-<cluster-name> deployment/control-plane-operator | grep -i "oidc\|auth\|identity"

Example Script

# Configure variables in example-usage.sh
# Then execute:
./example-usage.sh

Available Make Commands

make help          # See all available commands
make build         # Compile the binary
make setup         # Setup the project
make run-example   # Run example script

Troubleshooting

Error: "failed to load kubeconfig"

  • Verify that the path to kubeconfig is correct
  • Verify that the file exists and is readable

Error: "failed to get hosted cluster"

  • Verify that the cluster exists in the specified namespace
  • Verify that you have permissions to access the cluster

Error: "failed to get service account signing key"

  • Verify that the cluster is running
  • Verify that the sa-signing-key secret exists in the clusters-<cluster-name> namespace

Error: "failed to upload OIDC documents"

  • Verify AWS credentials
  • Verify S3 bucket permissions
  • Verify that the region is correct

Required Environment Variables

# AWS credentials (one of these options)
export AWS_ACCESS_KEY_ID=<your-access-key>
export AWS_SECRET_ACCESS_KEY=<your-secret-key>
# Or use AWS_PROFILE
export AWS_PROFILE=<your-profile>

# Kubernetes context (optional)
export KUBECONFIG=<path-to-kubeconfig>

File Structure

~/oidc-tools/
├── main.go              # Main entry point
├── regenerate.go         # OIDC regeneration command
├── delete.go            # OIDC deletion command
├── go.mod               # Go dependencies
├── README.md            # Complete documentation
├── USAGE.md             # This quick usage guide
├── example-usage.sh     # Example script
└── Makefile             # Development commands
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment