From d0ffb31fea2240065973910ded7f5ae36e427b29 Mon Sep 17 00:00:00 2001 From: James Rasell Date: Tue, 26 Aug 2025 13:49:06 +0100 Subject: [PATCH] e2e: Add Client Identity get and renew tests. (#26632) --- e2e/client_identity/client_identity_test.go | 141 ++++++++++++++++++++ e2e/client_identity/doc.go | 10 ++ 2 files changed, 151 insertions(+) create mode 100644 e2e/client_identity/client_identity_test.go create mode 100644 e2e/client_identity/doc.go diff --git a/e2e/client_identity/client_identity_test.go b/e2e/client_identity/client_identity_test.go new file mode 100644 index 000000000..ffe109aa4 --- /dev/null +++ b/e2e/client_identity/client_identity_test.go @@ -0,0 +1,141 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package clientidentity + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/hashicorp/nomad/api" + "github.com/hashicorp/nomad/e2e/e2eutil" + "github.com/shoenig/test/must" +) + +func TestClientIdentity(t *testing.T) { + nomad := e2eutil.NomadClient(t) + + e2eutil.WaitForLeader(t, nomad) + e2eutil.WaitForNodesReady(t, nomad, 1) + + t.Run("testClientIdentity", testClientIdentity) +} + +func testClientIdentity(t *testing.T) { + + nomad := e2eutil.NomadClient(t) + + // Get the list of regions which should include a single entry, so we can + // use it to validate the node identity claims. + regionList, err := nomad.Regions().List() + must.NoError(t, err) + must.Len(t, 1, regionList) + + nodeList, _, err := nomad.Nodes().List(nil) + must.NoError(t, err) + must.Greater(t, 0, len(nodeList)) + + // Create a context with a timeout to avoid waiting indefinitely for the + // client to renew its identity. 40 seconds should be more than enough no + // matter how long each client has until its next heartbeat. + ctx, cancel := context.WithTimeout(t.Context(), 40*time.Second) + defer cancel() + + // Use a wait group which allows us to trigger the identity renewal for all + // nodes in parallel, speeding up the test completion time. + wg := new(sync.WaitGroup) + + for _, node := range nodeList { + + // Perform an initial identity get request and validate the claims + // before asking the client to renew its identity. + nodeIdentityResp, err := nomad.Nodes().Identity().Get( + &api.NodeIdentityGetRequest{ + NodeID: node.ID, + }, + nil) + must.NoError(t, err) + must.MapNotEmpty(t, nodeIdentityResp.Claims) + + assertNodeIdentityClaims(t, regionList[0], node, nodeIdentityResp.Claims) + + wg.Add(1) + + go func(wg *sync.WaitGroup) { + defer wg.Done() + testClientIdentityRenew(t, ctx, nomad, node, nodeIdentityResp.Claims["jti"].(string), regionList[0]) + }(wg) + } + + // Wait for all the go routines to complete. + wg.Wait() +} + +func testClientIdentityRenew( + t *testing.T, + ctx context.Context, + client *api.Client, + nodeStub *api.NodeListStub, + jwtID, region string) { + + renewResp, err := client.Nodes().Identity().Renew( + &api.NodeIdentityRenewRequest{NodeID: nodeStub.ID}, + nil, + ) + must.NoError(t, err) + must.NotNil(t, renewResp) + + // Use a ticker, so we can poll the client to view it's identity claims + // until it has renewed. + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + + // Wait for the client to renew its identity, or the context to timeout. If + // the context times out then the test will fail and indicate the node did + // not renew its identity within the expected time. + for { + select { + case <-ctx.Done(): + t.Fatalf("timeout reached waiting for client %q to renew identity", nodeStub.ID) + return + case <-ticker.C: + nodeIdentityResp, err := client.Nodes().Identity().Get( + &api.NodeIdentityGetRequest{NodeID: nodeStub.ID}, + nil) + must.NoError(t, err) + must.MapNotEmpty(t, nodeIdentityResp.Claims) + + // If the jwtID has not changed, then continue to wait until the + // client performs its heartbeat to refresh the identity. + if nodeIdentityResp.Claims["jti"].(string) == jwtID { + continue + } + + assertNodeIdentityClaims(t, region, nodeStub, nodeIdentityResp.Claims) + return + } + } +} + +func assertNodeIdentityClaims(t *testing.T, region string, node *api.NodeListStub, claims map[string]any) { + + // Assert the Nomad node specific claims. + must.Eq(t, node.ID, claims["nomad_node_id"].(string)) + must.Eq(t, node.Datacenter, claims["nomad_node_datacenter"].(string)) + must.Eq(t, node.NodePool, claims["nomad_node_pool"].(string)) + + // Check the Nomad specific generic claims. + must.Eq(t, "nomadproject.io", claims["aud"].(string)) + must.Eq(t, fmt.Sprintf("node:%s:%s:%s:default", region, node.NodePool, node.ID), claims["sub"].(string)) + + // Check the standard claims that should be present. It's tricky to perform + // exact matches on these as they are time based or generated values, so we + // just check they are present and of the correct type. + must.NotEq(t, "", claims["jti"].(string)) + must.Greater(t, 0, claims["iat"].(float64)) + must.Greater(t, 0, claims["nbf"].(float64)) + must.Greater(t, 0, claims["exp"].(float64)) +} diff --git a/e2e/client_identity/doc.go b/e2e/client_identity/doc.go new file mode 100644 index 000000000..1b2d894f1 --- /dev/null +++ b/e2e/client_identity/doc.go @@ -0,0 +1,10 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +// Package clientidentity provides end-to-end tests for Nomad's client identity +// feature. This does not involve running jobs, but instead focuses on the +// identity API to query and force renewals of client identity claims. +// +// In order to run this test suite only, from the e2e directory you can trigger +// go test -v -run '^TestClientIdentity$' ./client_identity +package clientidentity