diff --git a/src/k8s/go.mod b/src/k8s/go.mod index c62529e5d..6ec6e3204 100644 --- a/src/k8s/go.mod +++ b/src/k8s/go.mod @@ -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 diff --git a/src/k8s/go.sum b/src/k8s/go.sum index 9b2bb853e..ee150fa8a 100644 --- a/src/k8s/go.sum +++ b/src/k8s/go.sum @@ -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= diff --git a/src/k8s/pkg/k8sd/api/capi_certificates_expiry.go b/src/k8s/pkg/k8sd/api/capi_certificates_expiry.go new file mode 100644 index 000000000..c6bc96ac8 --- /dev/null +++ b/src/k8s/pkg/k8sd/api/capi_certificates_expiry.go @@ -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), + }) +} diff --git a/src/k8s/pkg/k8sd/api/endpoints.go b/src/k8s/pkg/k8sd/api/endpoints.go index e7e7af5d0..3bc56701e 100644 --- a/src/k8s/pkg/k8sd/api/endpoints.go +++ b/src/k8s/pkg/k8sd/api/endpoints.go @@ -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", diff --git a/tests/integration/tests/test_smoke.py b/tests/integration/tests/test_smoke.py index c5dd95c38..9d8dd6da1 100644 --- a/tests/integration/tests/test_smoke.py +++ b/tests/integration/tests/test_smoke.py @@ -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." @@ -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): diff --git a/tests/integration/tests/test_util/util.py b/tests/integration/tests/test_util/util.py index 754dc744b..0124b5f84 100644 --- a/tests/integration/tests/test_util/util.py +++ b/tests/integration/tests/test_util/util.py @@ -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 @@ -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