Skip to content

Commit 286fce4

Browse files
authored
ATHENS-9030 cert-manager integration to rotate cert by config (#89)
1 parent cd7ba3c commit 286fce4

File tree

7 files changed

+535
-9
lines changed

7 files changed

+535
-9
lines changed

deploy/example/example-app.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,5 @@ spec:
7676
volumeAttributes:
7777
csi.cert-manager.athenz.io/pod-subdomain: "my-subdomain"
7878
csi.cert-manager.athenz.io/pod-hostname: "my-hostname"
79+
csi.cert-manager.athenz.io/refresh-interval: "1h"
80+

internal/csi/driver/driver.go

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,12 +45,14 @@ import (
4545
)
4646

4747
const (
48-
cloudMetaEndpoint = "http://169.254.169.254:80"
49-
attrPodSubdomain = "csi.cert-manager.athenz.io/pod-subdomain"
50-
attrPodHostname = "csi.cert-manager.athenz.io/pod-hostname"
51-
attrPodService = "csi.cert-manager.athenz.io/pod-service"
52-
attrNSForDomain = "csi.cert-manager.athenz.io/use-namespace-for-domain"
53-
clusterZone = "cluster.local"
48+
cloudMetaEndpoint = "http://169.254.169.254:80"
49+
attrPodSubdomain = "csi.cert-manager.athenz.io/pod-subdomain"
50+
attrPodHostname = "csi.cert-manager.athenz.io/pod-hostname"
51+
attrPodService = "csi.cert-manager.athenz.io/pod-service"
52+
attrNSForDomain = "csi.cert-manager.athenz.io/use-namespace-for-domain"
53+
attrRefreshInterval = "csi.cert-manager.athenz.io/refresh-interval"
54+
clusterZone = "cluster.local"
55+
defaultRefreshInterval = 24 * time.Hour
5456
)
5557

5658
// Options holds the Options needed for the CSI driver.
@@ -462,9 +464,26 @@ func (d *Driver) writeKeypair(meta metadata.Metadata, key crypto.PrivateKey, cha
462464

463465
// Calculate the next issuance time before we write any data to file,
464466
// so if the write fails, we are not left in a bad state.
465-
nextIssuanceTime, err := calculateNextIssuanceTime(chain)
466-
if err != nil {
467-
return fmt.Errorf("failed to calculate next issuance time: %w", err)
467+
var nextIssuanceTime time.Time
468+
469+
// Check if a custom refresh interval is specified in the volume context
470+
refreshIntervalStr := meta.VolumeContext[attrRefreshInterval]
471+
if refreshIntervalStr != "" {
472+
// Use the custom refresh interval from volume context
473+
refreshInterval, err := parseRefreshInterval(refreshIntervalStr, defaultRefreshInterval)
474+
if err != nil {
475+
return fmt.Errorf("failed to parse refresh interval: %w", err)
476+
}
477+
nextIssuanceTime = calculateNextIssuanceTimeWithRefreshInterval(refreshInterval)
478+
d.log.Info("using custom refresh interval", "refreshInterval", refreshInterval.String(), "nextIssuanceTime", nextIssuanceTime.Format(time.RFC3339))
479+
} else {
480+
// Fall back to certificate-based calculation (2/3 of validity period)
481+
var err error
482+
nextIssuanceTime, err = calculateNextIssuanceTime(chain)
483+
if err != nil {
484+
return fmt.Errorf("failed to calculate next issuance time: %w", err)
485+
}
486+
d.log.Info("using certificate-based refresh interval", "nextIssuanceTime", nextIssuanceTime.Format(time.RFC3339))
468487
}
469488

470489
data := map[string][]byte{

internal/csi/driver/driver_test.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,15 @@ import (
2222
"crypto/elliptic"
2323
"crypto/rand"
2424
"testing"
25+
"time"
2526

2627
cmapi "github.com/cert-manager/cert-manager/pkg/apis/certmanager/v1"
2728
utilpki "github.com/cert-manager/cert-manager/pkg/util/pki"
2829
"github.com/cert-manager/csi-lib/metadata"
2930
"github.com/cert-manager/csi-lib/storage"
3031
"github.com/spiffe/go-spiffe/v2/svid/x509svid"
3132
"github.com/stretchr/testify/require"
33+
"k8s.io/klog/v2/klogr"
3234

3335
"github.com/AthenZ/csi-driver-athenz/internal/csi/rootca"
3436
)
@@ -68,6 +70,7 @@ func Test_writeKeyPair(t *testing.T) {
6870

6971
store := storage.NewMemoryFS()
7072
d := &Driver{
73+
log: klogr.New(),
7174
certFileName: "crt.pem",
7275
keyFileName: "key.pem",
7376
caFileName: "ca.pem",
@@ -297,3 +300,141 @@ func Test_generateRequestWithNamespaceDomainFalse(t *testing.T) {
297300
expectedSpiffeID := "spiffe://athenz.io/ns/sandbox/sa/athenz.example"
298301
require.Equal(t, expectedSpiffeID, certBundle.Annotations["csi.cert-manager.athenz.io/identity"])
299302
}
303+
304+
func Test_writeKeyPairWithCustomRefreshInterval(t *testing.T) {
305+
ctx, cancel := context.WithCancel(context.Background())
306+
t.Cleanup(func() {
307+
cancel()
308+
})
309+
310+
capk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
311+
require.NoError(t, err)
312+
313+
caTmpl, err := utilpki.CertificateTemplateFromCertificate(&cmapi.Certificate{Spec: cmapi.CertificateSpec{CommonName: "my-ca"}})
314+
require.NoError(t, err)
315+
316+
caPEM, ca, err := utilpki.SignCertificate(caTmpl, caTmpl, capk.Public(), capk)
317+
require.NoError(t, err)
318+
319+
leafpk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
320+
require.NoError(t, err)
321+
322+
leafTmpl, err := utilpki.CertificateTemplateFromCertificate(
323+
&cmapi.Certificate{
324+
Spec: cmapi.CertificateSpec{URIs: []string{"spiffe://athenz.io/ns/sandbox/sa/default"}},
325+
},
326+
)
327+
require.NoError(t, err)
328+
329+
leafPEM, _, err := utilpki.SignCertificate(leafTmpl, ca, leafpk.Public(), capk)
330+
require.NoError(t, err)
331+
332+
ch := make(chan []byte)
333+
rootCAs := rootca.NewMemory(ctx, ch)
334+
ch <- caPEM
335+
336+
store := storage.NewMemoryFS()
337+
d := &Driver{
338+
log: klogr.New(),
339+
certFileName: "crt.pem",
340+
keyFileName: "key.pem",
341+
caFileName: "ca.pem",
342+
rootCAs: rootCAs,
343+
store: store,
344+
}
345+
346+
// Test with custom refresh interval of 12 hours
347+
volumeContext := map[string]string{
348+
"csi.cert-manager.athenz.io/refresh-interval": "12h",
349+
}
350+
meta := metadata.Metadata{VolumeID: "vol-id-refresh", VolumeContext: volumeContext}
351+
352+
_, err = store.RegisterMetadata(meta)
353+
require.NoError(t, err)
354+
355+
beforeWrite := time.Now()
356+
err = d.writeKeypair(meta, leafpk, leafPEM, nil)
357+
require.NoError(t, err)
358+
afterWrite := time.Now()
359+
360+
files, err := store.ReadFiles("vol-id-refresh")
361+
require.NoError(t, err)
362+
363+
_, err = x509svid.Parse(files["crt.pem"], files["key.pem"])
364+
require.NoError(t, err)
365+
366+
// Verify the next issuance time is approximately 12 hours from now
367+
updatedMeta, err := store.ReadMetadata("vol-id-refresh")
368+
require.NoError(t, err)
369+
require.NotNil(t, updatedMeta.NextIssuanceTime)
370+
371+
expectedMin := beforeWrite.Add(12 * time.Hour)
372+
expectedMax := afterWrite.Add(12 * time.Hour)
373+
require.True(t, updatedMeta.NextIssuanceTime.After(expectedMin) || updatedMeta.NextIssuanceTime.Equal(expectedMin),
374+
"NextIssuanceTime should be >= now + 12h")
375+
require.True(t, updatedMeta.NextIssuanceTime.Before(expectedMax) || updatedMeta.NextIssuanceTime.Equal(expectedMax),
376+
"NextIssuanceTime should be <= now + 12h")
377+
}
378+
379+
func Test_writeKeyPairWithDefaultRefreshInterval(t *testing.T) {
380+
ctx, cancel := context.WithCancel(context.Background())
381+
t.Cleanup(func() {
382+
cancel()
383+
})
384+
385+
capk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
386+
require.NoError(t, err)
387+
388+
caTmpl, err := utilpki.CertificateTemplateFromCertificate(&cmapi.Certificate{Spec: cmapi.CertificateSpec{CommonName: "my-ca"}})
389+
require.NoError(t, err)
390+
391+
caPEM, ca, err := utilpki.SignCertificate(caTmpl, caTmpl, capk.Public(), capk)
392+
require.NoError(t, err)
393+
394+
leafpk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
395+
require.NoError(t, err)
396+
397+
leafTmpl, err := utilpki.CertificateTemplateFromCertificate(
398+
&cmapi.Certificate{
399+
Spec: cmapi.CertificateSpec{URIs: []string{"spiffe://athenz.io/ns/sandbox/sa/default"}},
400+
},
401+
)
402+
require.NoError(t, err)
403+
404+
leafPEM, _, err := utilpki.SignCertificate(leafTmpl, ca, leafpk.Public(), capk)
405+
require.NoError(t, err)
406+
407+
ch := make(chan []byte)
408+
rootCAs := rootca.NewMemory(ctx, ch)
409+
ch <- caPEM
410+
411+
store := storage.NewMemoryFS()
412+
d := &Driver{
413+
log: klogr.New(),
414+
certFileName: "crt.pem",
415+
keyFileName: "key.pem",
416+
caFileName: "ca.pem",
417+
rootCAs: rootCAs,
418+
store: store,
419+
}
420+
421+
// Test without custom refresh interval (should use certificate-based calculation)
422+
meta := metadata.Metadata{VolumeID: "vol-id-default"}
423+
424+
_, err = store.RegisterMetadata(meta)
425+
require.NoError(t, err)
426+
427+
err = d.writeKeypair(meta, leafpk, leafPEM, nil)
428+
require.NoError(t, err)
429+
430+
files, err := store.ReadFiles("vol-id-default")
431+
require.NoError(t, err)
432+
433+
_, err = x509svid.Parse(files["crt.pem"], files["key.pem"])
434+
require.NoError(t, err)
435+
436+
// Verify the next issuance time was set (based on certificate validity)
437+
updatedMeta, err := store.ReadMetadata("vol-id-default")
438+
require.NoError(t, err)
439+
require.NotNil(t, updatedMeta.NextIssuanceTime)
440+
}

internal/csi/driver/util.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,31 @@ func getDomainFromNamespaceAnnotations(annotations map[string]string) string {
126126
}
127127
return ""
128128
}
129+
130+
// parseRefreshInterval parses a refresh interval string in hours (e.g., "24h", "12h", "1h")
131+
// and returns the duration. If the string is empty, it returns the default refresh interval.
132+
// If the string is invalid or less than 1 hour, it returns an error.
133+
func parseRefreshInterval(intervalStr string, defaultInterval time.Duration) (time.Duration, error) {
134+
if intervalStr == "" {
135+
return defaultInterval, nil
136+
}
137+
138+
// Parse the hours value (e.g., "24h" -> 24 hours)
139+
duration, err := time.ParseDuration(intervalStr)
140+
if err != nil {
141+
return 0, fmt.Errorf("invalid refresh interval %q: %w", intervalStr, err)
142+
}
143+
144+
// Ensure the refresh interval is at least 1 hour
145+
if duration < time.Hour {
146+
return 0, fmt.Errorf("refresh interval %q must be at least 1 hour", intervalStr)
147+
}
148+
149+
return duration, nil
150+
}
151+
152+
// calculateNextIssuanceTimeWithRefreshInterval returns the time when the certificate
153+
// should be renewed based on the specified refresh interval from the current time.
154+
func calculateNextIssuanceTimeWithRefreshInterval(refreshInterval time.Duration) time.Time {
155+
return time.Now().Add(refreshInterval)
156+
}

internal/csi/driver/util_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package driver
1818

1919
import (
2020
"testing"
21+
"time"
2122

2223
"github.com/stretchr/testify/assert"
2324
"github.com/stretchr/testify/require"
@@ -185,3 +186,92 @@ func Test_appendUri(t *testing.T) {
185186
})
186187
}
187188
}
189+
190+
func Test_parseRefreshInterval(t *testing.T) {
191+
defaultInterval := 24 * time.Hour
192+
193+
tests := []struct {
194+
name string
195+
intervalStr string
196+
defaultInterval time.Duration
197+
expected time.Duration
198+
expectError bool
199+
}{
200+
{
201+
name: "valid 1h interval (minimum)",
202+
intervalStr: "1h",
203+
defaultInterval: defaultInterval,
204+
expected: 1 * time.Hour,
205+
expectError: false,
206+
},
207+
{
208+
name: "valid 72h interval",
209+
intervalStr: "72h",
210+
defaultInterval: defaultInterval,
211+
expected: 72 * time.Hour,
212+
expectError: false,
213+
},
214+
{
215+
name: "empty interval returns default",
216+
intervalStr: "",
217+
defaultInterval: defaultInterval,
218+
expected: defaultInterval,
219+
expectError: false,
220+
},
221+
{
222+
name: "invalid string returns error",
223+
intervalStr: "interval",
224+
defaultInterval: defaultInterval,
225+
expectError: true,
226+
},
227+
}
228+
229+
for _, tt := range tests {
230+
t.Run(tt.name, func(t *testing.T) {
231+
result, err := parseRefreshInterval(tt.intervalStr, tt.defaultInterval)
232+
if tt.expectError {
233+
assert.Error(t, err)
234+
} else {
235+
assert.NoError(t, err)
236+
assert.Equal(t, tt.expected, result)
237+
}
238+
})
239+
}
240+
}
241+
242+
func Test_calculateNextIssuanceTimeWithRefreshInterval(t *testing.T) {
243+
tests := []struct {
244+
name string
245+
refreshInterval time.Duration
246+
}{
247+
{
248+
name: "24h refresh interval",
249+
refreshInterval: 24 * time.Hour,
250+
},
251+
{
252+
name: "1h refresh interval",
253+
refreshInterval: 1 * time.Hour,
254+
},
255+
{
256+
name: "12h refresh interval",
257+
refreshInterval: 12 * time.Hour,
258+
},
259+
}
260+
261+
for _, tt := range tests {
262+
t.Run(tt.name, func(t *testing.T) {
263+
before := time.Now()
264+
result := calculateNextIssuanceTimeWithRefreshInterval(tt.refreshInterval)
265+
after := time.Now()
266+
267+
// The result should be approximately now + refreshInterval
268+
expectedMin := before.Add(tt.refreshInterval)
269+
expectedMax := after.Add(tt.refreshInterval)
270+
271+
assert.True(t, result.After(expectedMin) || result.Equal(expectedMin),
272+
"result %v should be >= %v", result, expectedMin)
273+
assert.True(t, result.Before(expectedMax) || result.Equal(expectedMax),
274+
"result %v should be <= %v", result, expectedMax)
275+
})
276+
}
277+
}

test/e2e/suite/import.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,5 @@ import (
2121
_ "github.com/AthenZ/csi-driver-athenz/test/e2e/suite/carotation"
2222
_ "github.com/AthenZ/csi-driver-athenz/test/e2e/suite/fsgroup"
2323
_ "github.com/AthenZ/csi-driver-athenz/test/e2e/suite/namespacedomain"
24+
_ "github.com/AthenZ/csi-driver-athenz/test/e2e/suite/refreshinterval"
2425
)

0 commit comments

Comments
 (0)