Add CLI and API components for creating node introduction tokens via ACL endpoint. (#26332)

This commit is contained in:
James Rasell
2025-07-25 14:28:45 +02:00
committed by GitHub
parent 842f316615
commit 20251b675d
7 changed files with 406 additions and 0 deletions

View File

@@ -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,

34
command/node_intro.go Normal file
View File

@@ -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 <subcommand> [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)
}

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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{}
}