From eda4e99bf116a91768945f089d2ea650c22c328d Mon Sep 17 00:00:00 2001 From: "dmitry.trofimov" Date: Mon, 25 May 2026 18:41:28 +0300 Subject: [PATCH 1/5] add renew command for certs Signed-off-by: dmitry.trofimov --- internal/tools/pki/certs/cmd/certs.go | 1 + internal/tools/pki/certs/cmd/renew.go | 161 ++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 internal/tools/pki/certs/cmd/renew.go diff --git a/internal/tools/pki/certs/cmd/certs.go b/internal/tools/pki/certs/cmd/certs.go index 550749b3..89cfbfbd 100644 --- a/internal/tools/pki/certs/cmd/certs.go +++ b/internal/tools/pki/certs/cmd/certs.go @@ -35,6 +35,7 @@ func NewCommand() *cobra.Command { } certsCmd.AddCommand(NewCheckCommand()) + certsCmd.AddCommand(NewRenewCommand()) return certsCmd } diff --git a/internal/tools/pki/certs/cmd/renew.go b/internal/tools/pki/certs/cmd/renew.go new file mode 100644 index 00000000..330b3dc5 --- /dev/null +++ b/internal/tools/pki/certs/cmd/renew.go @@ -0,0 +1,161 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package cmd + +import ( + "errors" + "fmt" + "path/filepath" + + "github.com/deckhouse/deckhouse-cli/internal/tools/pki/certs" + "github.com/deckhouse/deckhouse/go_lib/controlplane/kubeconfig" + "github.com/deckhouse/deckhouse/go_lib/controlplane/pki" + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" +) + +var renewLong = templates.LongDesc(` +Renew control-plane certificates and kubeconfig client certificates. + +Renewal is unconditional — certificates are re-signed regardless of their current +expiration date. + +All Subject/SAN/Usage fields are read from the existing certificate on disk. +No cluster configuration is required — this command is designed for emergency +recovery when the Kubernetes API is unavailable. + +The signing CA must be present on disk and must not be expired. If the CA private +key is absent (external CA), the certificate is skipped. If the CA certificate has +expired, the command stops with an error — renewing leaf certificates against an +expired CA is pointless because chain validation will fail. + +After renewal, restart kube-apiserver, kube-controller-manager, kube-scheduler +and etcd so that the new certificates take effect or reboot the node/kubelet. + +© Flant JSC 2026`) + +type leafEntry struct { + name pki.LeafCertName + longName string +} + +type kcEntry struct { + file kubeconfig.File + longName string +} + +var knownLeafCerts = []leafEntry{ + {pki.ApiserverCertName, "certificate for serving the Kubernetes API"}, + {pki.ApiserverKubeletClientCertName, "certificate for the API server to connect to kubelet"}, + {pki.ApiserverEtcdClientCertName, "certificate the apiserver uses to access etcd"}, + {pki.FrontProxyClientCertName, "certificate for the front proxy client"}, + {pki.EtcdServerCertName, "certificate for serving etcd"}, + {pki.EtcdPeerCertName, "certificate for etcd nodes to communicate with each other"}, + {pki.EtcdHealthcheckClientCertName, "certificate for liveness probes to healthcheck etcd"}, +} + +var knownKubeconfigFiles = []kcEntry{ + {kubeconfig.Admin, "certificate embedded in the kubeconfig file for the admin to use"}, + {kubeconfig.SuperAdmin, "certificate embedded in the kubeconfig file for the super-admin"}, + {kubeconfig.ControllerManager, "certificate embedded in the kubeconfig file for the controller manager to use"}, + {kubeconfig.Scheduler, "certificate embedded in the kubeconfig file for the scheduler to use"}, +} + +// NewRenewCommand returns the "certs renew" group command with all subcommands. +func NewRenewCommand() *cobra.Command { + var ( + certsDir string + kubeconfigDir string + ) + + renewCmd := &cobra.Command{ + Use: "renew", + Short: "Renew control-plane certificates", + Long: renewLong, + } + + addRenewFlags := func(cmd *cobra.Command) { + cmd.Flags().StringVar(&certsDir, "path", defaultCertificatesDir, + "Directory containing the PKI certificates and CA files") + cmd.Flags().StringVar(&kubeconfigDir, "kubeconfig-dir", "", + "Directory containing kubeconfig files (defaults to the parent of --path)") + } + + effectiveKubeconfigDir := func() string { + if kubeconfigDir != "" { + return kubeconfigDir + } + return filepath.Dir(certsDir) + } + + // -- "all" subcommand -- + allCmd := &cobra.Command{ + Use: "all", + Short: "Renew all control-plane leaf certificates and kubeconfig client certificates", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + w := cmd.OutOrStdout() + kcDir := effectiveKubeconfigDir() + + for _, entry := range knownLeafCerts { + msg, fatal := renewLeafCert(certsDir, entry) + if fatal != nil { + return fatal + } + fmt.Fprintln(w, msg) + } + + fmt.Fprintln(w) + report, err := certs.BuildFullScanReport(certsDir, kcDir) + if err != nil { + return fmt.Errorf("building post-renewal report: %w", err) + } + certs.RenderReport(w, report) + + fmt.Fprintln(w) + fmt.Fprintln(w, "Done. Restart kube-apiserver, kube-controller-manager, kube-scheduler and etcd.") + return nil + }, + } + addRenewFlags(allCmd) + renewCmd.AddCommand(allCmd) + return renewCmd +} + +func renewLeafCert(certsDir string, entry leafEntry) (string, error) { + err := pki.RenewLeafCert(certsDir, entry.name) + if err == nil { + return fmt.Sprintf("%s renewed", entry.longName), nil + } + + var missingCert *pki.CertMissingError + var expiredCA *pki.CAExpiredError + var externalCA *pki.CAExternalError + + switch { + case errors.As(err, &missingCert): + return fmt.Sprintf("MISSING! %s", entry.longName), nil + case errors.As(err, &externalCA): + return fmt.Sprintf("Detected external %s, %s can't be renewed", externalCA.CAName, entry.longName), nil + case errors.As(err, &expiredCA): + return "", fmt.Errorf( + "CA %q expired at %s — rotate the CA before renewing kubeconfig certificates", + expiredCA.CAName, expiredCA.ExpiredAt.UTC().Format("Jan 02, 2006 15:04 MST"), + ) + default: + return "", fmt.Errorf("renew %q: %w", entry.name, err) + } +} From bfdf1ababc08469b57e4801deda2329c6426db6c Mon Sep 17 00:00:00 2001 From: "dmitry.trofimov" Date: Thu, 28 May 2026 17:17:44 +0300 Subject: [PATCH 2/5] fixes Signed-off-by: dmitry.trofimov --- internal/tools/pki/certs/certs.go | 157 +++++------ internal/tools/pki/certs/cmd/renew.go | 360 ++++++++++++++++++++------ 2 files changed, 337 insertions(+), 180 deletions(-) diff --git a/internal/tools/pki/certs/certs.go b/internal/tools/pki/certs/certs.go index d1d2c246..c192a729 100644 --- a/internal/tools/pki/certs/certs.go +++ b/internal/tools/pki/certs/certs.go @@ -20,7 +20,6 @@ import ( "errors" "fmt" "io" - "io/fs" "path/filepath" "sort" "strings" @@ -50,45 +49,49 @@ type Report struct { CAs []CAEntry } -type multiUnwrapper interface { - Unwrap() []error -} - // BuildFullScanReport enumerates all known control-plane certificates and kubeconfig // client certificates, returning a report split into CAs and leaf certs. // certsDir is the PKI directory (e.g. /etc/kubernetes/pki). // kubeconfigDir is the directory containing kubeconfig files (e.g. /etc/kubernetes). // Callers that want the standard layout can pass filepath.Dir(certsDir). func BuildFullScanReport(certsDir, kubeconfigDir string) (*Report, error) { - pkiExpirations, pkiErr := pki.ListCertificateExpirations( - pki.WithCertificatesDir(certsDir), - pki.WithIgnoreReadErrors(), - ) - kcExpirations, kcErr := kubeconfig.ListClientCertificateExpirations( - kubeconfig.WithKubeconfigDir(kubeconfigDir), - kubeconfig.WithIgnoreReadErrors(), - ) + pkiReport := pki.ListCertificateExpirations(pki.WithCertificatesDir(certsDir)) + kcReport := kubeconfig.ListClientCertificateExpirations(kubeconfig.WithKubeconfigDir(kubeconfigDir)) - hasExpirations := len(pkiExpirations) > 0 || len(kcExpirations) > 0 + report := &Report{} + var readErrs []error + + for _, e := range pkiReport.Entries { + switch { + case e.Err == nil: + appendPKIEntry(report, e.Name, e.NotAfter, e.IsCA, e.Authority) + case isCertMissing(e.Err): + // Missing — silent: worker/arbiter nodes dont carry the full PKI. + default: + readErrs = append(readErrs, e.Err) + } + } - err := errors.Join( - parseFullScanError("PKI certificates", certsDir, pkiErr), - parseFullScanError("kubeconfig client certificates", kubeconfigDir, kcErr), - ) - if err != nil && !hasExpirations { - return nil, fmt.Errorf("no control-plane certificates or kubeconfig client certificates found: %w", err) + for _, e := range kcReport.Entries { + switch { + case e.Err == nil: + appendKubeconfigEntry(report, e.File, e.NotAfter) + case isKubeconfigMissing(e.Err): + // Missing — silent. + default: + readErrs = append(readErrs, e.Err) + } } - if err != nil { - return nil, err + if len(readErrs) > 0 { + return nil, fmt.Errorf("listing PKI certificates in %q and kubeconfig client certificates in %q: %w", + certsDir, kubeconfigDir, errors.Join(readErrs...)) } - if !hasExpirations { + if len(report.Certs) == 0 && len(report.CAs) == 0 { return nil, fmt.Errorf("no control-plane certificates or kubeconfig client certificates found in %q and %q", certsDir, kubeconfigDir) } - report := reportFromExpirations(pkiExpirations, kcExpirations) - sort.Slice(report.Certs, func(i, j int) bool { return report.Certs[i].Name < report.Certs[j].Name }) @@ -99,60 +102,14 @@ func BuildFullScanReport(certsDir, kubeconfigDir string) (*Report, error) { return report, nil } -func reportFromExpirations(pkiExpirations []pki.CertificateExpiration, kcExpirations []kubeconfig.ClientCertificateExpiration) *Report { - report := &Report{} - - for _, exp := range pkiExpirations { - if exp.IsCA { - report.CAs = append(report.CAs, CAEntry{ - Name: pkiDisplayName(exp.Name), - Expires: exp.NotAfter, - }) - } else { - report.Certs = append(report.Certs, CertEntry{ - Name: pkiDisplayName(exp.Name), - Expires: exp.NotAfter, - Authority: pkiDisplayName(string(exp.Authority)), - }) - } - } - - for _, exp := range kcExpirations { - report.Certs = append(report.Certs, CertEntry{ - Name: kubeconfigDisplayName(exp.File), - Expires: exp.NotAfter, - Authority: string(pki.CACertName), - }) - } - - return report -} - -func parseFullScanError(subject, dir string, err error) error { - if err == nil || onlyNotExistErrors(err) { - return nil - } - - return fmt.Errorf("listing %s in %q: %w", subject, dir, err) +func isCertMissing(err error) bool { + var missing *pki.MissingError + return errors.As(err, &missing) } -func onlyNotExistErrors(err error) bool { - if err == nil { - return false - } - - var multiErr multiUnwrapper - if errors.As(err, &multiErr) { - for _, nestedErr := range multiErr.Unwrap() { - if !onlyNotExistErrors(nestedErr) { - return false - } - } - - return true - } - - return errors.Is(err, fs.ErrNotExist) +func isKubeconfigMissing(err error) bool { + var missing *kubeconfig.MissingError + return errors.As(err, &missing) } // BuildSingleFileReport inspects a single file at path. @@ -161,28 +118,15 @@ func onlyNotExistErrors(err error) bool { func BuildSingleFileReport(path string) (*Report, error) { kcExp, kcErr := kubeconfig.GetClientCertificateExpiration(path) if kcErr == nil { - return &Report{ - Certs: []CertEntry{{ - Name: kubeconfigDisplayName(kcExp.File), - Expires: kcExp.NotAfter, - Authority: string(pki.CACertName), - }}, - }, nil + report := &Report{} + appendKubeconfigEntry(report, kcExp.File, kcExp.NotAfter) + return report, nil } certExp, certErr := pki.GetCertificateExpiration(path) if certErr == nil { report := &Report{} - if certExp.IsCA { - report.CAs = []CAEntry{{Name: pkiDisplayName(certExp.Name), Expires: certExp.NotAfter}} - } else { - report.Certs = []CertEntry{{ - Name: pkiDisplayName(certExp.Name), - Expires: certExp.NotAfter, - Authority: pkiDisplayName(string(certExp.Authority)), - }} - } - + appendPKIEntry(report, certExp.Name, certExp.NotAfter, certExp.IsCA, certExp.Authority) return report, nil } @@ -192,6 +136,31 @@ func BuildSingleFileReport(path string) (*Report, error) { ) } +// appendPKIEntry appends a leaf cert or CA entry to report depending on IsCA. +func appendPKIEntry(report *Report, name string, notAfter time.Time, isCA bool, authority pki.RootCertName) { + if isCA { + report.CAs = append(report.CAs, CAEntry{ + Name: pkiDisplayName(name), + Expires: notAfter, + }) + return + } + report.Certs = append(report.Certs, CertEntry{ + Name: pkiDisplayName(name), + Expires: notAfter, + Authority: pkiDisplayName(string(authority)), + }) +} + +// appendKubeconfigEntry appends a kubeconfig client cert entry. Always a leaf cert signed by the cluster CA. +func appendKubeconfigEntry(report *Report, file kubeconfig.File, notAfter time.Time) { + report.Certs = append(report.Certs, CertEntry{ + Name: kubeconfigDisplayName(file), + Expires: notAfter, + Authority: string(pki.CACertName), + }) +} + // RenderReport writes the certificate expiration report to w in two sections: // leaf certificates followed by certificate authorities. func RenderReport(w io.Writer, report *Report) { diff --git a/internal/tools/pki/certs/cmd/renew.go b/internal/tools/pki/certs/cmd/renew.go index 330b3dc5..ca3495e8 100644 --- a/internal/tools/pki/certs/cmd/renew.go +++ b/internal/tools/pki/certs/cmd/renew.go @@ -18,13 +18,17 @@ package cmd import ( "errors" "fmt" + "io" "path/filepath" + "strings" + "time" - "github.com/deckhouse/deckhouse-cli/internal/tools/pki/certs" - "github.com/deckhouse/deckhouse/go_lib/controlplane/kubeconfig" - "github.com/deckhouse/deckhouse/go_lib/controlplane/pki" "github.com/spf13/cobra" "k8s.io/kubectl/pkg/util/templates" + + "github.com/deckhouse/deckhouse/go_lib/controlplane/constants" + "github.com/deckhouse/deckhouse/go_lib/controlplane/kubeconfig" + "github.com/deckhouse/deckhouse/go_lib/controlplane/pki" ) var renewLong = templates.LongDesc(` @@ -47,115 +51,299 @@ and etcd so that the new certificates take effect or reboot the node/kubelet. © Flant JSC 2026`) -type leafEntry struct { - name pki.LeafCertName - longName string -} - -type kcEntry struct { - file kubeconfig.File - longName string -} - -var knownLeafCerts = []leafEntry{ - {pki.ApiserverCertName, "certificate for serving the Kubernetes API"}, - {pki.ApiserverKubeletClientCertName, "certificate for the API server to connect to kubelet"}, - {pki.ApiserverEtcdClientCertName, "certificate the apiserver uses to access etcd"}, - {pki.FrontProxyClientCertName, "certificate for the front proxy client"}, - {pki.EtcdServerCertName, "certificate for serving etcd"}, - {pki.EtcdPeerCertName, "certificate for etcd nodes to communicate with each other"}, - {pki.EtcdHealthcheckClientCertName, "certificate for liveness probes to healthcheck etcd"}, -} - -var knownKubeconfigFiles = []kcEntry{ - {kubeconfig.Admin, "certificate embedded in the kubeconfig file for the admin to use"}, - {kubeconfig.SuperAdmin, "certificate embedded in the kubeconfig file for the super-admin"}, - {kubeconfig.ControllerManager, "certificate embedded in the kubeconfig file for the controller manager to use"}, - {kubeconfig.Scheduler, "certificate embedded in the kubeconfig file for the scheduler to use"}, -} - -// NewRenewCommand returns the "certs renew" group command with all subcommands. func NewRenewCommand() *cobra.Command { var ( certsDir string kubeconfigDir string + dryRun bool ) renewCmd := &cobra.Command{ - Use: "renew", - Short: "Renew control-plane certificates", + Use: "renew (all | PATH)", + Short: "Renew control-plane certificates and kubeconfig client certificates", Long: renewLong, + Args: cobra.ArbitraryArgs, + Example: " d8 tools pki certs renew all\n" + + " d8 tools pki certs renew all --dry-run\n" + + " d8 tools pki certs renew /etc/kubernetes/pki/apiserver.crt\n" + + " d8 tools pki certs renew /etc/kubernetes/admin.conf\n" + + " d8 tools pki certs renew all --path /opt/k8s/pki --kubeconfig-dir /opt/k8s", + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return cmd.Usage() + } + if len(args) > 1 { + return fmt.Errorf("accepts exactly one argument (PATH), received %d", len(args)) + } + w := cmd.OutOrStdout() + kcDir := effectiveKubeconfigDir(certsDir, kubeconfigDir) + return runRenewSingle(w, args[0], certsDir, kcDir, dryRun) + }, + } + + allCmd := &cobra.Command{ + Use: "all", + Short: "Renew all control-plane certificates and kubeconfig client certificates", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + w := cmd.OutOrStdout() + kcDir := effectiveKubeconfigDir(certsDir, kubeconfigDir) + return runRenewAll(w, certsDir, kcDir, dryRun) + }, } - addRenewFlags := func(cmd *cobra.Command) { - cmd.Flags().StringVar(&certsDir, "path", defaultCertificatesDir, + addPathFlags := func(c *cobra.Command) { + c.Flags().StringVar(&certsDir, "path", defaultCertificatesDir, "Directory containing the PKI certificates and CA files") - cmd.Flags().StringVar(&kubeconfigDir, "kubeconfig-dir", "", + c.Flags().StringVar(&kubeconfigDir, "kubeconfig-dir", "", "Directory containing kubeconfig files (defaults to the parent of --path)") + c.Flags().BoolVar(&dryRun, "dry-run", false, + "Run all without writing any files") + } + addPathFlags(renewCmd) + addPathFlags(allCmd) + + renewCmd.AddCommand(allCmd) + + return renewCmd +} + +func effectiveKubeconfigDir(certsDir, override string) string { + if override != "" { + return override + } + return filepath.Dir(certsDir) +} + +func runRenewAll(w io.Writer, certsDir, kcDir string, dryRun bool) error { + printDryRunBanner(w, dryRun) + + var warnings []string + usedCAs := map[pki.RootCertName]struct{}{} + + leafOpts := []pki.RenewOption{pki.WithRenewDir(certsDir)} + if dryRun { + leafOpts = append(leafOpts, pki.WithDryRun()) } - effectiveKubeconfigDir := func() string { - if kubeconfigDir != "" { - return kubeconfigDir + leafReport := pki.RenewCertificates(leafOpts...) + for _, e := range leafReport.Entries { + line := formatLeafEntry(pki.LeafDescription(e.Name), e) + fmt.Fprintln(w, line) + if e.Err != nil { + warnings = append(warnings, line) + continue } - return filepath.Dir(certsDir) + usedCAs[e.Authority] = struct{}{} } - // -- "all" subcommand -- - allCmd := &cobra.Command{ - Use: "all", - Short: "Renew all control-plane leaf certificates and kubeconfig client certificates", - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - w := cmd.OutOrStdout() - kcDir := effectiveKubeconfigDir() - - for _, entry := range knownLeafCerts { - msg, fatal := renewLeafCert(certsDir, entry) - if fatal != nil { - return fatal - } - fmt.Fprintln(w, msg) - } + kcOpts := []kubeconfig.RenewOption{ + kubeconfig.WithRenewKubeconfigDir(kcDir), + kubeconfig.WithRenewPKIDir(certsDir), + } + if dryRun { + kcOpts = append(kcOpts, kubeconfig.WithDryRun()) + } - fmt.Fprintln(w) - report, err := certs.BuildFullScanReport(certsDir, kcDir) - if err != nil { - return fmt.Errorf("building post-renewal report: %w", err) - } - certs.RenderReport(w, report) + kcReport := kubeconfig.RenewClientCerts(kcOpts...) + for _, e := range kcReport.Entries { + line := formatKubeconfigEntry(kubeconfig.FileDescription(e.File), e) + fmt.Fprintln(w, line) + if e.Err != nil { + warnings = append(warnings, line) + continue + } + usedCAs[pki.CACertName] = struct{}{} // kubeconfig client certs are signed by ca + } - fmt.Fprintln(w) - fmt.Fprintln(w, "Done. Restart kube-apiserver, kube-controller-manager, kube-scheduler and etcd.") - return nil - }, + warnings = append(warnings, checkCAsOutliveRenewed(w, certsDir, usedCAs)...) + + printDryRunFooter(w, dryRun) + + return warningsError(warnings) +} + +func printDryRunBanner(w io.Writer, dryRun bool) { + if dryRun { + fmt.Fprintln(w, "DRY RUN — no files will be modified") } - addRenewFlags(allCmd) - renewCmd.AddCommand(allCmd) - return renewCmd } -func renewLeafCert(certsDir string, entry leafEntry) (string, error) { - err := pki.RenewLeafCert(certsDir, entry.name) - if err == nil { - return fmt.Sprintf("%s renewed", entry.longName), nil +func printDryRunFooter(w io.Writer, dryRun bool) { + if dryRun { + fmt.Fprintln(w, "(dry-run) nothing was written") } +} - var missingCert *pki.CertMissingError - var expiredCA *pki.CAExpiredError - var externalCA *pki.CAExternalError +// checkCAsOutliveRenewed warns when a CA that signed a freshly renewed cert expires sooner than the new (1-year) cert. +func checkCAsOutliveRenewed(w io.Writer, certsDir string, usedCAs map[pki.RootCertName]struct{}) []string { + if len(usedCAs) == 0 { + return nil + } + + cas := make([]pki.RootCertName, 0, len(usedCAs)) + for ca := range usedCAs { + cas = append(cas, ca) + } + + report := pki.ListCertificateExpirations( + pki.WithCertificatesDir(certsDir), + pki.WithRootCertificates(cas...), + ) + + threshold := time.Now().Add(constants.CertificateValidityPeriod) + var warnings []string + for _, e := range report.Entries { + if e.Err != nil { + // CA read failure here is already surfaced via leaf renew skips. + continue + } + if e.NotAfter.Before(threshold) { + line := fmt.Sprintf("WARNING: CA %q expires %s, sooner than the renewed certificates — rotate the CA: %s", + e.Name, e.NotAfter.UTC().Format("Jan 02, 2006 15:04 MST"), e.Path) + fmt.Fprintln(w, line) + warnings = append(warnings, line) + } + } + return warnings +} + +func warningsError(warnings []string) error { + if len(warnings) == 0 { + return nil + } + return fmt.Errorf("%d warning(s) during renewal:\n%s", len(warnings), strings.Join(warnings, "\n")) +} + +func runRenewSingle(w io.Writer, path, certsDir, kcDirOverride string, dryRun bool) error { + if kcExp, err := kubeconfig.GetClientCertificateExpiration(path); err == nil { + return renewSingleKubeconfig(w, path, kcExp.File, certsDir, kcDirOverride, dryRun) + } + + certExp, certErr := pki.GetCertificateExpiration(path) + if certErr != nil { + return fmt.Errorf("cannot determine file type for %q: not a recognizable kubeconfig or PEM certificate: %w", path, certErr) + } + if certExp.IsCA { + return fmt.Errorf("CA certificate renewal is not supported: %q", path) + } + return renewSingleLeaf(w, path, pki.LeafCertName(certExp.Name), certsDir, dryRun) +} + +func renewSingleLeaf(w io.Writer, path string, name pki.LeafCertName, pkiDir string, dryRun bool) error { + printDryRunBanner(w, dryRun) + + if !strings.HasPrefix(filepath.Clean(path), filepath.Clean(pkiDir)+string(filepath.Separator)) { + return fmt.Errorf("certificate %q is not under PKI directory %q; use --path to specify the correct directory", path, pkiDir) + } + + opts := []pki.RenewOption{pki.WithRenewDir(pkiDir), pki.WithRenewLeafs(name)} + if dryRun { + opts = append(opts, pki.WithDryRun()) + } + + var warnings []string + usedCAs := map[pki.RootCertName]struct{}{} + + report := pki.RenewCertificates(opts...) + for _, e := range report.Entries { + line := formatLeafEntry(pki.LeafDescription(e.Name), e) + fmt.Fprintln(w, line) + if e.Err != nil { + warnings = append(warnings, line) + continue + } + usedCAs[e.Authority] = struct{}{} + } + warnings = append(warnings, checkCAsOutliveRenewed(w, pkiDir, usedCAs)...) + + printDryRunFooter(w, dryRun) + + return warningsError(warnings) +} + +func renewSingleKubeconfig(w io.Writer, path string, file kubeconfig.File, certsDirOverride, kcDirOverride string, dryRun bool) error { + printDryRunBanner(w, dryRun) + + kcDir := kcDirOverride + if kcDir == "" { + kcDir = filepath.Dir(filepath.Clean(path)) + } + + opts := []kubeconfig.RenewOption{ + kubeconfig.WithRenewKubeconfigDir(kcDir), + kubeconfig.WithRenewPKIDir(certsDirOverride), + } + if dryRun { + opts = append(opts, kubeconfig.WithDryRun()) + } + + var warnings []string + usedCAs := map[pki.RootCertName]struct{}{} + + entry := kubeconfig.KubeconfigRenewEntry{ + File: file, + Path: path, + Err: kubeconfig.RenewClientCert(file, opts...), + } + line := formatKubeconfigEntry(kubeconfig.FileDescription(file), entry) + fmt.Fprintln(w, line) + if entry.Err != nil { + warnings = append(warnings, line) + } else { + usedCAs[pki.CACertName] = struct{}{} // kubeconfig client certs are signed by ca + } + + warnings = append(warnings, checkCAsOutliveRenewed(w, certsDirOverride, usedCAs)...) + + printDryRunFooter(w, dryRun) + + return warningsError(warnings) +} + +func formatLeafEntry(desc string, e pki.PKIRenewEntry) string { + var ( + missing *pki.MissingError + caMissing *pki.CAMissingError + external *pki.CAExternalError + expired *pki.CAExpiredError + ) + switch { + case e.Err == nil: + return fmt.Sprintf("%s renewed", desc) + case errors.As(e.Err, &missing): + return fmt.Sprintf("MISSING! %s: %s", desc, e.Path) + case errors.As(e.Err, &caMissing): + return fmt.Sprintf("CA %q missing, %s skipped: %s", caMissing.CAName, desc, e.Path) + case errors.As(e.Err, &external): + return fmt.Sprintf("Detected external %s, %s can't be renewed: %s", external.CAName, desc, e.Path) + case errors.As(e.Err, &expired): + return fmt.Sprintf("CA %q expired, %s skipped: %s", expired.CAName, desc, e.Path) + default: + return fmt.Sprintf("%s — error: %s", desc, e.Err.Error()) + } +} + +func formatKubeconfigEntry(desc string, e kubeconfig.KubeconfigRenewEntry) string { + var ( + missing *kubeconfig.MissingError + caMissing *kubeconfig.CAMissingError + external *kubeconfig.CAExternalError + expired *kubeconfig.CAExpiredError + ) switch { - case errors.As(err, &missingCert): - return fmt.Sprintf("MISSING! %s", entry.longName), nil - case errors.As(err, &externalCA): - return fmt.Sprintf("Detected external %s, %s can't be renewed", externalCA.CAName, entry.longName), nil - case errors.As(err, &expiredCA): - return "", fmt.Errorf( - "CA %q expired at %s — rotate the CA before renewing kubeconfig certificates", - expiredCA.CAName, expiredCA.ExpiredAt.UTC().Format("Jan 02, 2006 15:04 MST"), - ) + case e.Err == nil: + return fmt.Sprintf("%s renewed", desc) + case errors.As(e.Err, &missing): + return fmt.Sprintf("MISSING! %s: %s", desc, e.Path) + case errors.As(e.Err, &caMissing): + return fmt.Sprintf("CA %q missing, %s skipped: %s", caMissing.CAName, desc, e.Path) + case errors.As(e.Err, &external): + return fmt.Sprintf("Detected external %s, %s can't be renewed: %s", external.CAName, desc, e.Path) + case errors.As(e.Err, &expired): + return fmt.Sprintf("CA %q expired, %s skipped: %s", expired.CAName, desc, e.Path) default: - return "", fmt.Errorf("renew %q: %w", entry.name, err) + return fmt.Sprintf("%s — error: %s", desc, e.Err.Error()) } } From fcab43d7ed7a21f30e4f44e9df61f286c72b9029 Mon Sep 17 00:00:00 2001 From: "dmitry.trofimov" Date: Fri, 29 May 2026 15:25:15 +0300 Subject: [PATCH 3/5] fixes Signed-off-by: dmitry.trofimov --- internal/tools/pki/certs/cmd/renew.go | 273 ++----------------------- internal/tools/pki/certs/renew.go | 278 ++++++++++++++++++++++++++ 2 files changed, 296 insertions(+), 255 deletions(-) create mode 100644 internal/tools/pki/certs/renew.go diff --git a/internal/tools/pki/certs/cmd/renew.go b/internal/tools/pki/certs/cmd/renew.go index ca3495e8..f5e9875f 100644 --- a/internal/tools/pki/certs/cmd/renew.go +++ b/internal/tools/pki/certs/cmd/renew.go @@ -16,19 +16,13 @@ limitations under the License. package cmd import ( - "errors" "fmt" - "io" - "path/filepath" - "strings" - "time" + "net" "github.com/spf13/cobra" "k8s.io/kubectl/pkg/util/templates" - "github.com/deckhouse/deckhouse/go_lib/controlplane/constants" - "github.com/deckhouse/deckhouse/go_lib/controlplane/kubeconfig" - "github.com/deckhouse/deckhouse/go_lib/controlplane/pki" + "github.com/deckhouse/deckhouse-cli/internal/tools/pki/certs" ) var renewLong = templates.LongDesc(` @@ -56,15 +50,17 @@ func NewRenewCommand() *cobra.Command { certsDir string kubeconfigDir string dryRun bool + san string ) renewCmd := &cobra.Command{ Use: "renew (all | PATH)", Short: "Renew control-plane certificates and kubeconfig client certificates", Long: renewLong, - Args: cobra.ArbitraryArgs, + Args: cobra.ArbitraryArgs, Example: " d8 tools pki certs renew all\n" + " d8 tools pki certs renew all --dry-run\n" + + " d8 tools pki certs renew all --san 192.168.0.5\n" + " d8 tools pki certs renew /etc/kubernetes/pki/apiserver.crt\n" + " d8 tools pki certs renew /etc/kubernetes/admin.conf\n" + " d8 tools pki certs renew all --path /opt/k8s/pki --kubeconfig-dir /opt/k8s", @@ -75,9 +71,7 @@ func NewRenewCommand() *cobra.Command { if len(args) > 1 { return fmt.Errorf("accepts exactly one argument (PATH), received %d", len(args)) } - w := cmd.OutOrStdout() - kcDir := effectiveKubeconfigDir(certsDir, kubeconfigDir) - return runRenewSingle(w, args[0], certsDir, kcDir, dryRun) + return certs.RunRenewSingle(cmd.OutOrStdout(), args[0], certsDir, kubeconfigDir, dryRun) }, } @@ -86,9 +80,14 @@ func NewRenewCommand() *cobra.Command { Short: "Renew all control-plane certificates and kubeconfig client certificates", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - w := cmd.OutOrStdout() - kcDir := effectiveKubeconfigDir(certsDir, kubeconfigDir) - return runRenewAll(w, certsDir, kcDir, dryRun) + var extraIP net.IP + if san != "" { + extraIP = net.ParseIP(san) + if extraIP == nil { + return fmt.Errorf("--san accepts only an IP address, got %q", san) + } + } + return certs.RunRenewAll(cmd.OutOrStdout(), certsDir, kubeconfigDir, dryRun, extraIP) }, } @@ -103,247 +102,11 @@ func NewRenewCommand() *cobra.Command { addPathFlags(renewCmd) addPathFlags(allCmd) + // --san is only for a full renewal + allCmd.Flags().StringVar(&san, "san", "", + "New IP SAN to add to serving certificates (e.g. the new master IP)") + renewCmd.AddCommand(allCmd) return renewCmd } - -func effectiveKubeconfigDir(certsDir, override string) string { - if override != "" { - return override - } - return filepath.Dir(certsDir) -} - -func runRenewAll(w io.Writer, certsDir, kcDir string, dryRun bool) error { - printDryRunBanner(w, dryRun) - - var warnings []string - usedCAs := map[pki.RootCertName]struct{}{} - - leafOpts := []pki.RenewOption{pki.WithRenewDir(certsDir)} - if dryRun { - leafOpts = append(leafOpts, pki.WithDryRun()) - } - - leafReport := pki.RenewCertificates(leafOpts...) - for _, e := range leafReport.Entries { - line := formatLeafEntry(pki.LeafDescription(e.Name), e) - fmt.Fprintln(w, line) - if e.Err != nil { - warnings = append(warnings, line) - continue - } - usedCAs[e.Authority] = struct{}{} - } - - kcOpts := []kubeconfig.RenewOption{ - kubeconfig.WithRenewKubeconfigDir(kcDir), - kubeconfig.WithRenewPKIDir(certsDir), - } - if dryRun { - kcOpts = append(kcOpts, kubeconfig.WithDryRun()) - } - - kcReport := kubeconfig.RenewClientCerts(kcOpts...) - for _, e := range kcReport.Entries { - line := formatKubeconfigEntry(kubeconfig.FileDescription(e.File), e) - fmt.Fprintln(w, line) - if e.Err != nil { - warnings = append(warnings, line) - continue - } - usedCAs[pki.CACertName] = struct{}{} // kubeconfig client certs are signed by ca - } - - warnings = append(warnings, checkCAsOutliveRenewed(w, certsDir, usedCAs)...) - - printDryRunFooter(w, dryRun) - - return warningsError(warnings) -} - -func printDryRunBanner(w io.Writer, dryRun bool) { - if dryRun { - fmt.Fprintln(w, "DRY RUN — no files will be modified") - } -} - -func printDryRunFooter(w io.Writer, dryRun bool) { - if dryRun { - fmt.Fprintln(w, "(dry-run) nothing was written") - } -} - -// checkCAsOutliveRenewed warns when a CA that signed a freshly renewed cert expires sooner than the new (1-year) cert. -func checkCAsOutliveRenewed(w io.Writer, certsDir string, usedCAs map[pki.RootCertName]struct{}) []string { - if len(usedCAs) == 0 { - return nil - } - - cas := make([]pki.RootCertName, 0, len(usedCAs)) - for ca := range usedCAs { - cas = append(cas, ca) - } - - report := pki.ListCertificateExpirations( - pki.WithCertificatesDir(certsDir), - pki.WithRootCertificates(cas...), - ) - - threshold := time.Now().Add(constants.CertificateValidityPeriod) - var warnings []string - for _, e := range report.Entries { - if e.Err != nil { - // CA read failure here is already surfaced via leaf renew skips. - continue - } - if e.NotAfter.Before(threshold) { - line := fmt.Sprintf("WARNING: CA %q expires %s, sooner than the renewed certificates — rotate the CA: %s", - e.Name, e.NotAfter.UTC().Format("Jan 02, 2006 15:04 MST"), e.Path) - fmt.Fprintln(w, line) - warnings = append(warnings, line) - } - } - return warnings -} - -func warningsError(warnings []string) error { - if len(warnings) == 0 { - return nil - } - return fmt.Errorf("%d warning(s) during renewal:\n%s", len(warnings), strings.Join(warnings, "\n")) -} - -func runRenewSingle(w io.Writer, path, certsDir, kcDirOverride string, dryRun bool) error { - if kcExp, err := kubeconfig.GetClientCertificateExpiration(path); err == nil { - return renewSingleKubeconfig(w, path, kcExp.File, certsDir, kcDirOverride, dryRun) - } - - certExp, certErr := pki.GetCertificateExpiration(path) - if certErr != nil { - return fmt.Errorf("cannot determine file type for %q: not a recognizable kubeconfig or PEM certificate: %w", path, certErr) - } - if certExp.IsCA { - return fmt.Errorf("CA certificate renewal is not supported: %q", path) - } - return renewSingleLeaf(w, path, pki.LeafCertName(certExp.Name), certsDir, dryRun) -} - -func renewSingleLeaf(w io.Writer, path string, name pki.LeafCertName, pkiDir string, dryRun bool) error { - printDryRunBanner(w, dryRun) - - if !strings.HasPrefix(filepath.Clean(path), filepath.Clean(pkiDir)+string(filepath.Separator)) { - return fmt.Errorf("certificate %q is not under PKI directory %q; use --path to specify the correct directory", path, pkiDir) - } - - opts := []pki.RenewOption{pki.WithRenewDir(pkiDir), pki.WithRenewLeafs(name)} - if dryRun { - opts = append(opts, pki.WithDryRun()) - } - - var warnings []string - usedCAs := map[pki.RootCertName]struct{}{} - - report := pki.RenewCertificates(opts...) - for _, e := range report.Entries { - line := formatLeafEntry(pki.LeafDescription(e.Name), e) - fmt.Fprintln(w, line) - if e.Err != nil { - warnings = append(warnings, line) - continue - } - usedCAs[e.Authority] = struct{}{} - } - - warnings = append(warnings, checkCAsOutliveRenewed(w, pkiDir, usedCAs)...) - - printDryRunFooter(w, dryRun) - - return warningsError(warnings) -} - -func renewSingleKubeconfig(w io.Writer, path string, file kubeconfig.File, certsDirOverride, kcDirOverride string, dryRun bool) error { - printDryRunBanner(w, dryRun) - - kcDir := kcDirOverride - if kcDir == "" { - kcDir = filepath.Dir(filepath.Clean(path)) - } - - opts := []kubeconfig.RenewOption{ - kubeconfig.WithRenewKubeconfigDir(kcDir), - kubeconfig.WithRenewPKIDir(certsDirOverride), - } - if dryRun { - opts = append(opts, kubeconfig.WithDryRun()) - } - - var warnings []string - usedCAs := map[pki.RootCertName]struct{}{} - - entry := kubeconfig.KubeconfigRenewEntry{ - File: file, - Path: path, - Err: kubeconfig.RenewClientCert(file, opts...), - } - line := formatKubeconfigEntry(kubeconfig.FileDescription(file), entry) - fmt.Fprintln(w, line) - if entry.Err != nil { - warnings = append(warnings, line) - } else { - usedCAs[pki.CACertName] = struct{}{} // kubeconfig client certs are signed by ca - } - - warnings = append(warnings, checkCAsOutliveRenewed(w, certsDirOverride, usedCAs)...) - - printDryRunFooter(w, dryRun) - - return warningsError(warnings) -} - -func formatLeafEntry(desc string, e pki.PKIRenewEntry) string { - var ( - missing *pki.MissingError - caMissing *pki.CAMissingError - external *pki.CAExternalError - expired *pki.CAExpiredError - ) - switch { - case e.Err == nil: - return fmt.Sprintf("%s renewed", desc) - case errors.As(e.Err, &missing): - return fmt.Sprintf("MISSING! %s: %s", desc, e.Path) - case errors.As(e.Err, &caMissing): - return fmt.Sprintf("CA %q missing, %s skipped: %s", caMissing.CAName, desc, e.Path) - case errors.As(e.Err, &external): - return fmt.Sprintf("Detected external %s, %s can't be renewed: %s", external.CAName, desc, e.Path) - case errors.As(e.Err, &expired): - return fmt.Sprintf("CA %q expired, %s skipped: %s", expired.CAName, desc, e.Path) - default: - return fmt.Sprintf("%s — error: %s", desc, e.Err.Error()) - } -} - -func formatKubeconfigEntry(desc string, e kubeconfig.KubeconfigRenewEntry) string { - var ( - missing *kubeconfig.MissingError - caMissing *kubeconfig.CAMissingError - external *kubeconfig.CAExternalError - expired *kubeconfig.CAExpiredError - ) - switch { - case e.Err == nil: - return fmt.Sprintf("%s renewed", desc) - case errors.As(e.Err, &missing): - return fmt.Sprintf("MISSING! %s: %s", desc, e.Path) - case errors.As(e.Err, &caMissing): - return fmt.Sprintf("CA %q missing, %s skipped: %s", caMissing.CAName, desc, e.Path) - case errors.As(e.Err, &external): - return fmt.Sprintf("Detected external %s, %s can't be renewed: %s", external.CAName, desc, e.Path) - case errors.As(e.Err, &expired): - return fmt.Sprintf("CA %q expired, %s skipped: %s", expired.CAName, desc, e.Path) - default: - return fmt.Sprintf("%s — error: %s", desc, e.Err.Error()) - } -} diff --git a/internal/tools/pki/certs/renew.go b/internal/tools/pki/certs/renew.go new file mode 100644 index 00000000..7c399371 --- /dev/null +++ b/internal/tools/pki/certs/renew.go @@ -0,0 +1,278 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package certs + +import ( + "errors" + "fmt" + "io" + "net" + "path/filepath" + "strings" + "time" + + "github.com/deckhouse/deckhouse/go_lib/controlplane/constants" + "github.com/deckhouse/deckhouse/go_lib/controlplane/kubeconfig" + "github.com/deckhouse/deckhouse/go_lib/controlplane/pki" +) + +// RunRenewAll renews every known control-plane leaf certificate and kubeconfig client certificate. +func RunRenewAll(w io.Writer, certsDir, kubeconfigDirOverride string, dryRun bool, extraIP net.IP) error { + kcDir := effectiveKubeconfigDir(certsDir, kubeconfigDirOverride) + + printDryRunBanner(w, dryRun) + + var warnings []string + usedCAs := map[pki.RootCertName]struct{}{} + + leafOpts := []pki.RenewOption{pki.WithRenewDir(certsDir)} + if dryRun { + leafOpts = append(leafOpts, pki.WithDryRun()) + } + if extraIP != nil { + leafOpts = append(leafOpts, pki.WithRenewExtraIP(extraIP)) + } + + leafReport := pki.RenewCertificates(leafOpts...) + for _, e := range leafReport.Entries { + line := formatLeafEntry(pki.LeafDescription(e.Name), e) + fmt.Fprintln(w, line) + if e.Err != nil { + warnings = append(warnings, line) + continue + } + usedCAs[e.Authority] = struct{}{} + } + + kcOpts := []kubeconfig.RenewOption{ + kubeconfig.WithRenewKubeconfigDir(kcDir), + kubeconfig.WithRenewPKIDir(certsDir), + } + if dryRun { + kcOpts = append(kcOpts, kubeconfig.WithDryRun()) + } + + kcReport := kubeconfig.RenewClientCerts(kcOpts...) + for _, e := range kcReport.Entries { + line := formatKubeconfigEntry(kubeconfig.FileDescription(e.File), e) + fmt.Fprintln(w, line) + if e.Err != nil { + warnings = append(warnings, line) + continue + } + usedCAs[pki.CACertName] = struct{}{} // kubeconfig client certs are signed by ca + } + + warnings = append(warnings, checkCAsOutliveRenewed(w, certsDir, usedCAs)...) + + printDryRunFooter(w, dryRun) + + return warningsError(warnings) +} + +// RunRenewSingle renews a single artifact identified by path: it auto-detects whether path is a kubeconfig file or a PEM leaf certificate. +func RunRenewSingle(w io.Writer, path, certsDir, kubeconfigDirOverride string, dryRun bool) error { + if kcExp, err := kubeconfig.GetClientCertificateExpiration(path); err == nil { + return renewSingleKubeconfig(w, path, kcExp.File, certsDir, kubeconfigDirOverride, dryRun) + } + + certExp, certErr := pki.GetCertificateExpiration(path) + if certErr != nil { + return fmt.Errorf("cannot determine file type for %q: not a recognizable kubeconfig or PEM certificate: %w", path, certErr) + } + if certExp.IsCA { + return fmt.Errorf("CA certificate renewal is not supported: %q", path) + } + return renewSingleLeaf(w, path, pki.LeafCertName(certExp.Name), certsDir, dryRun) +} + +func effectiveKubeconfigDir(certsDir, override string) string { + if override != "" { + return override + } + return filepath.Dir(certsDir) +} + +func renewSingleLeaf(w io.Writer, path string, name pki.LeafCertName, pkiDir string, dryRun bool) error { + printDryRunBanner(w, dryRun) + + if !strings.HasPrefix(filepath.Clean(path), filepath.Clean(pkiDir)+string(filepath.Separator)) { + return fmt.Errorf("certificate %q is not under PKI directory %q; use --path to specify the correct directory", path, pkiDir) + } + + opts := []pki.RenewOption{pki.WithRenewDir(pkiDir), pki.WithRenewLeafs(name)} + if dryRun { + opts = append(opts, pki.WithDryRun()) + } + + var warnings []string + usedCAs := map[pki.RootCertName]struct{}{} + + report := pki.RenewCertificates(opts...) + for _, e := range report.Entries { + line := formatLeafEntry(pki.LeafDescription(e.Name), e) + fmt.Fprintln(w, line) + if e.Err != nil { + warnings = append(warnings, line) + continue + } + usedCAs[e.Authority] = struct{}{} + } + + warnings = append(warnings, checkCAsOutliveRenewed(w, pkiDir, usedCAs)...) + + printDryRunFooter(w, dryRun) + + return warningsError(warnings) +} + +func renewSingleKubeconfig(w io.Writer, path string, file kubeconfig.File, certsDirOverride, kcDirOverride string, dryRun bool) error { + printDryRunBanner(w, dryRun) + + kcDir := kcDirOverride + if kcDir == "" { + kcDir = filepath.Dir(filepath.Clean(path)) + } + + opts := []kubeconfig.RenewOption{ + kubeconfig.WithRenewKubeconfigDir(kcDir), + kubeconfig.WithRenewPKIDir(certsDirOverride), + } + if dryRun { + opts = append(opts, kubeconfig.WithDryRun()) + } + + var warnings []string + usedCAs := map[pki.RootCertName]struct{}{} + + entry := kubeconfig.KubeconfigRenewEntry{ + File: file, + Path: path, + Err: kubeconfig.RenewClientCert(file, opts...), + } + line := formatKubeconfigEntry(kubeconfig.FileDescription(file), entry) + fmt.Fprintln(w, line) + if entry.Err != nil { + warnings = append(warnings, line) + } else { + usedCAs[pki.CACertName] = struct{}{} // kubeconfig client certs are signed by ca + } + + warnings = append(warnings, checkCAsOutliveRenewed(w, certsDirOverride, usedCAs)...) + + printDryRunFooter(w, dryRun) + + return warningsError(warnings) +} + +func printDryRunBanner(w io.Writer, dryRun bool) { + if dryRun { + fmt.Fprintln(w, "DRY RUN — no files will be modified") + } +} + +func printDryRunFooter(w io.Writer, dryRun bool) { + if dryRun { + fmt.Fprintln(w, "(dry-run) nothing was written") + } +} + +// checkCAsOutliveRenewed warns when a CA that signed a freshly renewed cert expires sooner than the new (1-year) cert. +func checkCAsOutliveRenewed(w io.Writer, certsDir string, usedCAs map[pki.RootCertName]struct{}) []string { + if len(usedCAs) == 0 { + return nil + } + + cas := make([]pki.RootCertName, 0, len(usedCAs)) + for ca := range usedCAs { + cas = append(cas, ca) + } + + report := pki.ListCertificateExpirations( + pki.WithCertificatesDir(certsDir), + pki.WithRootCertificates(cas...), + ) + + threshold := time.Now().Add(constants.CertificateValidityPeriod) + var warnings []string + for _, e := range report.Entries { + if e.Err != nil { + // CA read failure here is already surfaced via leaf renew skips. + continue + } + if e.NotAfter.Before(threshold) { + line := fmt.Sprintf("WARNING: CA %q expires %s, sooner than the renewed certificates — rotate the CA: %s", + e.Name, e.NotAfter.UTC().Format("Jan 02, 2006 15:04 MST"), e.Path) + fmt.Fprintln(w, line) + warnings = append(warnings, line) + } + } + return warnings +} + +func warningsError(warnings []string) error { + if len(warnings) == 0 { + return nil + } + return fmt.Errorf("%d warning(s) during renewal:\n%s", len(warnings), strings.Join(warnings, "\n")) +} + +func formatLeafEntry(desc string, e pki.PKIRenewEntry) string { + var ( + missing *pki.MissingError + caMissing *pki.CAMissingError + external *pki.CAExternalError + expired *pki.CAExpiredError + ) + switch { + case e.Err == nil: + return fmt.Sprintf("%s renewed", desc) + case errors.As(e.Err, &missing): + return fmt.Sprintf("MISSING! %s: %s", desc, e.Path) + case errors.As(e.Err, &caMissing): + return fmt.Sprintf("CA %q missing, %s skipped: %s", caMissing.CAName, desc, e.Path) + case errors.As(e.Err, &external): + return fmt.Sprintf("Detected external %s, %s can't be renewed: %s", external.CAName, desc, e.Path) + case errors.As(e.Err, &expired): + return fmt.Sprintf("CA %q expired, %s skipped: %s", expired.CAName, desc, e.Path) + default: + return fmt.Sprintf("%s — error: %s", desc, e.Err.Error()) + } +} + +func formatKubeconfigEntry(desc string, e kubeconfig.KubeconfigRenewEntry) string { + var ( + missing *kubeconfig.MissingError + caMissing *kubeconfig.CAMissingError + external *kubeconfig.CAExternalError + expired *kubeconfig.CAExpiredError + ) + switch { + case e.Err == nil: + return fmt.Sprintf("%s renewed", desc) + case errors.As(e.Err, &missing): + return fmt.Sprintf("MISSING! %s: %s", desc, e.Path) + case errors.As(e.Err, &caMissing): + return fmt.Sprintf("CA %q missing, %s skipped: %s", caMissing.CAName, desc, e.Path) + case errors.As(e.Err, &external): + return fmt.Sprintf("Detected external %s, %s can't be renewed: %s", external.CAName, desc, e.Path) + case errors.As(e.Err, &expired): + return fmt.Sprintf("CA %q expired, %s skipped: %s", expired.CAName, desc, e.Path) + default: + return fmt.Sprintf("%s — error: %s", desc, e.Err.Error()) + } +} From 5771b75d5e8dbfae1820fff567d6b14a310b1613 Mon Sep 17 00:00:00 2001 From: "dmitry.trofimov" Date: Fri, 29 May 2026 16:22:08 +0300 Subject: [PATCH 4/5] fixes Signed-off-by: dmitry.trofimov --- internal/tools/pki/certs/renew.go | 87 +++++++++++++------------------ 1 file changed, 37 insertions(+), 50 deletions(-) diff --git a/internal/tools/pki/certs/renew.go b/internal/tools/pki/certs/renew.go index 7c399371..7e743ee5 100644 --- a/internal/tools/pki/certs/renew.go +++ b/internal/tools/pki/certs/renew.go @@ -47,16 +47,7 @@ func RunRenewAll(w io.Writer, certsDir, kubeconfigDirOverride string, dryRun boo leafOpts = append(leafOpts, pki.WithRenewExtraIP(extraIP)) } - leafReport := pki.RenewCertificates(leafOpts...) - for _, e := range leafReport.Entries { - line := formatLeafEntry(pki.LeafDescription(e.Name), e) - fmt.Fprintln(w, line) - if e.Err != nil { - warnings = append(warnings, line) - continue - } - usedCAs[e.Authority] = struct{}{} - } + warnings = append(warnings, collectLeafWarnings(w, pki.RenewCertificates(leafOpts...), usedCAs)...) kcOpts := []kubeconfig.RenewOption{ kubeconfig.WithRenewKubeconfigDir(kcDir), @@ -66,16 +57,7 @@ func RunRenewAll(w io.Writer, certsDir, kubeconfigDirOverride string, dryRun boo kcOpts = append(kcOpts, kubeconfig.WithDryRun()) } - kcReport := kubeconfig.RenewClientCerts(kcOpts...) - for _, e := range kcReport.Entries { - line := formatKubeconfigEntry(kubeconfig.FileDescription(e.File), e) - fmt.Fprintln(w, line) - if e.Err != nil { - warnings = append(warnings, line) - continue - } - usedCAs[pki.CACertName] = struct{}{} // kubeconfig client certs are signed by ca - } + warnings = append(warnings, collectKubeconfigWarnings(w, kubeconfig.RenewClientCerts(kcOpts...), usedCAs)...) warnings = append(warnings, checkCAsOutliveRenewed(w, certsDir, usedCAs)...) @@ -119,20 +101,8 @@ func renewSingleLeaf(w io.Writer, path string, name pki.LeafCertName, pkiDir str opts = append(opts, pki.WithDryRun()) } - var warnings []string usedCAs := map[pki.RootCertName]struct{}{} - - report := pki.RenewCertificates(opts...) - for _, e := range report.Entries { - line := formatLeafEntry(pki.LeafDescription(e.Name), e) - fmt.Fprintln(w, line) - if e.Err != nil { - warnings = append(warnings, line) - continue - } - usedCAs[e.Authority] = struct{}{} - } - + warnings := collectLeafWarnings(w, pki.RenewCertificates(opts...), usedCAs) warnings = append(warnings, checkCAsOutliveRenewed(w, pkiDir, usedCAs)...) printDryRunFooter(w, dryRun) @@ -151,27 +121,14 @@ func renewSingleKubeconfig(w io.Writer, path string, file kubeconfig.File, certs opts := []kubeconfig.RenewOption{ kubeconfig.WithRenewKubeconfigDir(kcDir), kubeconfig.WithRenewPKIDir(certsDirOverride), + kubeconfig.WithRenewFiles(file), } if dryRun { opts = append(opts, kubeconfig.WithDryRun()) } - var warnings []string usedCAs := map[pki.RootCertName]struct{}{} - - entry := kubeconfig.KubeconfigRenewEntry{ - File: file, - Path: path, - Err: kubeconfig.RenewClientCert(file, opts...), - } - line := formatKubeconfigEntry(kubeconfig.FileDescription(file), entry) - fmt.Fprintln(w, line) - if entry.Err != nil { - warnings = append(warnings, line) - } else { - usedCAs[pki.CACertName] = struct{}{} // kubeconfig client certs are signed by ca - } - + warnings := collectKubeconfigWarnings(w, kubeconfig.RenewClientCerts(opts...), usedCAs) warnings = append(warnings, checkCAsOutliveRenewed(w, certsDirOverride, usedCAs)...) printDryRunFooter(w, dryRun) @@ -211,7 +168,7 @@ func checkCAsOutliveRenewed(w io.Writer, certsDir string, usedCAs map[pki.RootCe var warnings []string for _, e := range report.Entries { if e.Err != nil { - // CA read failure here is already surfaced via leaf renew skips. + // A CA read failure here is already surfaced via the renew entry above. continue } if e.NotAfter.Before(threshold) { @@ -228,7 +185,37 @@ func warningsError(warnings []string) error { if len(warnings) == 0 { return nil } - return fmt.Errorf("%d warning(s) during renewal:\n%s", len(warnings), strings.Join(warnings, "\n")) + return fmt.Errorf("%d certificate(s) not renewed; see output above", len(warnings)) +} + +// collectLeafWarnings prints a progress line for every leaf renewal entry +func collectLeafWarnings(w io.Writer, report pki.PKIRenewReport, usedCAs map[pki.RootCertName]struct{}) []string { + var warnings []string + for _, e := range report.Entries { + line := formatLeafEntry(pki.LeafDescription(e.Name), e) + fmt.Fprintln(w, line) + if e.Err != nil { + warnings = append(warnings, line) + continue + } + usedCAs[e.Authority] = struct{}{} + } + return warnings +} + +// collectKubeconfigWarnings mirrors collectLeafWarnings for kubeconfigs +func collectKubeconfigWarnings(w io.Writer, report kubeconfig.KubeconfigRenewReport, usedCAs map[pki.RootCertName]struct{}) []string { + var warnings []string + for _, e := range report.Entries { + line := formatKubeconfigEntry(kubeconfig.FileDescription(e.File), e) + fmt.Fprintln(w, line) + if e.Err != nil { + warnings = append(warnings, line) + continue + } + usedCAs[pki.CACertName] = struct{}{} + } + return warnings } func formatLeafEntry(desc string, e pki.PKIRenewEntry) string { From 3f271f5a618efc67f2f73894ca7eacd7b614707f Mon Sep 17 00:00:00 2001 From: "dmitry.trofimov" Date: Mon, 1 Jun 2026 17:40:46 +0300 Subject: [PATCH 5/5] fixes Signed-off-by: dmitry.trofimov --- internal/tools/pki/certs/certs.go | 5 ++++- internal/tools/pki/certs/renew.go | 8 +++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/internal/tools/pki/certs/certs.go b/internal/tools/pki/certs/certs.go index c192a729..a8f1506c 100644 --- a/internal/tools/pki/certs/certs.go +++ b/internal/tools/pki/certs/certs.go @@ -55,7 +55,10 @@ type Report struct { // kubeconfigDir is the directory containing kubeconfig files (e.g. /etc/kubernetes). // Callers that want the standard layout can pass filepath.Dir(certsDir). func BuildFullScanReport(certsDir, kubeconfigDir string) (*Report, error) { - pkiReport := pki.ListCertificateExpirations(pki.WithCertificatesDir(certsDir)) + pkiReport, err := pki.ListCertificateExpirations(pki.WithCertificatesDir(certsDir)) + if err != nil { + return nil, fmt.Errorf("listing PKI certificates in %q: %w", certsDir, err) + } kcReport := kubeconfig.ListClientCertificateExpirations(kubeconfig.WithKubeconfigDir(kubeconfigDir)) report := &Report{} diff --git a/internal/tools/pki/certs/renew.go b/internal/tools/pki/certs/renew.go index 7e743ee5..b6ebe1c2 100644 --- a/internal/tools/pki/certs/renew.go +++ b/internal/tools/pki/certs/renew.go @@ -159,10 +159,16 @@ func checkCAsOutliveRenewed(w io.Writer, certsDir string, usedCAs map[pki.RootCe cas = append(cas, ca) } - report := pki.ListCertificateExpirations( + report, err := pki.ListCertificateExpirations( pki.WithCertificatesDir(certsDir), pki.WithRootCertificates(cas...), ) + if err != nil { + // It shouldn't happen, but return error through as a warning. + line := fmt.Sprintf("WARNING: cannot check CA expiration: %v", err) + fmt.Fprintln(w, line) + return []string{line} + } threshold := time.Now().Add(constants.CertificateValidityPeriod) var warnings []string