Skip to content

Commit c8b5cec

Browse files
committed
certsyncpod: Swap secret/cm directories atomically
Currently it can happen that cert-syncer replaces some of the secret/configmap files successfully and then fails. This can lead to problems when these are e.g. TLS cert/key files and the directory gets inconsistent. This may seem transient, but when cert-syncer is terminated in the middle, it can later fail to start as the whole kube-apiserver gets into a crash loop. This introduces a new staticpod.SwapDirectoriesAtomic, which uses unix.Renameat2 with RENAME_EXCHANGE flag set.
1 parent 4139e55 commit c8b5cec

File tree

8 files changed

+978
-43
lines changed

8 files changed

+978
-43
lines changed

pkg/operator/staticpod/certsyncpod/certsync_controller.go

Lines changed: 100 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package certsyncpod
22

33
import (
4+
"bytes"
45
"context"
6+
"fmt"
57
"os"
68
"path/filepath"
79
"reflect"
@@ -115,7 +117,7 @@ func (c *CertSyncController) sync(ctx context.Context, syncCtx factory.SyncConte
115117

116118
contentDir := getConfigMapDir(c.destinationDir, cm.Name)
117119

118-
data := map[string]string{}
120+
data := make(map[string]string, len(configMap.Data))
119121
for filename := range configMap.Data {
120122
fullFilename := filepath.Join(contentDir, filename)
121123

@@ -152,27 +154,7 @@ func (c *CertSyncController) sync(ctx context.Context, syncCtx factory.SyncConte
152154
continue
153155
}
154156

155-
klog.Infof("Creating directory %q ...", contentDir)
156-
if err := os.MkdirAll(contentDir, 0755); err != nil && !os.IsExist(err) {
157-
c.eventRecorder.Warningf("CertificateUpdateFailed", "Failed creating directory for configmap: %s/%s: %v", configMap.Namespace, configMap.Name, err)
158-
errors = append(errors, err)
159-
continue
160-
}
161-
for filename, content := range configMap.Data {
162-
fullFilename := filepath.Join(contentDir, filename)
163-
// if the existing is the same, do nothing
164-
if reflect.DeepEqual(data[fullFilename], content) {
165-
continue
166-
}
167-
168-
klog.Infof("Writing configmap manifest %q ...", fullFilename)
169-
if err := staticpod.WriteFileAtomic([]byte(content), 0644, fullFilename); err != nil {
170-
c.eventRecorder.Warningf("CertificateUpdateFailed", "Failed writing file for configmap: %s/%s: %v", configMap.Namespace, configMap.Name, err)
171-
errors = append(errors, err)
172-
continue
173-
}
174-
}
175-
c.eventRecorder.Eventf("CertificateUpdated", "Wrote updated configmap: %s/%s", configMap.Namespace, configMap.Name)
157+
errors = append(errors, writeFiles(&realFS, c.eventRecorder, "configmap", configMap.ObjectMeta, contentDir, data, 0644))
176158
}
177159

178160
klog.Infof("Syncing secrets: %v", c.secrets)
@@ -220,7 +202,7 @@ func (c *CertSyncController) sync(ctx context.Context, syncCtx factory.SyncConte
220202

221203
contentDir := getSecretDir(c.destinationDir, s.Name)
222204

223-
data := map[string][]byte{}
205+
data := make(map[string][]byte, len(secret.Data))
224206
for filename := range secret.Data {
225207
fullFilename := filepath.Join(contentDir, filename)
226208

@@ -257,29 +239,104 @@ func (c *CertSyncController) sync(ctx context.Context, syncCtx factory.SyncConte
257239
continue
258240
}
259241

260-
klog.Infof("Creating directory %q ...", contentDir)
261-
if err := os.MkdirAll(contentDir, 0755); err != nil && !os.IsExist(err) {
262-
c.eventRecorder.Warningf("CertificateUpdateFailed", "Failed creating directory for secret: %s/%s: %v", secret.Namespace, secret.Name, err)
263-
errors = append(errors, err)
264-
continue
242+
errors = append(errors, writeFiles(&realFS, c.eventRecorder, "secret", secret.ObjectMeta, contentDir, data, 0600))
243+
}
244+
return utilerrors.NewAggregate(errors)
245+
}
246+
247+
type fileSystem struct {
248+
MkdirAll func(path string, perm os.FileMode) error
249+
MkdirTemp func(dir, pattern string) (string, error)
250+
RemoveAll func(path string) error
251+
WriteFile func(name string, data []byte, perm os.FileMode) error
252+
SwapDirectoriesAtomic func(dirA, dirB string) error
253+
HashDirectory func(path string) ([]byte, error)
254+
}
255+
256+
var realFS = fileSystem{
257+
MkdirAll: os.MkdirAll,
258+
MkdirTemp: os.MkdirTemp,
259+
RemoveAll: os.RemoveAll,
260+
WriteFile: os.WriteFile,
261+
SwapDirectoriesAtomic: staticpod.SwapDirectoriesAtomic,
262+
HashDirectory: hashDirectory,
263+
}
264+
265+
func writeFiles[C string | []byte](
266+
fs *fileSystem, eventRecorder events.Recorder,
267+
typeName string, o metav1.ObjectMeta,
268+
targetDir string, files map[string]C, filePerm os.FileMode,
269+
) error {
270+
// We are doing to prepare a tmp directory and write all files into that directory.
271+
// Then we are going to atomically swap the new data directory for the old one.
272+
// This is currently implemented as really atomically exchanging directories.
273+
//
274+
// The same goal of atomic swap could be implemented using symlinks much like AtomicWriter does in
275+
// https://github.com/kubernetes/kubernetes/blob/v1.34.0/pkg/volume/util/atomic_writer.go#L58
276+
// The reason we don't do that is that we already have a directory populated and watched that needs to we swapped,
277+
// in other words, it's for compatibility reasons. And if we were to migrate to the symlink approach,
278+
// we would anyway need to atomically turn the current data directory to a symlink.
279+
// This would all just increase complexity and require atomic swap on the OS level anyway.
280+
281+
// In case the target directory does not exist, create it so that the directory not existing is not a special case.
282+
klog.Infof("Ensuring content directory %q exists ...", targetDir)
283+
if err := fs.MkdirAll(targetDir, 0755); err != nil && !os.IsExist(err) {
284+
eventRecorder.Warningf("CertificateUpdateFailed", "Failed creating content directory for %s: %s/%s: %v", typeName, o.Namespace, o.Name, err)
285+
return err
286+
}
287+
288+
// We make sure the target directory is unchanged while we prepare the switch by computing the directory hash.
289+
klog.Infof("Hashing current content directory %q ...", targetDir)
290+
targetDirHashBefore, err := fs.HashDirectory(targetDir)
291+
if err != nil {
292+
eventRecorder.Warningf("CertificateUpdateFailed", "Failed to hash current content directory for %s: %s/%s: %v", typeName, o.Namespace, o.Name, err)
293+
return err
294+
}
295+
296+
// Create a tmp source directory to be swapped.
297+
klog.Infof("Creating temporary directory to swap for %q ...", targetDir)
298+
tmpDir, err := fs.MkdirTemp(filepath.Dir(targetDir), filepath.Base(targetDir)+"-*")
299+
if err != nil {
300+
eventRecorder.Warningf("CertificateUpdateFailed", "Failed to create temporary directory for %s: %s/%s: %v", typeName, o.Namespace, o.Name, err)
301+
return err
302+
}
303+
defer func() {
304+
if err := fs.RemoveAll(tmpDir); err != nil {
305+
klog.Errorf("Failed to remove temporary directory %q during cleanup: %v", tmpDir, err)
265306
}
266-
for filename, content := range secret.Data {
267-
// TODO fix permissions
268-
fullFilename := filepath.Join(contentDir, filename)
269-
// if the existing is the same, do nothing
270-
if reflect.DeepEqual(data[fullFilename], content) {
271-
continue
272-
}
307+
}()
273308

274-
klog.Infof("Writing secret manifest %q ...", fullFilename)
275-
if err := staticpod.WriteFileAtomic(content, 0600, fullFilename); err != nil {
276-
c.eventRecorder.Warningf("CertificateUpdateFailed", "Failed writing file for secret: %s/%s: %v", secret.Namespace, secret.Name, err)
277-
errors = append(errors, err)
278-
continue
279-
}
309+
// Populate the tmp directory with files.
310+
for filename, content := range files {
311+
fullFilename := filepath.Join(tmpDir, filename)
312+
klog.Infof("Writing %s manifest %q ...", typeName, fullFilename)
313+
314+
if err := fs.WriteFile(fullFilename, []byte(content), filePerm); err != nil {
315+
eventRecorder.Warningf("CertificateUpdateFailed", "Failed writing file for %s: %s/%s: %v", typeName, o.Namespace, o.Name, err)
316+
return err
280317
}
281-
c.eventRecorder.Eventf("CertificateUpdated", "Wrote updated secret: %s/%s", secret.Namespace, secret.Name)
282318
}
283319

284-
return utilerrors.NewAggregate(errors)
320+
// Make sure the target directory hasn't changed in the meantime.
321+
klog.Infof("Hashing current content directory %q again and ensuring it's unchanged ...", targetDir)
322+
targetDirHashAfter, err := fs.HashDirectory(targetDir)
323+
if err != nil {
324+
eventRecorder.Warningf("CertificateUpdateFailed", "Failed to hash current content directory for %s: %s/%s: %v", typeName, o.Namespace, o.Name, err)
325+
return err
326+
}
327+
if !bytes.Equal(targetDirHashBefore, targetDirHashAfter) {
328+
eventRecorder.Warningf("CertificateUpdateFailed", "Content directory changed while preparing to apply an update for %s: %s/%s", typeName, o.Namespace, o.Name)
329+
klog.Warningf("Content directory changed while preparing to apply an update: %q", targetDir)
330+
return fmt.Errorf("content directory changed while preparing to apply an update: %q", targetDir)
331+
}
332+
333+
// Swap directories atomically.
334+
klog.Infof("Atomically swapping target directory %q with temporary directory %q for %s: %s/%s ...", targetDir, tmpDir, typeName, o.Namespace, o.Name)
335+
if err := fs.SwapDirectoriesAtomic(targetDir, tmpDir); err != nil {
336+
eventRecorder.Warningf("CertificateUpdateFailed", "Failed to swap target directory %q with temporary directory %q for %s: %s/%s: %v", targetDir, tmpDir, typeName, o.Namespace, o.Name, err)
337+
return err
338+
}
339+
340+
eventRecorder.Eventf("CertificateUpdated", "Wrote updated %s: %s/%s", typeName, o.Namespace, o.Name)
341+
return nil
285342
}
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
//go:build linux
2+
3+
package certsyncpod
4+
5+
import (
6+
"bytes"
7+
"context"
8+
"crypto/ecdsa"
9+
"crypto/elliptic"
10+
"crypto/rand"
11+
"crypto/x509"
12+
"crypto/x509/pkix"
13+
"encoding/pem"
14+
"math/big"
15+
"os"
16+
"path/filepath"
17+
"sync"
18+
"testing"
19+
"time"
20+
21+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
22+
"k8s.io/apimachinery/pkg/util/wait"
23+
"k8s.io/apiserver/pkg/server/dynamiccertificates"
24+
25+
"github.com/openshift/library-go/pkg/operator/events/eventstesting"
26+
)
27+
28+
// TestDynamicCertificates makes sure the receiving side of certificate synchronization works as expected.
29+
// It reads and watches the certificates being synchronized in the same way as e.g. kube-apiserver,
30+
// the very same libraries are being used.
31+
func TestDynamicCertificates(t *testing.T) {
32+
const typeName = "secret"
33+
om := metav1.ObjectMeta{
34+
Namespace: "openshift-kube-apiserver",
35+
Name: "s1",
36+
}
37+
38+
// Generate all necessary keypairs.
39+
tlsCert, tlsKey := generateKeypair(t)
40+
tlsCertUpdated, tlsKeyUpdated := generateKeypair(t)
41+
42+
// Write the keypair into a secret directory.
43+
secretDir := filepath.Join(t.TempDir(), "secrets", om.Name)
44+
certFile := filepath.Join(secretDir, "tls.crt")
45+
keyFile := filepath.Join(secretDir, "tls.key")
46+
47+
if err := os.MkdirAll(secretDir, 0700); err != nil {
48+
t.Fatalf("Failed to create secret directory %q: %v", secretDir, err)
49+
}
50+
if err := os.WriteFile(certFile, tlsCert, 0600); err != nil {
51+
t.Fatalf("Failed to write TLS certificate into %q: %v", certFile, err)
52+
}
53+
if err := os.WriteFile(keyFile, tlsKey, 0600); err != nil {
54+
t.Fatalf("Failed to write TLS key into %q: %v", keyFile, err)
55+
}
56+
57+
// Start the watcher.
58+
// This reads the keypair synchronously so the initial state is loaded here.
59+
dc, err := dynamiccertificates.NewDynamicServingContentFromFiles("localhost TLS", certFile, keyFile)
60+
if err != nil {
61+
t.Fatalf("Failed to init dynamic certificate: %v", err)
62+
}
63+
64+
// Check the initial keypair is loaded.
65+
cert, key := dc.CurrentCertKeyContent()
66+
if !bytes.Equal(cert, tlsCert) || !bytes.Equal(key, tlsKey) {
67+
t.Fatal("Unexpected initial keypair loaded")
68+
}
69+
70+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
71+
var wg sync.WaitGroup
72+
wg.Add(1)
73+
go func() {
74+
defer wg.Done()
75+
dc.Run(ctx, 1)
76+
}()
77+
defer wg.Wait()
78+
defer cancel()
79+
80+
// Poll until update detected.
81+
recorder := eventstesting.NewTestingEventRecorder(t)
82+
files := map[string][]byte{
83+
"tls.crt": tlsCertUpdated,
84+
"tls.key": tlsKeyUpdated,
85+
}
86+
err = wait.PollUntilContextCancel(ctx, 250*time.Millisecond, true, func(ctx context.Context) (bool, error) {
87+
// Replace the secret directory.
88+
if err := writeFiles(&realFS, recorder, typeName, om, secretDir, files, 0600); err != nil {
89+
t.Errorf("Failed to write files: %v", err)
90+
return false, err
91+
}
92+
93+
// Check the loaded content matches.
94+
// This is most probably updated based on write in a previous Poll invocation.
95+
cert, key := dc.CurrentCertKeyContent()
96+
return bytes.Equal(cert, tlsCertUpdated) && bytes.Equal(key, tlsKeyUpdated), nil
97+
})
98+
if err != nil {
99+
t.Fatalf("Failed to wait for dynamic certificate: %v", err)
100+
}
101+
}
102+
103+
// generateKeypair returns (cert, key).
104+
func generateKeypair(t *testing.T) ([]byte, []byte) {
105+
t.Helper()
106+
107+
privateKey, err := ecdsa.GenerateKey(elliptic.P224(), rand.Reader)
108+
if err != nil {
109+
t.Fatalf("Failed to generate TLS key: %v", err)
110+
}
111+
112+
notBefore := time.Now()
113+
notAfter := notBefore.Add(1 * time.Hour)
114+
115+
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
116+
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
117+
if err != nil {
118+
t.Fatalf("Failed to generate serial number for TLS keypair: %v", err)
119+
}
120+
121+
template := x509.Certificate{
122+
SerialNumber: serialNumber,
123+
Subject: pkix.Name{
124+
Organization: []string{"Example Org"},
125+
},
126+
NotBefore: notBefore,
127+
NotAfter: notAfter,
128+
KeyUsage: x509.KeyUsageDigitalSignature,
129+
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
130+
BasicConstraintsValid: true,
131+
DNSNames: []string{"example.com"},
132+
}
133+
134+
publicKeyBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
135+
if err != nil {
136+
t.Fatalf("Failed to create TLS certificate: %v", err)
137+
}
138+
139+
var certOut bytes.Buffer
140+
if err := pem.Encode(&certOut, &pem.Block{Type: "CERTIFICATE", Bytes: publicKeyBytes}); err != nil {
141+
t.Fatalf("Failed to write certificate PEM: %v", err)
142+
}
143+
144+
privateKeyBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
145+
if err != nil {
146+
t.Fatalf("Unable to marshal private key: %v", err)
147+
}
148+
149+
var keyOut bytes.Buffer
150+
if err := pem.Encode(&keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privateKeyBytes}); err != nil {
151+
t.Fatalf("Failed to write certificate PEM: %v", err)
152+
}
153+
154+
return certOut.Bytes(), keyOut.Bytes()
155+
}

0 commit comments

Comments
 (0)