From ed406d5943a6e227e13f60f343374a087d577066 Mon Sep 17 00:00:00 2001 From: Aryan Puttur Date: Tue, 15 Sep 2026 15:07:09 -0400 Subject: [PATCH 1/4] Add skupper debug cert subcommand to inspect X.509 certificates Fixes #2569 --- internal/cmd/skupper/common/flags.go | 5 + internal/cmd/skupper/debug/cert/display.go | 159 ++++++++++++++++++ .../cmd/skupper/debug/cert/display_test.go | 33 ++++ internal/cmd/skupper/debug/debug.go | 33 ++++ internal/cmd/skupper/debug/debug_test.go | 8 + internal/cmd/skupper/debug/kube/cert.go | 157 +++++++++++++++++ internal/cmd/skupper/debug/kube/cert_test.go | 104 ++++++++++++ internal/cmd/skupper/debug/nonkube/cert.go | 144 ++++++++++++++++ 8 files changed, 643 insertions(+) create mode 100644 internal/cmd/skupper/debug/cert/display.go create mode 100644 internal/cmd/skupper/debug/cert/display_test.go create mode 100644 internal/cmd/skupper/debug/kube/cert.go create mode 100644 internal/cmd/skupper/debug/kube/cert_test.go create mode 100644 internal/cmd/skupper/debug/nonkube/cert.go diff --git a/internal/cmd/skupper/common/flags.go b/internal/cmd/skupper/common/flags.go index 4648558802..892a5c95f7 100644 --- a/internal/cmd/skupper/common/flags.go +++ b/internal/cmd/skupper/common/flags.go @@ -266,6 +266,11 @@ type CommandVersionFlags struct { type CommandDebugFlags struct { } +type CommandDebugCertFlags struct { + Output string + File string +} + type CommandConnSweeperFlags struct { IdleThreshold int Execute bool diff --git a/internal/cmd/skupper/debug/cert/display.go b/internal/cmd/skupper/debug/cert/display.go new file mode 100644 index 0000000000..4578ccb818 --- /dev/null +++ b/internal/cmd/skupper/debug/cert/display.go @@ -0,0 +1,159 @@ +package cert + +import ( + "crypto/ecdsa" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "os" + "strings" + "text/tabwriter" + "time" + + "github.com/skupperproject/skupper/internal/certs" + "github.com/skupperproject/skupper/internal/cmd/skupper/common/utils" +) + +// Info holds the decoded fields of an X.509 certificate for display. +type Info struct { + Name string `json:"name"` + Subject string `json:"subject"` + Issuer string `json:"issuer"` + SerialNumber string `json:"serialNumber"` + NotBefore string `json:"notBefore"` + NotAfter string `json:"notAfter"` + DNSNames []string `json:"dnsNames,omitempty"` + IPAddresses []string `json:"ipAddresses,omitempty"` + IsCA bool `json:"isCA"` + PublicKeyAlgorithm string `json:"publicKeyAlgorithm"` + PublicKeySize int `json:"publicKeySize"` + SignatureAlgorithm string `json:"signatureAlgorithm"` + Status string `json:"status,omitempty"` + CrExpiration string `json:"crExpiration,omitempty"` +} + +func ParseCertificate(name string, certData []byte) (*Info, error) { + cert, err := certs.DecodeCertificate(certData) + if err != nil { + return nil, err + } + return certToInfo(name, cert), nil +} + +func ParseCertificateFile(name, path string) (*Info, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + return ParseCertificate(name, data) +} + +func certToInfo(name string, cert *x509.Certificate) *Info { + algo, size := publicKeyInfo(cert) + ips := make([]string, len(cert.IPAddresses)) + for i, ip := range cert.IPAddresses { + ips[i] = ip.String() + } + return &Info{ + Name: name, + Subject: formatName(cert.Subject), + Issuer: formatName(cert.Issuer), + SerialNumber: cert.SerialNumber.String(), + NotBefore: cert.NotBefore.Format(time.RFC3339), + NotAfter: cert.NotAfter.Format(time.RFC3339), + DNSNames: cert.DNSNames, + IPAddresses: ips, + IsCA: cert.IsCA, + PublicKeyAlgorithm: algo, + PublicKeySize: size, + SignatureAlgorithm: cert.SignatureAlgorithm.String(), + } +} + +func formatName(name pkix.Name) string { + if name.CommonName != "" { + return name.CommonName + } + return name.String() +} + +func publicKeyInfo(cert *x509.Certificate) (algorithm string, size int) { + switch pub := cert.PublicKey.(type) { + case *rsa.PublicKey: + return "RSA", pub.N.BitLen() + case *ecdsa.PublicKey: + return "ECDSA", pub.Curve.Params().BitSize + default: + return fmt.Sprintf("%T", cert.PublicKey), 0 + } +} + +func formatSANs(info *Info) string { + var parts []string + parts = append(parts, info.DNSNames...) + parts = append(parts, info.IPAddresses...) + if len(parts) == 0 { + return "" + } + return strings.Join(parts, ", ") +} + +// Display renders one or more certificates to stdout. +func Display(infos []Info, output string, detail bool) error { + if output != "" { + for _, info := range infos { + encoded, err := utils.Encode(output, info) + if err != nil { + return err + } + fmt.Println(encoded) + } + return nil + } + if detail && len(infos) == 1 { + displayDetail(infos[0]) + return nil + } + return displayList(infos) +} + +func displayDetail(info Info) { + fmt.Printf("Name\t\t: %s\n", info.Name) + fmt.Printf("Subject\t\t: %s\n", info.Subject) + fmt.Printf("Issuer\t\t: %s\n", info.Issuer) + fmt.Printf("Serial Number\t: %s\n", info.SerialNumber) + fmt.Printf("Not Before\t: %s\n", info.NotBefore) + fmt.Printf("Not After\t: %s\n", info.NotAfter) + fmt.Printf("Is CA\t\t: %t\n", info.IsCA) + fmt.Printf("DNS Names\t: %s\n", formatSANs(&info)) + if info.PublicKeySize > 0 { + fmt.Printf("Public Key\t: %s (%d bits)\n", info.PublicKeyAlgorithm, info.PublicKeySize) + } else { + fmt.Printf("Public Key\t: %s\n", info.PublicKeyAlgorithm) + } + fmt.Printf("Signature\t: %s\n", info.SignatureAlgorithm) + if info.Status != "" { + fmt.Printf("Status\t\t: %s\n", info.Status) + } + if info.CrExpiration != "" { + fmt.Printf("CR Expiration\t: %s\n", info.CrExpiration) + } +} + +func displayList(infos []Info) error { + if len(infos) == 0 { + fmt.Println("No certificates found") + return nil + } + writer := tabwriter.NewWriter(os.Stdout, 0, 8, 2, '\t', 0) + fmt.Fprintln(writer, "NAME\tSUBJECT\tISSUER\tNOT AFTER\tSANs") + for _, info := range infos { + sans := formatSANs(&info) + if len(sans) > 60 { + sans = sans[:57] + "..." + } + fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\n", info.Name, info.Subject, info.Issuer, info.NotAfter, sans) + } + return writer.Flush() +} diff --git a/internal/cmd/skupper/debug/cert/display_test.go b/internal/cmd/skupper/debug/cert/display_test.go new file mode 100644 index 0000000000..1c5f28741a --- /dev/null +++ b/internal/cmd/skupper/debug/cert/display_test.go @@ -0,0 +1,33 @@ +package cert + +import ( + "testing" + + "github.com/skupperproject/skupper/internal/certs" + "gotest.tools/v3/assert" +) + +func TestParseCertificate(t *testing.T) { + caSecret, err := certs.GenerateSecret("test-ca", "test-ca.example.com", nil, 0, nil) + assert.NilError(t, err) + + leafSecret, err := certs.GenerateSecret("test-cert", "test.example.com", []string{"test.example.com"}, 86400000000000, caSecret) + assert.NilError(t, err) + + info, err := ParseCertificate("test-cert", leafSecret.Data["tls.crt"]) + assert.NilError(t, err) + + assert.Equal(t, "test-cert", info.Name) + assert.Equal(t, "test.example.com", info.Subject) + assert.Equal(t, "test-ca.example.com", info.Issuer) + assert.Equal(t, "RSA", info.PublicKeyAlgorithm) + assert.Equal(t, 2048, info.PublicKeySize) + assert.Assert(t, len(info.NotBefore) > 0) + assert.Assert(t, len(info.NotAfter) > 0) + assert.DeepEqual(t, []string{"test.example.com"}, info.DNSNames) +} + +func TestParseCertificateInvalidPEM(t *testing.T) { + _, err := ParseCertificate("bad", []byte("not a pem")) + assert.ErrorContains(t, err, "Could not decode PEM block") +} diff --git a/internal/cmd/skupper/debug/debug.go b/internal/cmd/skupper/debug/debug.go index 6ebad64a6c..7aae7a5a90 100644 --- a/internal/cmd/skupper/debug/debug.go +++ b/internal/cmd/skupper/debug/debug.go @@ -19,6 +19,7 @@ func NewCmdDebug() *cobra.Command { } platform := common.Platform(config.GetPlatform()) cmd.AddCommand(CmdDebugDumpFactory(platform)) + cmd.AddCommand(CmdDebugCertFactory(platform)) cmd.AddCommand(CmdDebugSweepFactory(platform)) return cmd @@ -64,6 +65,38 @@ skupper debug sweep --port 8080 --port 9090 --idle-threshold 14400 --execute`, return cmd } +func CmdDebugCertFactory(configuredPlatform common.Platform) *cobra.Command { + kubeCommand := kube.NewCmdDebugCert() + nonKubeCommand := nonkube.NewCmdDebugCert() + + cmdDesc := common.SkupperCmdDescription{ + Use: "cert [name]", + Short: "Inspect X.509 certificates in use by Skupper", + Long: `Decode and display key fields of X.509 certificates managed by Skupper, +including subject, issuer, validity period, SANs, and public key information. + +Without a name, all certificates in the namespace are listed. With a name, +detailed information for that certificate is shown.`, + Example: `skupper debug cert +skupper debug cert skupper-local-server +skupper debug cert --file /path/to/tls.crt +skupper debug cert -o yaml`, + } + + cmd := common.ConfigureCobraCommand(configuredPlatform, cmdDesc, kubeCommand, nonKubeCommand) + + cmdFlags := common.CommandDebugCertFlags{} + cmd.Flags().StringVarP(&cmdFlags.Output, common.FlagNameOutput, "o", "", common.FlagDescOutput) + cmd.Flags().StringVar(&cmdFlags.File, "file", "", "Inspect a local PEM-encoded certificate file") + + kubeCommand.CobraCmd = cmd + kubeCommand.Flags = &cmdFlags + nonKubeCommand.CobraCmd = cmd + nonKubeCommand.Flags = &cmdFlags + + return cmd +} + func CmdDebugDumpFactory(configuredPlatform common.Platform) *cobra.Command { kubeCommand := kube.NewCmdDebug() nonKubeCommand := nonkube.NewCmdDebug() diff --git a/internal/cmd/skupper/debug/debug_test.go b/internal/cmd/skupper/debug/debug_test.go index 198cc40a5c..9978753170 100644 --- a/internal/cmd/skupper/debug/debug_test.go +++ b/internal/cmd/skupper/debug/debug_test.go @@ -24,6 +24,14 @@ func TestCmdLinkFactory(t *testing.T) { expectedFlagsWithDefaultValue: map[string]interface{}{}, command: CmdDebugDumpFactory(common.PlatformKubernetes), }, + { + name: "CmdDebugCertFactory", + expectedFlagsWithDefaultValue: map[string]interface{}{ + "output": "", + "file": "", + }, + command: CmdDebugCertFactory(common.PlatformKubernetes), + }, } for _, test := range testTable { diff --git a/internal/cmd/skupper/debug/kube/cert.go b/internal/cmd/skupper/debug/kube/cert.go new file mode 100644 index 0000000000..d2e01c9aec --- /dev/null +++ b/internal/cmd/skupper/debug/kube/cert.go @@ -0,0 +1,157 @@ +package kube + +import ( + "context" + "errors" + "fmt" + + "github.com/skupperproject/skupper/internal/cmd/skupper/common" + "github.com/skupperproject/skupper/internal/cmd/skupper/common/utils" + certdisplay "github.com/skupperproject/skupper/internal/cmd/skupper/debug/cert" + "github.com/skupperproject/skupper/internal/kube/client" + "github.com/skupperproject/skupper/internal/utils/validator" + "github.com/skupperproject/skupper/pkg/apis/skupper/v2alpha1" + skupperv2alpha1 "github.com/skupperproject/skupper/pkg/generated/client/clientset/versioned/typed/skupper/v2alpha1" + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +type CmdDebugCert struct { + Client skupperv2alpha1.SkupperV2alpha1Interface + KubeClient kubernetes.Interface + CobraCmd *cobra.Command + Flags *common.CommandDebugCertFlags + Namespace string + certName string + output string +} + +func NewCmdDebugCert() *CmdDebugCert { + return &CmdDebugCert{} +} + +func (cmd *CmdDebugCert) NewClient(cobraCommand *cobra.Command, args []string) { + if cmd.Flags != nil && cmd.Flags.File != "" { + return + } + cli, err := client.NewClient( + cobraCommand.Flag("namespace").Value.String(), + cobraCommand.Flag("context").Value.String(), + cobraCommand.Flag("kubeconfig").Value.String(), + ) + if err != nil { + return + } + cmd.Client = cli.GetSkupperClient().SkupperV2alpha1() + cmd.KubeClient = cli.GetKubeClient() + cmd.Namespace = cli.Namespace +} + +func (cmd *CmdDebugCert) ValidateInput(args []string) error { + var validationErrors []error + outputTypeValidator := validator.NewOptionValidator(common.OutputTypes) + filePathValidator := validator.NewFilePathStringValidator() + + if cmd.Flags != nil && cmd.Flags.File != "" { + ok, err := filePathValidator.Evaluate(cmd.Flags.File) + if !ok { + validationErrors = append(validationErrors, fmt.Errorf("file path is not valid: %s", err)) + } + if len(args) > 0 { + validationErrors = append(validationErrors, fmt.Errorf("file flag cannot be used with a certificate name argument")) + } + } else { + if cmd.Client == nil || cmd.KubeClient == nil { + validationErrors = append(validationErrors, fmt.Errorf("failed setting up command")) + } + if len(args) > 1 { + validationErrors = append(validationErrors, fmt.Errorf("only one certificate name is allowed")) + } + if len(args) == 1 { + cmd.certName = args[0] + } + } + + if cmd.Flags != nil && cmd.Flags.Output != "" { + ok, err := outputTypeValidator.Evaluate(cmd.Flags.Output) + if !ok { + validationErrors = append(validationErrors, fmt.Errorf("output type is not valid: %s", err)) + } + } + + return errors.Join(validationErrors...) +} + +func (cmd *CmdDebugCert) InputToOptions() { + if cmd.Flags != nil { + cmd.output = cmd.Flags.Output + } +} + +func (cmd *CmdDebugCert) Run() error { + if cmd.Flags != nil && cmd.Flags.File != "" { + info, err := certdisplay.ParseCertificateFile(cmd.Flags.File, cmd.Flags.File) + if err != nil { + return fmt.Errorf("failed to parse certificate from %s: %w", cmd.Flags.File, err) + } + return certdisplay.Display([]certdisplay.Info{*info}, cmd.output, true) + } + + if cmd.certName != "" { + certificate, err := cmd.Client.Certificates(cmd.Namespace).Get(context.TODO(), cmd.certName, metav1.GetOptions{}) + if err != nil { + return err + } + info, err := cmd.certInfoFromCR(certificate) + if err != nil { + return err + } + return certdisplay.Display([]certdisplay.Info{*info}, cmd.output, true) + } + + certificateList, err := cmd.Client.Certificates(cmd.Namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return utils.HandleMissingCrds(err) + } + + if certificateList == nil || len(certificateList.Items) == 0 { + fmt.Println("No certificate resources found in the namespace") + return nil + } + + var infos []certdisplay.Info + for _, certificate := range certificateList.Items { + info, err := cmd.certInfoFromCR(&certificate) + if err != nil { + return err + } + infos = append(infos, *info) + } + return certdisplay.Display(infos, cmd.output, false) +} + +func (cmd *CmdDebugCert) certInfoFromCR(certificate *v2alpha1.Certificate) (*certdisplay.Info, error) { + secret, err := cmd.KubeClient.CoreV1().Secrets(cmd.Namespace).Get(context.TODO(), certificate.Name, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to get secret for certificate %s: %w", certificate.Name, err) + } + return cmd.certInfoFromSecret(certificate.Name, secret, string(certificate.Status.StatusType), certificate.Status.Expiration) +} + +func (cmd *CmdDebugCert) certInfoFromSecret(name string, secret *corev1.Secret, status, crExpiration string) (*certdisplay.Info, error) { + certData, ok := secret.Data["tls.crt"] + if !ok || len(certData) == 0 { + return nil, fmt.Errorf("secret %s does not contain tls.crt", name) + } + info, err := certdisplay.ParseCertificate(name, certData) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate %s: %w", name, err) + } + info.Status = status + info.CrExpiration = crExpiration + return info, nil +} + +func (cmd *CmdDebugCert) WaitUntil() error { return nil } diff --git a/internal/cmd/skupper/debug/kube/cert_test.go b/internal/cmd/skupper/debug/kube/cert_test.go new file mode 100644 index 0000000000..7cb10f1b73 --- /dev/null +++ b/internal/cmd/skupper/debug/kube/cert_test.go @@ -0,0 +1,104 @@ +package kube + +import ( + "testing" + + "github.com/skupperproject/skupper/internal/certs" + "github.com/skupperproject/skupper/internal/cmd/skupper/common" + "github.com/skupperproject/skupper/internal/cmd/skupper/common/testutils" + fakeclient "github.com/skupperproject/skupper/internal/kube/client/fake" + "github.com/skupperproject/skupper/pkg/apis/skupper/v2alpha1" + "gotest.tools/v3/assert" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestCmdDebugCert_ValidateInput(t *testing.T) { + type test struct { + name string + args []string + flags common.CommandDebugCertFlags + k8sObjects []runtime.Object + skupperObjects []runtime.Object + expectedError string + } + + testTable := []test{ + { + name: "more than one argument", + args: []string{"cert-a", "cert-b"}, + expectedError: "only one certificate name is allowed", + }, + { + name: "file and name argument", + flags: common.CommandDebugCertFlags{File: "tls.crt"}, + args: []string{"my-cert"}, + expectedError: "file flag cannot be used with a certificate name argument", + }, + { + name: "invalid output format", + flags: common.CommandDebugCertFlags{Output: "xml"}, + expectedError: "output type is not valid: value xml not allowed. It should be one of this options: [json yaml]", + }, + } + + for _, tt := range testTable { + t.Run(tt.name, func(t *testing.T) { + cmd, err := newCmdDebugCertWithMocks("test", tt.k8sObjects, tt.skupperObjects, "") + assert.Assert(t, err) + cmd.Flags = &tt.flags + testutils.CheckValidateInput(t, cmd, tt.expectedError, tt.args) + }) + } +} + +func TestCmdDebugCert_certInfoFromSecret(t *testing.T) { + caSecret, err := certs.GenerateSecret("test-ca", "ca.example.com", nil, 0, nil) + assert.NilError(t, err) + + leafSecret, err := certs.GenerateSecret("my-cert", "my.example.com", []string{"my.example.com"}, 86400000000000, caSecret) + assert.NilError(t, err) + + cmd := NewCmdDebugCert() + info, err := cmd.certInfoFromSecret("my-cert", leafSecret, "Ready", "2027-01-01T00:00:00Z") + assert.NilError(t, err) + assert.Equal(t, "my-cert", info.Name) + assert.Equal(t, "my.example.com", info.Subject) + assert.Equal(t, "Ready", info.Status) + assert.Equal(t, "2027-01-01T00:00:00Z", info.CrExpiration) +} + +func TestCmdDebugCert_RunWithCertificateCR(t *testing.T) { + leafSecret, err := certs.GenerateSecret("skupper-local-server", "local.example.com", []string{"local.example.com"}, 0, nil) + assert.NilError(t, err) + leafSecret.Namespace = "test" + + certCR := &v2alpha1.Certificate{ + ObjectMeta: v1.ObjectMeta{Name: "skupper-local-server", Namespace: "test"}, + Status: v2alpha1.CertificateStatus{ + Status: v2alpha1.Status{ + StatusType: v2alpha1.StatusReady, + }, + Expiration: "2027-01-01T00:00:00Z", + }, + } + + cmd, err := newCmdDebugCertWithMocks("test", []runtime.Object{leafSecret}, []runtime.Object{certCR}, "") + assert.Assert(t, err) + cmd.certName = "skupper-local-server" + + err = cmd.Run() + assert.NilError(t, err) +} + +func newCmdDebugCertWithMocks(namespace string, k8sObjects []runtime.Object, skupperObjects []runtime.Object, fakeSkupperError string) (*CmdDebugCert, error) { + client, err := fakeclient.NewFakeClient(namespace, k8sObjects, skupperObjects, fakeSkupperError) + if err != nil { + return nil, err + } + return &CmdDebugCert{ + Client: client.GetSkupperClient().SkupperV2alpha1(), + KubeClient: client.GetKubeClient(), + Namespace: namespace, + }, nil +} diff --git a/internal/cmd/skupper/debug/nonkube/cert.go b/internal/cmd/skupper/debug/nonkube/cert.go new file mode 100644 index 0000000000..cbc75c15a3 --- /dev/null +++ b/internal/cmd/skupper/debug/nonkube/cert.go @@ -0,0 +1,144 @@ +package nonkube + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/skupperproject/skupper/internal/cmd/skupper/common" + certdisplay "github.com/skupperproject/skupper/internal/cmd/skupper/debug/cert" + "github.com/skupperproject/skupper/internal/utils/validator" + "github.com/skupperproject/skupper/pkg/nonkube/api" + "github.com/spf13/cobra" +) + +type CmdDebugCert struct { + CobraCmd *cobra.Command + Flags *common.CommandDebugCertFlags + namespace string + certName string + output string +} + +func NewCmdDebugCert() *CmdDebugCert { + return &CmdDebugCert{} +} + +func (cmd *CmdDebugCert) NewClient(cobraCommand *cobra.Command, args []string) { + if cmd.CobraCmd != nil && cmd.CobraCmd.Flag(common.FlagNameNamespace) != nil { + cmd.namespace = cmd.CobraCmd.Flag(common.FlagNameNamespace).Value.String() + } +} + +func (cmd *CmdDebugCert) ValidateInput(args []string) error { + var validationErrors []error + outputTypeValidator := validator.NewOptionValidator(common.OutputTypes) + filePathValidator := validator.NewFilePathStringValidator() + + if cmd.Flags != nil && cmd.Flags.File != "" { + ok, err := filePathValidator.Evaluate(cmd.Flags.File) + if !ok { + validationErrors = append(validationErrors, fmt.Errorf("file path is not valid: %s", err)) + } + if len(args) > 0 { + validationErrors = append(validationErrors, fmt.Errorf("file flag cannot be used with a certificate name argument")) + } + } else { + if len(args) > 1 { + validationErrors = append(validationErrors, fmt.Errorf("only one certificate name is allowed")) + } + if len(args) == 1 { + cmd.certName = args[0] + } + } + + if cmd.Flags != nil && cmd.Flags.Output != "" { + ok, err := outputTypeValidator.Evaluate(cmd.Flags.Output) + if !ok { + validationErrors = append(validationErrors, fmt.Errorf("output type is not valid: %s", err)) + } + } + + return errors.Join(validationErrors...) +} + +func (cmd *CmdDebugCert) InputToOptions() { + if cmd.Flags != nil { + cmd.output = cmd.Flags.Output + } +} + +func (cmd *CmdDebugCert) Run() error { + if cmd.Flags != nil && cmd.Flags.File != "" { + info, err := certdisplay.ParseCertificateFile(cmd.Flags.File, cmd.Flags.File) + if err != nil { + return fmt.Errorf("failed to parse certificate from %s: %w", cmd.Flags.File, err) + } + return certdisplay.Display([]certdisplay.Info{*info}, cmd.output, true) + } + + infos, err := cmd.collectCerts() + if err != nil { + return err + } + + if cmd.certName != "" { + for _, info := range infos { + if info.Name == cmd.certName { + return certdisplay.Display([]certdisplay.Info{info}, cmd.output, true) + } + } + return fmt.Errorf("certificate %s not found", cmd.certName) + } + + return certdisplay.Display(infos, cmd.output, false) +} + +func (cmd *CmdDebugCert) collectCerts() ([]certdisplay.Info, error) { + var infos []certdisplay.Info + seen := map[string]bool{} + + certPaths := []struct { + basePath api.InternalPath + prefix string + }{ + {api.CertificatesPath, ""}, + {api.InputCertificatesPath, "input/"}, + } + + for _, cp := range certPaths { + dir := api.GetInternalOutputPath(cmd.namespace, cp.basePath) + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + if seen[name] { + continue + } + certFile := filepath.Join(dir, name, "tls.crt") + data, err := os.ReadFile(certFile) + if err != nil { + continue + } + info, err := certdisplay.ParseCertificate(name, data) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate %s: %w", name, err) + } + if cp.prefix != "" { + info.Name = cp.prefix + name + } + seen[name] = true + infos = append(infos, *info) + } + } + + return infos, nil +} + +func (cmd *CmdDebugCert) WaitUntil() error { return nil } From aca857de0ba3089bbbe782dd527f874a3bda3535 Mon Sep 17 00:00:00 2001 From: Aryan Puttur Date: Tue, 15 Sep 2026 15:44:03 -0400 Subject: [PATCH 2/4] Fix formatting and address review feedback for debug cert Run go fmt on cert_test.go, include email/URI SANs in cert output, and fix non-kube input cert deduplication by display name. --- internal/cmd/skupper/debug/cert/display.go | 12 +++++++++++- internal/cmd/skupper/debug/kube/cert_test.go | 10 +++++----- internal/cmd/skupper/debug/nonkube/cert.go | 12 +++++------- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/internal/cmd/skupper/debug/cert/display.go b/internal/cmd/skupper/debug/cert/display.go index 4578ccb818..5eedd631d5 100644 --- a/internal/cmd/skupper/debug/cert/display.go +++ b/internal/cmd/skupper/debug/cert/display.go @@ -25,6 +25,8 @@ type Info struct { NotAfter string `json:"notAfter"` DNSNames []string `json:"dnsNames,omitempty"` IPAddresses []string `json:"ipAddresses,omitempty"` + EmailAddresses []string `json:"emailAddresses,omitempty"` + URIs []string `json:"uris,omitempty"` IsCA bool `json:"isCA"` PublicKeyAlgorithm string `json:"publicKeyAlgorithm"` PublicKeySize int `json:"publicKeySize"` @@ -55,6 +57,10 @@ func certToInfo(name string, cert *x509.Certificate) *Info { for i, ip := range cert.IPAddresses { ips[i] = ip.String() } + uris := make([]string, len(cert.URIs)) + for i, uri := range cert.URIs { + uris[i] = uri.String() + } return &Info{ Name: name, Subject: formatName(cert.Subject), @@ -64,6 +70,8 @@ func certToInfo(name string, cert *x509.Certificate) *Info { NotAfter: cert.NotAfter.Format(time.RFC3339), DNSNames: cert.DNSNames, IPAddresses: ips, + EmailAddresses: cert.EmailAddresses, + URIs: uris, IsCA: cert.IsCA, PublicKeyAlgorithm: algo, PublicKeySize: size, @@ -93,6 +101,8 @@ func formatSANs(info *Info) string { var parts []string parts = append(parts, info.DNSNames...) parts = append(parts, info.IPAddresses...) + parts = append(parts, info.EmailAddresses...) + parts = append(parts, info.URIs...) if len(parts) == 0 { return "" } @@ -126,7 +136,7 @@ func displayDetail(info Info) { fmt.Printf("Not Before\t: %s\n", info.NotBefore) fmt.Printf("Not After\t: %s\n", info.NotAfter) fmt.Printf("Is CA\t\t: %t\n", info.IsCA) - fmt.Printf("DNS Names\t: %s\n", formatSANs(&info)) + fmt.Printf("SANs\t\t: %s\n", formatSANs(&info)) if info.PublicKeySize > 0 { fmt.Printf("Public Key\t: %s (%d bits)\n", info.PublicKeyAlgorithm, info.PublicKeySize) } else { diff --git a/internal/cmd/skupper/debug/kube/cert_test.go b/internal/cmd/skupper/debug/kube/cert_test.go index 7cb10f1b73..d7ff80a5f7 100644 --- a/internal/cmd/skupper/debug/kube/cert_test.go +++ b/internal/cmd/skupper/debug/kube/cert_test.go @@ -15,12 +15,12 @@ import ( func TestCmdDebugCert_ValidateInput(t *testing.T) { type test struct { - name string - args []string - flags common.CommandDebugCertFlags - k8sObjects []runtime.Object + name string + args []string + flags common.CommandDebugCertFlags + k8sObjects []runtime.Object skupperObjects []runtime.Object - expectedError string + expectedError string } testTable := []test{ diff --git a/internal/cmd/skupper/debug/nonkube/cert.go b/internal/cmd/skupper/debug/nonkube/cert.go index cbc75c15a3..3f86f0eb38 100644 --- a/internal/cmd/skupper/debug/nonkube/cert.go +++ b/internal/cmd/skupper/debug/nonkube/cert.go @@ -118,7 +118,8 @@ func (cmd *CmdDebugCert) collectCerts() ([]certdisplay.Info, error) { continue } name := entry.Name() - if seen[name] { + displayName := cp.prefix + name + if seen[displayName] { continue } certFile := filepath.Join(dir, name, "tls.crt") @@ -126,14 +127,11 @@ func (cmd *CmdDebugCert) collectCerts() ([]certdisplay.Info, error) { if err != nil { continue } - info, err := certdisplay.ParseCertificate(name, data) + info, err := certdisplay.ParseCertificate(displayName, data) if err != nil { - return nil, fmt.Errorf("failed to parse certificate %s: %w", name, err) + return nil, fmt.Errorf("failed to parse certificate %s: %w", displayName, err) } - if cp.prefix != "" { - info.Name = cp.prefix + name - } - seen[name] = true + seen[displayName] = true infos = append(infos, *info) } } From dc103aa634376ebe31bf6e3e3046a46fccb7d7b2 Mon Sep 17 00:00:00 2001 From: Aryan Puttur Date: Thu, 17 Sep 2026 11:27:10 -0400 Subject: [PATCH 3/4] Address review feedback for debug cert-inspect Rename cert to cert-inspect, fall back to TLS Secrets on kube, and scan issuers and input resource secrets on non-kube. --- internal/cmd/skupper/debug/debug.go | 10 +- internal/cmd/skupper/debug/kube/cert.go | 74 +++++++++-- internal/cmd/skupper/debug/kube/cert_test.go | 15 +++ internal/cmd/skupper/debug/nonkube/cert.go | 126 +++++++++++++++---- 4 files changed, 186 insertions(+), 39 deletions(-) diff --git a/internal/cmd/skupper/debug/debug.go b/internal/cmd/skupper/debug/debug.go index 7aae7a5a90..29988a6530 100644 --- a/internal/cmd/skupper/debug/debug.go +++ b/internal/cmd/skupper/debug/debug.go @@ -70,17 +70,17 @@ func CmdDebugCertFactory(configuredPlatform common.Platform) *cobra.Command { nonKubeCommand := nonkube.NewCmdDebugCert() cmdDesc := common.SkupperCmdDescription{ - Use: "cert [name]", + Use: "cert-inspect [name]", Short: "Inspect X.509 certificates in use by Skupper", Long: `Decode and display key fields of X.509 certificates managed by Skupper, including subject, issuer, validity period, SANs, and public key information. Without a name, all certificates in the namespace are listed. With a name, detailed information for that certificate is shown.`, - Example: `skupper debug cert -skupper debug cert skupper-local-server -skupper debug cert --file /path/to/tls.crt -skupper debug cert -o yaml`, + Example: `skupper debug cert-inspect +skupper debug cert-inspect skupper-local-server +skupper debug cert-inspect --file /path/to/tls.crt +skupper debug cert-inspect -o yaml`, } cmd := common.ConfigureCobraCommand(configuredPlatform, cmdDesc, kubeCommand, nonKubeCommand) diff --git a/internal/cmd/skupper/debug/kube/cert.go b/internal/cmd/skupper/debug/kube/cert.go index d2e01c9aec..ba8be9d4e2 100644 --- a/internal/cmd/skupper/debug/kube/cert.go +++ b/internal/cmd/skupper/debug/kube/cert.go @@ -9,11 +9,13 @@ import ( "github.com/skupperproject/skupper/internal/cmd/skupper/common/utils" certdisplay "github.com/skupperproject/skupper/internal/cmd/skupper/debug/cert" "github.com/skupperproject/skupper/internal/kube/client" + "github.com/skupperproject/skupper/internal/kube/secrets" "github.com/skupperproject/skupper/internal/utils/validator" "github.com/skupperproject/skupper/pkg/apis/skupper/v2alpha1" skupperv2alpha1 "github.com/skupperproject/skupper/pkg/generated/client/clientset/versioned/typed/skupper/v2alpha1" "github.com/spf13/cobra" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/kubernetes" ) @@ -100,36 +102,84 @@ func (cmd *CmdDebugCert) Run() error { } if cmd.certName != "" { - certificate, err := cmd.Client.Certificates(cmd.Namespace).Get(context.TODO(), cmd.certName, metav1.GetOptions{}) - if err != nil { - return err - } - info, err := cmd.certInfoFromCR(certificate) + info, err := cmd.certInfoByName(cmd.certName) if err != nil { return err } return certdisplay.Display([]certdisplay.Info{*info}, cmd.output, true) } - certificateList, err := cmd.Client.Certificates(cmd.Namespace).List(context.TODO(), metav1.ListOptions{}) + infos, err := cmd.collectCertInfos() if err != nil { - return utils.HandleMissingCrds(err) + return err } - - if certificateList == nil || len(certificateList.Items) == 0 { - fmt.Println("No certificate resources found in the namespace") + if len(infos) == 0 { + fmt.Println("No certificates found in the namespace") return nil } + return certdisplay.Display(infos, cmd.output, false) +} +func (cmd *CmdDebugCert) certInfoByName(name string) (*certdisplay.Info, error) { + certificate, err := cmd.Client.Certificates(cmd.Namespace).Get(context.TODO(), name, metav1.GetOptions{}) + if err == nil { + return cmd.certInfoFromCR(certificate) + } + if !apierrors.IsNotFound(err) { + return nil, err + } + + secret, err := cmd.KubeClient.CoreV1().Secrets(cmd.Namespace).Get(context.TODO(), name, metav1.GetOptions{}) + if err != nil { + return nil, err + } + if !secrets.IsTlsCredentialSecret(secret) { + return nil, fmt.Errorf("secret %s is not a TLS credential", name) + } + return cmd.certInfoFromSecret(name, secret, "", "") +} + +func (cmd *CmdDebugCert) collectCertInfos() ([]certdisplay.Info, error) { + seen := map[string]bool{} var infos []certdisplay.Info + + certificateList, err := cmd.Client.Certificates(cmd.Namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, utils.HandleMissingCrds(err) + } + for _, certificate := range certificateList.Items { info, err := cmd.certInfoFromCR(&certificate) if err != nil { - return err + return nil, err } + seen[certificate.Name] = true infos = append(infos, *info) } - return certdisplay.Display(infos, cmd.output, false) + + secretList, err := cmd.KubeClient.CoreV1().Secrets(cmd.Namespace).List(context.TODO(), metav1.ListOptions{}) + if err != nil { + return nil, err + } + + for _, secret := range secretList.Items { + if seen[secret.Name] { + continue + } + if !secrets.IsTlsCredentialSecret(&secret) { + continue + } + if _, ok := secret.Data["tls.crt"]; !ok { + continue + } + info, err := cmd.certInfoFromSecret(secret.Name, &secret, "", "") + if err != nil { + return nil, err + } + infos = append(infos, *info) + } + + return infos, nil } func (cmd *CmdDebugCert) certInfoFromCR(certificate *v2alpha1.Certificate) (*certdisplay.Info, error) { diff --git a/internal/cmd/skupper/debug/kube/cert_test.go b/internal/cmd/skupper/debug/kube/cert_test.go index d7ff80a5f7..048538403a 100644 --- a/internal/cmd/skupper/debug/kube/cert_test.go +++ b/internal/cmd/skupper/debug/kube/cert_test.go @@ -9,6 +9,7 @@ import ( fakeclient "github.com/skupperproject/skupper/internal/kube/client/fake" "github.com/skupperproject/skupper/pkg/apis/skupper/v2alpha1" "gotest.tools/v3/assert" + corev1 "k8s.io/api/core/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ) @@ -68,6 +69,20 @@ func TestCmdDebugCert_certInfoFromSecret(t *testing.T) { assert.Equal(t, "2027-01-01T00:00:00Z", info.CrExpiration) } +func TestCmdDebugCert_RunWithSecretOnly(t *testing.T) { + linkSecret, err := certs.GenerateSecret("link-profile", "link.example.com", []string{"link.example.com"}, 86400000000000, nil) + assert.NilError(t, err) + linkSecret.Namespace = "test" + linkSecret.Type = corev1.SecretTypeTLS + + cmd, err := newCmdDebugCertWithMocks("test", []runtime.Object{linkSecret}, nil, "") + assert.Assert(t, err) + cmd.certName = "link-profile" + + err = cmd.Run() + assert.NilError(t, err) +} + func TestCmdDebugCert_RunWithCertificateCR(t *testing.T) { leafSecret, err := certs.GenerateSecret("skupper-local-server", "local.example.com", []string{"local.example.com"}, 0, nil) assert.NilError(t, err) diff --git a/internal/cmd/skupper/debug/nonkube/cert.go b/internal/cmd/skupper/debug/nonkube/cert.go index 3f86f0eb38..06808d5edb 100644 --- a/internal/cmd/skupper/debug/nonkube/cert.go +++ b/internal/cmd/skupper/debug/nonkube/cert.go @@ -1,16 +1,23 @@ package nonkube import ( + "bufio" "errors" "fmt" "os" "path/filepath" + "strings" "github.com/skupperproject/skupper/internal/cmd/skupper/common" certdisplay "github.com/skupperproject/skupper/internal/cmd/skupper/debug/cert" "github.com/skupperproject/skupper/internal/utils/validator" "github.com/skupperproject/skupper/pkg/nonkube/api" "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer/yaml" + yamlutil "k8s.io/apimachinery/pkg/util/yaml" ) type CmdDebugCert struct { @@ -97,6 +104,7 @@ func (cmd *CmdDebugCert) Run() error { func (cmd *CmdDebugCert) collectCerts() ([]certdisplay.Info, error) { var infos []certdisplay.Info + var err error seen := map[string]bool{} certPaths := []struct { @@ -105,38 +113,112 @@ func (cmd *CmdDebugCert) collectCerts() ([]certdisplay.Info, error) { }{ {api.CertificatesPath, ""}, {api.InputCertificatesPath, "input/"}, + {api.IssuersPath, "issuers/"}, + {api.InputIssuersPath, "input/issuers/"}, } for _, cp := range certPaths { - dir := api.GetInternalOutputPath(cmd.namespace, cp.basePath) - entries, err := os.ReadDir(dir) + infos, err = cmd.collectCertsFromDir(cp.basePath, cp.prefix, seen, infos) if err != nil { + return nil, err + } + } + + return cmd.collectInputResourceSecrets(seen, infos) +} + +func (cmd *CmdDebugCert) collectCertsFromDir(basePath api.InternalPath, prefix string, seen map[string]bool, infos []certdisplay.Info) ([]certdisplay.Info, error) { + dir := api.GetInternalOutputPath(cmd.namespace, basePath) + entries, err := os.ReadDir(dir) + if err != nil { + return infos, nil + } + for _, entry := range entries { + if !entry.IsDir() { continue } - for _, entry := range entries { - if !entry.IsDir() { - continue - } - name := entry.Name() - displayName := cp.prefix + name - if seen[displayName] { - continue - } - certFile := filepath.Join(dir, name, "tls.crt") - data, err := os.ReadFile(certFile) - if err != nil { - continue - } - info, err := certdisplay.ParseCertificate(displayName, data) - if err != nil { - return nil, fmt.Errorf("failed to parse certificate %s: %w", displayName, err) - } - seen[displayName] = true - infos = append(infos, *info) + name := entry.Name() + displayName := prefix + name + if seen[displayName] { + continue } + certFile := filepath.Join(dir, name, "tls.crt") + data, err := os.ReadFile(certFile) + if err != nil { + continue + } + info, err := certdisplay.ParseCertificate(displayName, data) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate %s: %w", displayName, err) + } + seen[displayName] = true + infos = append(infos, *info) } + return infos, nil +} +func (cmd *CmdDebugCert) collectInputResourceSecrets(seen map[string]bool, infos []certdisplay.Info) ([]certdisplay.Info, error) { + dir := api.GetInternalOutputPath(cmd.namespace, api.InputSiteStatePath) + entries, err := os.ReadDir(dir) + if err != nil { + return infos, nil + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + filename := entry.Name() + if !strings.HasPrefix(filename, "Secret-") { + continue + } + if !strings.HasSuffix(filename, ".yaml") && !strings.HasSuffix(filename, ".yml") { + continue + } + secret, err := decodeSecretFile(filepath.Join(dir, filename)) + if err != nil { + return nil, fmt.Errorf("failed to decode secret from %s: %w", filename, err) + } + if secret.Data == nil || len(secret.Data["tls.crt"]) == 0 { + continue + } + displayName := "input/secret/" + secret.Name + if seen[displayName] { + continue + } + info, err := certdisplay.ParseCertificate(displayName, secret.Data["tls.crt"]) + if err != nil { + return nil, fmt.Errorf("failed to parse certificate %s: %w", displayName, err) + } + seen[displayName] = true + infos = append(infos, *info) + } return infos, nil } +func decodeSecretFile(path string) (*corev1.Secret, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + yamlDecoder := yamlutil.NewYAMLOrJSONDecoder(bufio.NewReader(file), 1024) + var rawObj runtime.RawExtension + if err := yamlDecoder.Decode(&rawObj); err != nil { + return nil, err + } + obj, gvk, err := yaml.NewDecodingSerializer(unstructured.UnstructuredJSONScheme).Decode(rawObj.Raw, nil, nil) + if err != nil { + return nil, err + } + if gvk.Kind != "Secret" { + return nil, fmt.Errorf("expected Secret, got %s", gvk.Kind) + } + var secret corev1.Secret + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.(runtime.Unstructured).UnstructuredContent(), &secret); err != nil { + return nil, err + } + return &secret, nil +} + func (cmd *CmdDebugCert) WaitUntil() error { return nil } From eb054abbf951a56bb6fd598c4bbf8678605d8e21 Mon Sep 17 00:00:00 2001 From: Aryan Puttur Date: Fri, 18 Sep 2026 14:01:14 -0400 Subject: [PATCH 4/4] Address cert-inspect review feedback for signing and errors Surface Is Signing Cert from Certificate CR (kube) or issuers path (non-kube), and only ignore missing directories when scanning certs. --- internal/cmd/skupper/debug/cert/display.go | 4 +++ internal/cmd/skupper/debug/kube/cert.go | 10 +++--- internal/cmd/skupper/debug/kube/cert_test.go | 37 +++++++++++++++++++- internal/cmd/skupper/debug/nonkube/cert.go | 29 +++++++++------ 4 files changed, 65 insertions(+), 15 deletions(-) diff --git a/internal/cmd/skupper/debug/cert/display.go b/internal/cmd/skupper/debug/cert/display.go index 5eedd631d5..47687fa1d5 100644 --- a/internal/cmd/skupper/debug/cert/display.go +++ b/internal/cmd/skupper/debug/cert/display.go @@ -28,6 +28,7 @@ type Info struct { EmailAddresses []string `json:"emailAddresses,omitempty"` URIs []string `json:"uris,omitempty"` IsCA bool `json:"isCA"` + IsSigningCert *bool `json:"isSigningCert,omitempty"` PublicKeyAlgorithm string `json:"publicKeyAlgorithm"` PublicKeySize int `json:"publicKeySize"` SignatureAlgorithm string `json:"signatureAlgorithm"` @@ -136,6 +137,9 @@ func displayDetail(info Info) { fmt.Printf("Not Before\t: %s\n", info.NotBefore) fmt.Printf("Not After\t: %s\n", info.NotAfter) fmt.Printf("Is CA\t\t: %t\n", info.IsCA) + if info.IsSigningCert != nil { + fmt.Printf("Is Signing Cert\t: %t\n", *info.IsSigningCert) + } fmt.Printf("SANs\t\t: %s\n", formatSANs(&info)) if info.PublicKeySize > 0 { fmt.Printf("Public Key\t: %s (%d bits)\n", info.PublicKeyAlgorithm, info.PublicKeySize) diff --git a/internal/cmd/skupper/debug/kube/cert.go b/internal/cmd/skupper/debug/kube/cert.go index ba8be9d4e2..b64e4ea29d 100644 --- a/internal/cmd/skupper/debug/kube/cert.go +++ b/internal/cmd/skupper/debug/kube/cert.go @@ -136,7 +136,7 @@ func (cmd *CmdDebugCert) certInfoByName(name string) (*certdisplay.Info, error) if !secrets.IsTlsCredentialSecret(secret) { return nil, fmt.Errorf("secret %s is not a TLS credential", name) } - return cmd.certInfoFromSecret(name, secret, "", "") + return cmd.certInfoFromSecret(name, secret, "", "", nil) } func (cmd *CmdDebugCert) collectCertInfos() ([]certdisplay.Info, error) { @@ -172,7 +172,7 @@ func (cmd *CmdDebugCert) collectCertInfos() ([]certdisplay.Info, error) { if _, ok := secret.Data["tls.crt"]; !ok { continue } - info, err := cmd.certInfoFromSecret(secret.Name, &secret, "", "") + info, err := cmd.certInfoFromSecret(secret.Name, &secret, "", "", nil) if err != nil { return nil, err } @@ -187,10 +187,11 @@ func (cmd *CmdDebugCert) certInfoFromCR(certificate *v2alpha1.Certificate) (*cer if err != nil { return nil, fmt.Errorf("failed to get secret for certificate %s: %w", certificate.Name, err) } - return cmd.certInfoFromSecret(certificate.Name, secret, string(certificate.Status.StatusType), certificate.Status.Expiration) + isSigning := certificate.Spec.Signing + return cmd.certInfoFromSecret(certificate.Name, secret, string(certificate.Status.StatusType), certificate.Status.Expiration, &isSigning) } -func (cmd *CmdDebugCert) certInfoFromSecret(name string, secret *corev1.Secret, status, crExpiration string) (*certdisplay.Info, error) { +func (cmd *CmdDebugCert) certInfoFromSecret(name string, secret *corev1.Secret, status, crExpiration string, isSigning *bool) (*certdisplay.Info, error) { certData, ok := secret.Data["tls.crt"] if !ok || len(certData) == 0 { return nil, fmt.Errorf("secret %s does not contain tls.crt", name) @@ -201,6 +202,7 @@ func (cmd *CmdDebugCert) certInfoFromSecret(name string, secret *corev1.Secret, } info.Status = status info.CrExpiration = crExpiration + info.IsSigningCert = isSigning return info, nil } diff --git a/internal/cmd/skupper/debug/kube/cert_test.go b/internal/cmd/skupper/debug/kube/cert_test.go index 048538403a..58c3e017a9 100644 --- a/internal/cmd/skupper/debug/kube/cert_test.go +++ b/internal/cmd/skupper/debug/kube/cert_test.go @@ -61,12 +61,13 @@ func TestCmdDebugCert_certInfoFromSecret(t *testing.T) { assert.NilError(t, err) cmd := NewCmdDebugCert() - info, err := cmd.certInfoFromSecret("my-cert", leafSecret, "Ready", "2027-01-01T00:00:00Z") + info, err := cmd.certInfoFromSecret("my-cert", leafSecret, "Ready", "2027-01-01T00:00:00Z", nil) assert.NilError(t, err) assert.Equal(t, "my-cert", info.Name) assert.Equal(t, "my.example.com", info.Subject) assert.Equal(t, "Ready", info.Status) assert.Equal(t, "2027-01-01T00:00:00Z", info.CrExpiration) + assert.Assert(t, info.IsSigningCert == nil) } func TestCmdDebugCert_RunWithSecretOnly(t *testing.T) { @@ -90,6 +91,9 @@ func TestCmdDebugCert_RunWithCertificateCR(t *testing.T) { certCR := &v2alpha1.Certificate{ ObjectMeta: v1.ObjectMeta{Name: "skupper-local-server", Namespace: "test"}, + Spec: v2alpha1.CertificateSpec{ + Signing: false, + }, Status: v2alpha1.CertificateStatus{ Status: v2alpha1.Status{ StatusType: v2alpha1.StatusReady, @@ -104,6 +108,37 @@ func TestCmdDebugCert_RunWithCertificateCR(t *testing.T) { err = cmd.Run() assert.NilError(t, err) + + info, err := cmd.certInfoFromCR(certCR) + assert.NilError(t, err) + assert.Assert(t, info.IsSigningCert != nil) + assert.Equal(t, false, *info.IsSigningCert) +} + +func TestCmdDebugCert_SigningFromCR(t *testing.T) { + caSecret, err := certs.GenerateSecret("skupper-site-ca", "site-ca.example.com", nil, 0, nil) + assert.NilError(t, err) + caSecret.Namespace = "test" + + certCR := &v2alpha1.Certificate{ + ObjectMeta: v1.ObjectMeta{Name: "skupper-site-ca", Namespace: "test"}, + Spec: v2alpha1.CertificateSpec{ + Signing: true, + }, + Status: v2alpha1.CertificateStatus{ + Status: v2alpha1.Status{ + StatusType: v2alpha1.StatusReady, + }, + }, + } + + cmd, err := newCmdDebugCertWithMocks("test", []runtime.Object{caSecret}, []runtime.Object{certCR}, "") + assert.Assert(t, err) + + info, err := cmd.certInfoFromCR(certCR) + assert.NilError(t, err) + assert.Assert(t, info.IsSigningCert != nil) + assert.Equal(t, true, *info.IsSigningCert) } func newCmdDebugCertWithMocks(namespace string, k8sObjects []runtime.Object, skupperObjects []runtime.Object, fakeSkupperError string) (*CmdDebugCert, error) { diff --git a/internal/cmd/skupper/debug/nonkube/cert.go b/internal/cmd/skupper/debug/nonkube/cert.go index 06808d5edb..e3783b3e4a 100644 --- a/internal/cmd/skupper/debug/nonkube/cert.go +++ b/internal/cmd/skupper/debug/nonkube/cert.go @@ -108,17 +108,18 @@ func (cmd *CmdDebugCert) collectCerts() ([]certdisplay.Info, error) { seen := map[string]bool{} certPaths := []struct { - basePath api.InternalPath - prefix string + basePath api.InternalPath + prefix string + isSigning bool }{ - {api.CertificatesPath, ""}, - {api.InputCertificatesPath, "input/"}, - {api.IssuersPath, "issuers/"}, - {api.InputIssuersPath, "input/issuers/"}, + {api.CertificatesPath, "", false}, + {api.InputCertificatesPath, "input/", false}, + {api.IssuersPath, "issuers/", true}, + {api.InputIssuersPath, "input/issuers/", true}, } for _, cp := range certPaths { - infos, err = cmd.collectCertsFromDir(cp.basePath, cp.prefix, seen, infos) + infos, err = cmd.collectCertsFromDir(cp.basePath, cp.prefix, cp.isSigning, seen, infos) if err != nil { return nil, err } @@ -127,11 +128,14 @@ func (cmd *CmdDebugCert) collectCerts() ([]certdisplay.Info, error) { return cmd.collectInputResourceSecrets(seen, infos) } -func (cmd *CmdDebugCert) collectCertsFromDir(basePath api.InternalPath, prefix string, seen map[string]bool, infos []certdisplay.Info) ([]certdisplay.Info, error) { +func (cmd *CmdDebugCert) collectCertsFromDir(basePath api.InternalPath, prefix string, isSigning bool, seen map[string]bool, infos []certdisplay.Info) ([]certdisplay.Info, error) { dir := api.GetInternalOutputPath(cmd.namespace, basePath) entries, err := os.ReadDir(dir) if err != nil { - return infos, nil + if errors.Is(err, os.ErrNotExist) { + return infos, nil + } + return nil, fmt.Errorf("failed to read certificate directory %s: %w", dir, err) } for _, entry := range entries { if !entry.IsDir() { @@ -151,6 +155,8 @@ func (cmd *CmdDebugCert) collectCertsFromDir(basePath api.InternalPath, prefix s if err != nil { return nil, fmt.Errorf("failed to parse certificate %s: %w", displayName, err) } + signing := isSigning + info.IsSigningCert = &signing seen[displayName] = true infos = append(infos, *info) } @@ -161,7 +167,10 @@ func (cmd *CmdDebugCert) collectInputResourceSecrets(seen map[string]bool, infos dir := api.GetInternalOutputPath(cmd.namespace, api.InputSiteStatePath) entries, err := os.ReadDir(dir) if err != nil { - return infos, nil + if errors.Is(err, os.ErrNotExist) { + return infos, nil + } + return nil, fmt.Errorf("failed to read certificate directory %s: %w", dir, err) } for _, entry := range entries { if entry.IsDir() {