Skip to content

Commit 36e49b0

Browse files
authored
[tools] Add d8 tools pki certs renew for control-plane certificates (#368)
Signed-off-by: dmitry.trofimov <dmitry.trofimov@flant.com>
1 parent b4a33a9 commit 36e49b0

6 files changed

Lines changed: 476 additions & 96 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ go 1.25.6
44

55
require (
66
github.com/Masterminds/semver/v3 v3.3.1
7-
github.com/deckhouse/deckhouse/go_lib/controlplane v0.0.0-20260521055926-efd0886cc5da
7+
github.com/deckhouse/deckhouse/go_lib/controlplane v0.0.0-20260602082302-91957e5e124e
88
github.com/deckhouse/deckhouse/pkg/log v0.2.0
99
github.com/deckhouse/deckhouse/pkg/registry v0.0.0-20260414112803-53a5662881d9
1010
github.com/deckhouse/virtualization/src/cli v0.0.0-20260411164351-43c8e331b69d // fix/cli/mute-warnings-during-cobra-init-release-1-6-2 (Change to version 1.8.0 after updating Kubernetes to 0.34)

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -417,8 +417,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
417417
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
418418
github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM=
419419
github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4=
420-
github.com/deckhouse/deckhouse/go_lib/controlplane v0.0.0-20260521055926-efd0886cc5da h1:qTCPtAjVY+EWX056Vf/DHbS/wlMc9Kw1mCLKya63O+0=
421-
github.com/deckhouse/deckhouse/go_lib/controlplane v0.0.0-20260521055926-efd0886cc5da/go.mod h1:sSsmzJpe+mL3xEtlzae0kywYBjMS/tnw3TyaQ2X77hQ=
420+
github.com/deckhouse/deckhouse/go_lib/controlplane v0.0.0-20260602082302-91957e5e124e h1:Nvr0bV5Xetjhsb9ibjwuTaoZb854yy3W7jhhWpPNBEQ=
421+
github.com/deckhouse/deckhouse/go_lib/controlplane v0.0.0-20260602082302-91957e5e124e/go.mod h1:LXzIV70U2qmIKPwLHqreCT8dbfYV1w6Qn2Tdb+jU5gY=
422422
github.com/deckhouse/deckhouse/pkg/log v0.2.0 h1:6tmZQLwNb1o/hP1gzJQBjcwfA/bubbgObovXzxq+Exo=
423423
github.com/deckhouse/deckhouse/pkg/log v0.2.0/go.mod h1:pbAxTSDcPmwyl3wwKDcEB3qdxHnRxqTV+J0K+sha8bw=
424424
github.com/deckhouse/deckhouse/pkg/registry v0.0.0-20260414112803-53a5662881d9 h1:Il2d6wB6SdgjmD5ojC48qT9eQyITuANzIFjSd0DCdUI=

internal/tools/pki/certs/certs.go

Lines changed: 71 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import (
2020
"errors"
2121
"fmt"
2222
"io"
23-
"io/fs"
2423
"path/filepath"
2524
"sort"
2625
"strings"
@@ -50,44 +49,53 @@ type Report struct {
5049
CAs []CAEntry
5150
}
5251

53-
type multiUnwrapper interface {
54-
Unwrap() []error
55-
}
56-
5752
// BuildFullScanReport enumerates all known control-plane certificates and kubeconfig
5853
// client certificates, returning a report split into CAs and leaf certs.
5954
// certsDir is the PKI directory (e.g. /etc/kubernetes/pki).
6055
// kubeconfigDir is the directory containing kubeconfig files (e.g. /etc/kubernetes).
6156
// Callers that want the standard layout can pass filepath.Dir(certsDir).
6257
func BuildFullScanReport(certsDir, kubeconfigDir string) (*Report, error) {
63-
pkiExpirations, pkiErr := pki.ListCertificateExpirations(
64-
pki.WithCertificatesDir(certsDir),
65-
pki.WithIgnoreReadErrors(),
66-
)
67-
kcExpirations, kcErr := kubeconfig.ListClientCertificateExpirations(
68-
kubeconfig.WithKubeconfigDir(kubeconfigDir),
69-
kubeconfig.WithIgnoreReadErrors(),
70-
)
58+
pkiReport, err := pki.ListCertificateExpirations(pki.WithCertificatesDir(certsDir))
59+
if err != nil {
60+
return nil, fmt.Errorf("listing PKI certificates in %q: %w", certsDir, err)
61+
}
7162

72-
hasExpirations := len(pkiExpirations) > 0 || len(kcExpirations) > 0
63+
kcReport := kubeconfig.ListClientCertificateExpirations(kubeconfig.WithKubeconfigDir(kubeconfigDir))
7364

74-
err := errors.Join(
75-
parseFullScanError("PKI certificates", certsDir, pkiErr),
76-
parseFullScanError("kubeconfig client certificates", kubeconfigDir, kcErr),
77-
)
78-
if err != nil && !hasExpirations {
79-
return nil, fmt.Errorf("no control-plane certificates or kubeconfig client certificates found: %w", err)
65+
report := &Report{}
66+
67+
var readErrs []error
68+
69+
for _, e := range pkiReport.Entries {
70+
switch {
71+
case e.Err == nil:
72+
appendPKIEntry(report, e.Name, e.NotAfter, e.IsCA, e.Authority)
73+
case isCertMissing(e.Err):
74+
// Missing — silent: worker/arbiter nodes don't carry the full PKI.
75+
default:
76+
readErrs = append(readErrs, e.Err)
77+
}
8078
}
8179

82-
if err != nil {
83-
return nil, err
80+
for _, e := range kcReport.Entries {
81+
switch {
82+
case e.Err == nil:
83+
appendKubeconfigEntry(report, e.File, e.NotAfter)
84+
case isKubeconfigMissing(e.Err):
85+
// Missing — silent.
86+
default:
87+
readErrs = append(readErrs, e.Err)
88+
}
8489
}
8590

86-
if !hasExpirations {
87-
return nil, fmt.Errorf("no control-plane certificates or kubeconfig client certificates found in %q and %q", certsDir, kubeconfigDir)
91+
if len(readErrs) > 0 {
92+
return nil, fmt.Errorf("listing PKI certificates in %q and kubeconfig client certificates in %q: %w",
93+
certsDir, kubeconfigDir, errors.Join(readErrs...))
8894
}
8995

90-
report := reportFromExpirations(pkiExpirations, kcExpirations)
96+
if len(report.Certs) == 0 && len(report.CAs) == 0 {
97+
return nil, fmt.Errorf("no control-plane certificates or kubeconfig client certificates found in %q and %q", certsDir, kubeconfigDir)
98+
}
9199

92100
sort.Slice(report.Certs, func(i, j int) bool {
93101
return report.Certs[i].Name < report.Certs[j].Name
@@ -99,60 +107,14 @@ func BuildFullScanReport(certsDir, kubeconfigDir string) (*Report, error) {
99107
return report, nil
100108
}
101109

102-
func reportFromExpirations(pkiExpirations []pki.CertificateExpiration, kcExpirations []kubeconfig.ClientCertificateExpiration) *Report {
103-
report := &Report{}
104-
105-
for _, exp := range pkiExpirations {
106-
if exp.IsCA {
107-
report.CAs = append(report.CAs, CAEntry{
108-
Name: pkiDisplayName(exp.Name),
109-
Expires: exp.NotAfter,
110-
})
111-
} else {
112-
report.Certs = append(report.Certs, CertEntry{
113-
Name: pkiDisplayName(exp.Name),
114-
Expires: exp.NotAfter,
115-
Authority: pkiDisplayName(string(exp.Authority)),
116-
})
117-
}
118-
}
119-
120-
for _, exp := range kcExpirations {
121-
report.Certs = append(report.Certs, CertEntry{
122-
Name: kubeconfigDisplayName(exp.File),
123-
Expires: exp.NotAfter,
124-
Authority: string(pki.CACertName),
125-
})
126-
}
127-
128-
return report
129-
}
130-
131-
func parseFullScanError(subject, dir string, err error) error {
132-
if err == nil || onlyNotExistErrors(err) {
133-
return nil
134-
}
135-
136-
return fmt.Errorf("listing %s in %q: %w", subject, dir, err)
110+
func isCertMissing(err error) bool {
111+
var missing *pki.MissingError
112+
return errors.As(err, &missing)
137113
}
138114

139-
func onlyNotExistErrors(err error) bool {
140-
if err == nil {
141-
return false
142-
}
143-
144-
var multiErr multiUnwrapper
145-
if errors.As(err, &multiErr) {
146-
for _, nestedErr := range multiErr.Unwrap() {
147-
if !onlyNotExistErrors(nestedErr) {
148-
return false
149-
}
150-
}
151-
152-
return true
153-
}
154-
155-
return errors.Is(err, fs.ErrNotExist)
115+
func isKubeconfigMissing(err error) bool {
116+
var missing *kubeconfig.MissingError
117+
return errors.As(err, &missing)
156118
}
157119

158120
// BuildSingleFileReport inspects a single file at path.
@@ -161,27 +123,16 @@ func onlyNotExistErrors(err error) bool {
161123
func BuildSingleFileReport(path string) (*Report, error) {
162124
kcExp, kcErr := kubeconfig.GetClientCertificateExpiration(path)
163125
if kcErr == nil {
164-
return &Report{
165-
Certs: []CertEntry{{
166-
Name: kubeconfigDisplayName(kcExp.File),
167-
Expires: kcExp.NotAfter,
168-
Authority: string(pki.CACertName),
169-
}},
170-
}, nil
126+
report := &Report{}
127+
appendKubeconfigEntry(report, kcExp.File, kcExp.NotAfter)
128+
129+
return report, nil
171130
}
172131

173132
certExp, certErr := pki.GetCertificateExpiration(path)
174133
if certErr == nil {
175134
report := &Report{}
176-
if certExp.IsCA {
177-
report.CAs = []CAEntry{{Name: pkiDisplayName(certExp.Name), Expires: certExp.NotAfter}}
178-
} else {
179-
report.Certs = []CertEntry{{
180-
Name: pkiDisplayName(certExp.Name),
181-
Expires: certExp.NotAfter,
182-
Authority: pkiDisplayName(string(certExp.Authority)),
183-
}}
184-
}
135+
appendPKIEntry(report, certExp.Name, certExp.NotAfter, certExp.IsCA, certExp.Authority)
185136

186137
return report, nil
187138
}
@@ -192,6 +143,33 @@ func BuildSingleFileReport(path string) (*Report, error) {
192143
)
193144
}
194145

146+
// appendPKIEntry appends a leaf cert or CA entry to report depending on IsCA.
147+
func appendPKIEntry(report *Report, name string, notAfter time.Time, isCA bool, authority pki.RootCertName) {
148+
if isCA {
149+
report.CAs = append(report.CAs, CAEntry{
150+
Name: pkiDisplayName(name),
151+
Expires: notAfter,
152+
})
153+
154+
return
155+
}
156+
157+
report.Certs = append(report.Certs, CertEntry{
158+
Name: pkiDisplayName(name),
159+
Expires: notAfter,
160+
Authority: pkiDisplayName(string(authority)),
161+
})
162+
}
163+
164+
// appendKubeconfigEntry appends a kubeconfig client cert entry. Always a leaf cert signed by the cluster CA.
165+
func appendKubeconfigEntry(report *Report, file kubeconfig.File, notAfter time.Time) {
166+
report.Certs = append(report.Certs, CertEntry{
167+
Name: kubeconfigDisplayName(file),
168+
Expires: notAfter,
169+
Authority: string(pki.CACertName),
170+
})
171+
}
172+
195173
// RenderReport writes the certificate expiration report to w in two sections:
196174
// leaf certificates followed by certificate authorities.
197175
func RenderReport(w io.Writer, report *Report) {

internal/tools/pki/certs/cmd/certs.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ func NewCommand() *cobra.Command {
3535
}
3636

3737
certsCmd.AddCommand(NewCheckCommand())
38+
certsCmd.AddCommand(NewRenewCommand())
3839

3940
return certsCmd
4041
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/*
2+
Copyright 2026 Flant JSC
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
package cmd
17+
18+
import (
19+
"fmt"
20+
"net"
21+
22+
"github.com/spf13/cobra"
23+
"k8s.io/kubectl/pkg/util/templates"
24+
25+
"github.com/deckhouse/deckhouse-cli/internal/tools/pki/certs"
26+
)
27+
28+
var renewLong = templates.LongDesc(`
29+
Renew control-plane certificates and kubeconfig client certificates.
30+
31+
Renewal is unconditional — certificates are re-signed regardless of their current
32+
expiration date.
33+
34+
All Subject/SAN/Usage fields are read from the existing certificate on disk.
35+
No cluster configuration is required — this command is designed for emergency
36+
recovery when the Kubernetes API is unavailable.
37+
38+
The signing CA must be present on disk and must not be expired. If the CA private
39+
key is absent (external CA), or the CA certificate has expired, the certificate
40+
is skipped; renewing leaf certificates against an expired CA is pointless because
41+
chain validation will fail. The command exits non-zero if any certificate was skipped.
42+
43+
After renewal, restart kube-apiserver, kube-controller-manager, kube-scheduler
44+
and etcd so that the new certificates take effect or reboot the node/kubelet.
45+
46+
© Flant JSC 2026`)
47+
48+
func NewRenewCommand() *cobra.Command {
49+
var (
50+
certsDir string
51+
kubeconfigDir string
52+
dryRun bool
53+
san string
54+
)
55+
56+
renewCmd := &cobra.Command{
57+
Use: "renew (all | PATH)",
58+
Short: "Renew control-plane certificates and kubeconfig client certificates",
59+
Long: renewLong,
60+
Args: cobra.ExactArgs(1),
61+
Example: " d8 tools pki certs renew all\n" +
62+
" d8 tools pki certs renew all --dry-run\n" +
63+
" d8 tools pki certs renew all --san 192.168.0.5\n" +
64+
" d8 tools pki certs renew /etc/kubernetes/pki/apiserver.crt\n" +
65+
" d8 tools pki certs renew /etc/kubernetes/admin.conf\n" +
66+
" d8 tools pki certs renew all --path /opt/k8s/pki --kubeconfig-dir /opt/k8s",
67+
RunE: func(cmd *cobra.Command, args []string) error {
68+
pathExplicit := cmd.Flags().Changed("path")
69+
70+
return certs.RunRenewSingle(cmd.OutOrStdout(), args[0], certsDir, kubeconfigDir, dryRun, pathExplicit)
71+
},
72+
}
73+
74+
allCmd := &cobra.Command{
75+
Use: "all",
76+
Short: "Renew all control-plane certificates and kubeconfig client certificates",
77+
Args: cobra.NoArgs,
78+
RunE: func(cmd *cobra.Command, _ []string) error {
79+
var extraIP net.IP
80+
if san != "" {
81+
extraIP = net.ParseIP(san)
82+
if extraIP == nil {
83+
return fmt.Errorf("--san accepts only an IP address, got %q", san)
84+
}
85+
}
86+
87+
return certs.RunRenewAll(cmd.OutOrStdout(), certsDir, kubeconfigDir, dryRun, extraIP)
88+
},
89+
}
90+
91+
addPathFlags := func(c *cobra.Command) {
92+
c.Flags().StringVar(&certsDir, "path", defaultCertificatesDir,
93+
"Directory containing the PKI certificates and CA files")
94+
c.Flags().StringVar(&kubeconfigDir, "kubeconfig-dir", "",
95+
"Directory containing kubeconfig files (defaults to the parent of --path)")
96+
c.Flags().BoolVar(&dryRun, "dry-run", false,
97+
"Run all without writing any files")
98+
}
99+
addPathFlags(renewCmd)
100+
addPathFlags(allCmd)
101+
102+
// --san is only for a full renewal
103+
allCmd.Flags().StringVar(&san, "san", "",
104+
"New IP SAN to add to serving certificates (e.g. the new master IP)")
105+
106+
renewCmd.AddCommand(allCmd)
107+
108+
return renewCmd
109+
}

0 commit comments

Comments
 (0)