Skip to content

Commit

Permalink
Add certificate expiry endpoint (#683)
Browse files Browse the repository at this point in the history
  • Loading branch information
bschimke95 committed Sep 20, 2024
1 parent cfa7f99 commit d189816
Show file tree
Hide file tree
Showing 6 changed files with 95 additions and 4 deletions.
2 changes: 1 addition & 1 deletion src/k8s/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ go 1.22.6
require (
dario.cat/mergo v1.0.0
github.com/canonical/go-dqlite v1.22.0
github.com/canonical/k8s-snap-api v1.0.6
github.com/canonical/k8s-snap-api v1.0.7
github.com/canonical/lxd v0.0.0-20240822122218-e7b2a7a83230
github.com/canonical/microcluster/v3 v3.0.0-20240827143335-f7a4d3984970
github.com/go-logr/logr v1.4.2
Expand Down
4 changes: 2 additions & 2 deletions src/k8s/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0 h1:nvj0OLI3YqYXe
github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE=
github.com/canonical/go-dqlite v1.22.0 h1:DuJmfcREl4gkQJyvZzjl2GHFZROhbPyfdjDRQXpkOyw=
github.com/canonical/go-dqlite v1.22.0/go.mod h1:Uvy943N8R4CFUAs59A1NVaziWY9nJ686lScY7ywurfg=
github.com/canonical/k8s-snap-api v1.0.6 h1:hUJ59ol9romwUz82bYIumitobcuBQwKjWMnge1AhGzM=
github.com/canonical/k8s-snap-api v1.0.6/go.mod h1:LDPoIYCeYnfgOFrwVPJ/4edGU264w7BB7g0GsVi36AY=
github.com/canonical/k8s-snap-api v1.0.7 h1:40qz+9IcV90ZN/wTMuOraZcuqoyRHaJck1J3c7FcWrQ=
github.com/canonical/k8s-snap-api v1.0.7/go.mod h1:LDPoIYCeYnfgOFrwVPJ/4edGU264w7BB7g0GsVi36AY=
github.com/canonical/lxd v0.0.0-20240822122218-e7b2a7a83230 h1:YOqZ+/14OPZ+/TOXpRHIX3KLT0C+wZVpewKIwlGUmW0=
github.com/canonical/lxd v0.0.0-20240822122218-e7b2a7a83230/go.mod h1:YVGI7HStOKsV+cMyXWnJ7RaMPaeWtrkxyIPvGWbgACc=
github.com/canonical/microcluster/v3 v3.0.0-20240827143335-f7a4d3984970 h1:UrnpglbXELlxtufdk6DGDytu2JzyzuS3WTsOwPrkQLI=
Expand Down
50 changes: 50 additions & 0 deletions src/k8s/pkg/k8sd/api/capi_certificates_expiry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package api

import (
"fmt"
"net/http"
"time"

apiv1 "github.com/canonical/k8s-snap-api/api/v1"
databaseutil "github.com/canonical/k8s/pkg/k8sd/database/util"
pkiutil "github.com/canonical/k8s/pkg/utils/pki"
"github.com/canonical/lxd/lxd/response"
"github.com/canonical/microcluster/v3/state"
)

func (e *Endpoints) postCertificatesExpiry(s state.State, r *http.Request) response.Response {
config, err := databaseutil.GetClusterConfig(r.Context(), s)
if err != nil {
return response.InternalError(fmt.Errorf("failed to get cluster config: %w", err))
}

certificates := []string{
config.Certificates.GetCACert(),
config.Certificates.GetClientCACert(),
config.Certificates.GetAdminClientCert(),
config.Certificates.GetAPIServerKubeletClientCert(),
config.Certificates.GetFrontProxyCACert(),
}

var earliestExpiry time.Time
// Find the earliest expiry certificate
// They should all be about the same but better double-check this.
for _, cert := range certificates {
if cert == "" {
continue
}

cert, _, err := pkiutil.LoadCertificate(cert, "")
if err != nil {
return response.InternalError(fmt.Errorf("failed to load certificate: %w", err))
}

if earliestExpiry.IsZero() || cert.NotAfter.Before(earliestExpiry) {
earliestExpiry = cert.NotAfter
}
}

return response.SyncResponse(true, &apiv1.CertificatesExpiryResponse{
ExpiryDate: earliestExpiry.Format(time.RFC3339),
})
}
5 changes: 5 additions & 0 deletions src/k8s/pkg/k8sd/api/endpoints.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,11 @@ func (e *Endpoints) Endpoints() []rest.Endpoint {
Path: apiv1.ClusterAPIRemoveNodeRPC,
Post: rest.EndpointAction{Handler: e.postClusterRemove, AccessHandler: ValidateCAPIAuthTokenAccessHandler("capi-auth-token"), AllowUntrusted: true},
},
{
Name: "ClusterAPI/CertificatesExpiry",
Path: apiv1.ClusterAPICertificatesExpiryRPC,
Post: rest.EndpointAction{Handler: e.postCertificatesExpiry, AccessHandler: ValidateCAPIAuthTokenAccessHandler("capi-auth-token"), AllowUntrusted: true},
},
// Snap refreshes
{
Name: "Snap/Refresh",
Expand Down
27 changes: 26 additions & 1 deletion tests/integration/tests/test_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ def test_smoke(instances: List[harness.Instance]):
capture_output=True,
)
response = json.loads(resp.stdout.decode())

assert (
response["error_code"] == 0
), "Failed to generate join token using CAPI endpoints."
Expand All @@ -101,6 +100,32 @@ def test_smoke(instances: List[harness.Instance]):
metadata.get("token") is not None
), "Token not found in the generate-join-token response."

resp = instance.exec(
[
"curl",
"-XPOST",
"-H",
"Content-Type: application/json",
"-H",
"capi-auth-token: my-secret-token",
"--unix-socket",
"/var/snap/k8s/common/var/lib/k8sd/state/control.socket",
"http://localhost/1.0/x/capi/certificates-expiry",
],
capture_output=True,
)
response = json.loads(resp.stdout.decode())
assert (
response["error_code"] == 0
), "Failed to get certificate expiry using CAPI endpoints."
metadata = response.get("metadata")
assert (
metadata is not None
), "Metadata not found in the certificate expiry response."
assert util.is_valid_rfc3339(
metadata.get("expiry-date")
), "Token not found in the certificate expiry response."

def status_output_matches(p: subprocess.CompletedProcess) -> bool:
result_lines = p.stdout.decode().strip().split("\n")
if len(result_lines) != len(STATUS_PATTERNS):
Expand Down
11 changes: 11 additions & 0 deletions tests/integration/tests/test_util/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import re
import shlex
import subprocess
from datetime import datetime
from functools import partial
from pathlib import Path
from typing import Any, Callable, List, Mapping, Optional, Union
Expand Down Expand Up @@ -283,3 +284,13 @@ def get_global_unicast_ipv6(instance: harness.Instance, interface="eth0") -> str
if match:
return match.group(1)
return None


# Checks if a datastring is a valid RFC3339 date.
def is_valid_rfc3339(date_str):
try:
# Attempt to parse the string according to the RFC3339 format
datetime.strptime(date_str, "%Y-%m-%dT%H:%M:%S%z")
return True
except ValueError:
return False

0 comments on commit d189816

Please sign in to comment.