From 20251b675dcffc8839549933b4cfd7e184c72268 Mon Sep 17 00:00:00 2001 From: James Rasell Date: Fri, 25 Jul 2025 14:28:45 +0200 Subject: [PATCH] Add CLI and API components for creating node introduction tokens via ACL endpoint. (#26332) --- api/acl.go | 51 +++++++++++ api/acl_test.go | 35 ++++++++ command/commands.go | 10 +++ command/node_intro.go | 34 +++++++ command/node_intro_create.go | 145 ++++++++++++++++++++++++++++++ command/node_intro_create_test.go | 115 ++++++++++++++++++++++++ command/node_intro_test.go | 16 ++++ 7 files changed, 406 insertions(+) create mode 100644 command/node_intro.go create mode 100644 command/node_intro_create.go create mode 100644 command/node_intro_create_test.go create mode 100644 command/node_intro_test.go diff --git a/api/acl.go b/api/acl.go index 74f71f605..edaa6a5a6 100644 --- a/api/acl.go +++ b/api/acl.go @@ -1246,3 +1246,54 @@ type ACLLoginRequest struct { // LoginToken is the token used to login. This is a required parameter. LoginToken string } + +// ACLIdentity is used to query the ACL identity endpoints. +type ACLIdentity struct { + client *Client +} + +// ACLIdentity returns a new handle on the ACL identity API client. +func (c *Client) ACLIdentity() *ACLIdentity { + return &ACLIdentity{client: c} +} + +// CreateClientIntroductionToken is the API endpoint used to generate a JWT +// token to be used for introducing a new client node into the cluster. +func (a *ACLIdentity) CreateClientIntroductionToken( + req *ACLIdentityClientIntroductionTokenRequest, + writeOpts *WriteOptions) (*ACLIdentityClientIntroductionTokenResponse, *WriteMeta, error) { + + var resp ACLIdentityClientIntroductionTokenResponse + wm, err := a.client.put("/v1/acl/identity/client-introduction-token", req, &resp, writeOpts) + if err != nil { + return nil, nil, err + } + return &resp, wm, nil +} + +// ACLIdentityClientIntroductionTokenRequest is the request object used within +// the ACL client introduction API request. This is used to generate a JWT token +// that can be used to register a new client node into the cluster. +type ACLIdentityClientIntroductionTokenRequest struct { + + // TTL is the requested TTL for the identity token. This is an optional + // parameter and if not set, defaults to the server defined default TTL. + TTL time.Duration + + // NodeName is the name of the node that is being introduced. This is added + // to the token as a claim when present, but is optional. + NodeName string + + // NodePool is the name of the node pool that this node belongs to. This is + // an optional parameter, and if not set, defaults to "default". + NodePool string +} + +// ACLIdentityClientIntroductionTokenResponse is the response object used within +// the ACL client introduction HTTP endpoint. +type ACLIdentityClientIntroductionTokenResponse struct { + + // JWT is the signed identity token that can be used as an introduction + // token for a new client node to register with the Nomad cluster. + JWT string +} diff --git a/api/acl_test.go b/api/acl_test.go index 636ebe01f..1e2acbf8c 100644 --- a/api/acl_test.go +++ b/api/acl_test.go @@ -742,3 +742,38 @@ func TestACLBindingRules(t *testing.T) { must.Len(t, 0, aclBindingRulesListResp) assertQueryMeta(t, queryMeta) } + +// TestACLIdentity_CreateClientIntroductionToken perform some basic tests of the +// CreateClientIntroductionToken method. It does not test that the response +// contains a valid JWT, as that would require bringing in the JWT library into +// the API. Instead, we rely on HTTP and RPC tests to ensure that. +func TestACLIdentity_CreateClientIntroductionToken(t *testing.T) { + testutil.Parallel(t) + + testClient, testServer, _ := makeACLClient(t, nil, nil) + defer testServer.Stop() + + t.Run("empty_req_params", func(t *testing.T) { + + req := ACLIdentityClientIntroductionTokenRequest{} + + resp, _, err := testClient.ACLIdentity().CreateClientIntroductionToken(&req, nil) + must.NoError(t, err) + must.NotNil(t, resp) + must.NotEq(t, "", resp.JWT) + }) + + t.Run("full_req_params", func(t *testing.T) { + + req := ACLIdentityClientIntroductionTokenRequest{ + TTL: time.Second, + NodePool: NodePoolDefault, + NodeName: "test-node", + } + + resp, _, err := testClient.ACLIdentity().CreateClientIntroductionToken(&req, nil) + must.NoError(t, err) + must.NotNil(t, resp) + must.NotEq(t, "", resp.JWT) + }) +} diff --git a/command/commands.go b/command/commands.go index dbcd37bd6..642c18d96 100644 --- a/command/commands.go +++ b/command/commands.go @@ -644,6 +644,16 @@ func Commands(metaPtr *Meta, agentUi cli.Ui) map[string]cli.CommandFactory { Meta: meta, }, nil }, + "node intro": func() (cli.Command, error) { + return &NodeIntroCommand{ + Meta: meta, + }, nil + }, + "node intro create": func() (cli.Command, error) { + return &NodeIntroCreateCommand{ + Meta: meta, + }, nil + }, "node meta": func() (cli.Command, error) { return &NodeMetaCommand{ Meta: meta, diff --git a/command/node_intro.go b/command/node_intro.go new file mode 100644 index 000000000..a5766ee59 --- /dev/null +++ b/command/node_intro.go @@ -0,0 +1,34 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "strings" + + "github.com/hashicorp/cli" +) + +type NodeIntroCommand struct { + Meta +} + +func (n *NodeIntroCommand) Name() string { return "node intro" } + +func (n *NodeIntroCommand) Run(_ []string) int { return cli.RunResultHelp } + +func (n *NodeIntroCommand) Synopsis() string { + return "Tooling for managing node introduction tokens" +} + +func (n *NodeIntroCommand) Help() string { + helpText := ` +Usage: nomad node intro [options] + + This command groups subcommands for managing node introduction tokens. These + tokens are used to authenticate new Nomad client nodes to the cluster. + + Please see the individual subcommand help for detailed usage information. + ` + return strings.TrimSpace(helpText) +} diff --git a/command/node_intro_create.go b/command/node_intro_create.go new file mode 100644 index 000000000..67e793676 --- /dev/null +++ b/command/node_intro_create.go @@ -0,0 +1,145 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "fmt" + "strings" + "time" + + "github.com/hashicorp/nomad/api" + "github.com/posener/complete" +) + +type NodeIntroCreateCommand struct { + Meta + + // Fields for the command flags. + json bool + tmpl string + ttl string + nodeName string + nodePool string +} + +func (n *NodeIntroCreateCommand) Help() string { + helpText := ` +Usage: nomad node intro create [options] + + Generates a new node introduction token. This token is used to authenticate + a new Nomad client node to the cluster. + + If ACLs are enabled, this command requires a token with the 'node:write' + capability. + +General Options: + + ` + generalOptionsUsage(usageOptsDefault|usageOptsNoNamespace) + ` + +Create Options: + + -node-name + The name of the node to which the introduction token will be scoped. If not + specified, the value will be left empty. + + -node-pool + The node pool to which the introduction token will be scoped. If not + specified, the value "default" will be used. + + -ttl + The TTL to apply to the introduction token. If not specified, the server + configured default value will be used. + + -json + Output the response object in JSON format. + + -t + Format and display the response object using a Go template. +` + + return strings.TrimSpace(helpText) +} + +func (n *NodeIntroCreateCommand) Synopsis() string { + return "Generate a new node introduction token" +} + +func (n *NodeIntroCreateCommand) AutocompleteFlags() complete.Flags { + return mergeAutocompleteFlags(n.Meta.AutocompleteFlags(FlagSetClient), + complete.Flags{ + "-node-pool": nodePoolPredictor(n.Client, nil), + "-json": complete.PredictNothing, + "-t": complete.PredictAnything, + }) +} + +func (n *NodeIntroCreateCommand) AutocompleteArgs() complete.Predictor { + return complete.PredictNothing +} + +func (n *NodeIntroCreateCommand) Name() string { return "node intro create" } + +func (n *NodeIntroCreateCommand) Run(args []string) int { + + flags := n.Meta.FlagSet(n.Name(), FlagSetClient) + flags.Usage = func() { n.Ui.Output(n.Help()) } + flags.StringVar(&n.ttl, "ttl", "", "") + flags.StringVar(&n.nodeName, "node-name", "", "") + flags.StringVar(&n.nodePool, "node-pool", "", "") + flags.StringVar(&n.tmpl, "t", "", "") + flags.BoolVar(&n.json, "json", false, "") + if err := flags.Parse(args); err != nil { + return 1 + } + + args = flags.Args() + if len(args) != 0 { + n.Ui.Error(uiMessageNoArguments) + n.Ui.Error(commandErrorText(n)) + return 1 + } + + var ttlTime time.Duration + + if n.ttl != "" { + parsedTTL, err := time.ParseDuration(n.ttl) + if err != nil { + n.Ui.Error(fmt.Sprintf("Error parsing TTL: %s", err)) + return 1 + } + ttlTime = parsedTTL + } + + client, err := n.Meta.Client() + if err != nil { + n.Ui.Error(fmt.Sprintf("Error creating Nomad client: %s", err)) + return 1 + } + + req := api.ACLIdentityClientIntroductionTokenRequest{ + TTL: ttlTime, + NodeName: n.nodeName, + NodePool: n.nodePool, + } + + resp, _, err := client.ACLIdentity().CreateClientIntroductionToken(&req, nil) + if err != nil { + n.Ui.Error(fmt.Sprintf("Error generating introduction token: %s", err)) + return 1 + } + + if n.json || n.tmpl != "" { + out, err := Format(n.json, n.tmpl, resp) + if err != nil { + n.Ui.Error(err.Error()) + return 1 + } + + n.Ui.Output(out) + return 0 + } + + n.Ui.Output(fmt.Sprintf("Successfully generated client introduction token:\n\n%s", resp.JWT)) + return 0 +} diff --git a/command/node_intro_create_test.go b/command/node_intro_create_test.go new file mode 100644 index 000000000..5d9b1fdcc --- /dev/null +++ b/command/node_intro_create_test.go @@ -0,0 +1,115 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "encoding/json" + "testing" + + "github.com/hashicorp/cli" + "github.com/hashicorp/nomad/api" + "github.com/hashicorp/nomad/ci" + "github.com/hashicorp/nomad/testutil" + "github.com/shoenig/test/must" +) + +func TestNodeIntroCreateCommand_Implements(t *testing.T) { + ci.Parallel(t) + var _ cli.Command = &NodeIntroCreateCommand{} +} + +func TestNodeIntroCreateCommand_Run(t *testing.T) { + ci.Parallel(t) + + srv, _, url := testServer(t, true, nil) + defer srv.Shutdown() + + // Wait until our test node is ready. + testutil.WaitForClient( + t, + srv.Agent.Client().RPC, + srv.Agent.Client().NodeID(), + srv.Agent.Client().Region(), + ) + + ui := cli.NewMockUi() + + cmd := &NodeIntroCreateCommand{ + Meta: Meta{ + Ui: ui, + flagAddress: url, + }, + } + + // Run the command with some random arguments to ensure we are performing + // this check. + t.Run("with command argument", func(t *testing.T) { + t.Cleanup(func() { resetUI(ui) }) + + must.One(t, cmd.Run([]string{"pretty-please"})) + must.StrContains(t, ui.ErrorWriter.String(), "This command takes no arguments") + ui.ErrorWriter.Reset() + }) + + // Run the command with an invalid TTL, which should return an error as we + // parse this within the CLI. + t.Run("incorrect TTL", func(t *testing.T) { + t.Cleanup(func() { resetUI(ui) }) + + must.One(t, cmd.Run([]string{"-ttl=1millennium"})) + must.StrContains(t, ui.ErrorWriter.String(), "Error parsing TTL") + ui.ErrorWriter.Reset() + }) + + // Run the command with no flags supplied, which should write the JWT to the + // console. + t.Run("standard format output", func(t *testing.T) { + must.Zero(t, cmd.Run([]string{"--address=" + url})) + + t.Cleanup(func() { resetUI(ui) }) + must.StrContains(t, ui.OutputWriter.String(), + "Successfully generated client introduction token") + ui.OutputWriter.Reset() + }) + + // Run the command with all claim specific flags supplied, which should + // write the JWT to the console. + t.Run("standard format output all flags", func(t *testing.T) { + must.Zero(t, cmd.Run([]string{ + "--address=" + url, + "-ttl=1h", + "-node-name=test-node", + "-node-pool=test-pool", + })) + + t.Cleanup(func() { resetUI(ui) }) + must.StrContains(t, ui.OutputWriter.String(), + "Successfully generated client introduction token") + ui.OutputWriter.Reset() + }) + + // Run the command with the JSON flag supplied and ensure the output looks + // like valid JSON. + t.Run("json format output", func(t *testing.T) { + t.Cleanup(func() { resetUI(ui) }) + + must.Zero(t, cmd.Run([]string{"--address=" + url, "-json"})) + + var jsonObj api.ACLIdentityClientIntroductionTokenResponse + must.NoError(t, json.Unmarshal([]byte(ui.OutputWriter.String()), &jsonObj)) + must.NotEq(t, "", jsonObj.JWT) + }) + + t.Run("template format output", func(t *testing.T) { + t.Cleanup(func() { resetUI(ui) }) + + must.Zero(t, cmd.Run([]string{"--address=" + url, "-t={{.JWT}}"})) + must.NotEq(t, "", ui.OutputWriter.String()) + }) +} + +func resetUI(ui *cli.MockUi) { + ui.ErrorWriter.Reset() + ui.OutputWriter.Reset() +} diff --git a/command/node_intro_test.go b/command/node_intro_test.go new file mode 100644 index 000000000..7d4b9769b --- /dev/null +++ b/command/node_intro_test.go @@ -0,0 +1,16 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package command + +import ( + "testing" + + "github.com/hashicorp/cli" + "github.com/hashicorp/nomad/ci" +) + +func TestNodeIntroCommand_Implements(t *testing.T) { + ci.Parallel(t) + var _ cli.Command = &NodeIntroCommand{} +}