feat(nodectl): add bootstrap-token subcommand [tsk_DQ59QeVCOMjq]
Add `nodectl bootstrap-token -node=<uuid>` subcommand that issues a one-time Redis-backed enrollment token for a node UUID. Operators use this to re-enroll a node after cert expiry or state loss without having to re-provision the machine. - wires mtls.BootstrapTokenManager via REDIS_ADDR / REDIS_PASSWORD env vars - prints 64-char hex token to stdout (same format as cloud-init path) - updates usage() and package-level doc comment to document the new subcommand - adds cmd/nodectl/bootstrap_token_test.go with 5 unit tests (miniredis, no external deps): token format, one-time guarantee, missing-flag error, unreachable Redis error, unknown subcommand regression guard Compilation: `go build ./...` clean. Tests: `go test ./...` all pass (31 packages). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/mtls"
|
||||
)
|
||||
|
||||
// newTestBootstrapManager spins up an in-process miniredis instance and returns
|
||||
// a BootstrapTokenManager backed by it, plus a cleanup function.
|
||||
func newTestBootstrapManager(t *testing.T) (*mtls.BootstrapTokenManager, *miniredis.Miniredis) {
|
||||
t.Helper()
|
||||
mr := miniredis.RunT(t)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { rdb.Close() })
|
||||
return mtls.NewBootstrapTokenManager(rdb), mr
|
||||
}
|
||||
|
||||
// TestBootstrapTokenCmd_IssuesHexToken verifies that the token manager (the
|
||||
// same one wired into cmdBootstrapToken) produces a well-formed 64-char hex token.
|
||||
func TestBootstrapTokenCmd_IssuesHexToken(t *testing.T) {
|
||||
mgr, _ := newTestBootstrapManager(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const nodeUUID = "11111111-2222-3333-4444-555555555555"
|
||||
token, err := mgr.IssueToken(ctx, nodeUUID)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueToken: %v", err)
|
||||
}
|
||||
if len(token) != 64 {
|
||||
t.Errorf("token length = %d; want 64 hex chars", len(token))
|
||||
}
|
||||
// Verify it's pure lowercase hex.
|
||||
const hexChars = "0123456789abcdef"
|
||||
for _, c := range token {
|
||||
if !strings.ContainsRune(hexChars, c) {
|
||||
t.Errorf("token contains non-hex character %q in %q", c, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapTokenCmd_ConsumeOnce verifies the one-time guarantee: the token
|
||||
// can be consumed exactly once, and a second call returns an error.
|
||||
func TestBootstrapTokenCmd_ConsumeOnce(t *testing.T) {
|
||||
mgr, _ := newTestBootstrapManager(t)
|
||||
ctx := context.Background()
|
||||
|
||||
const nodeUUID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
|
||||
token, err := mgr.IssueToken(ctx, nodeUUID)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueToken: %v", err)
|
||||
}
|
||||
|
||||
// First consume must return the correct UUID.
|
||||
got, err := mgr.ConsumeToken(ctx, token)
|
||||
if err != nil {
|
||||
t.Fatalf("ConsumeToken (1st): %v", err)
|
||||
}
|
||||
if got != nodeUUID {
|
||||
t.Errorf("ConsumeToken returned %q; want %q", got, nodeUUID)
|
||||
}
|
||||
|
||||
// Second consume must fail (one-time guarantee).
|
||||
_, err = mgr.ConsumeToken(ctx, token)
|
||||
if err == nil {
|
||||
t.Fatal("ConsumeToken (2nd): expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapTokenCmd_MissingNode verifies that cmdBootstrapToken returns an
|
||||
// error when -node is not supplied, without touching Redis.
|
||||
func TestBootstrapTokenCmd_MissingNode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
err := run(ctx, "bootstrap-token", []string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when -node is empty, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "-node") {
|
||||
t.Errorf("error message %q should mention -node flag", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapTokenCmd_UnknownRedis verifies that cmdBootstrapToken returns an
|
||||
// error when REDIS_ADDR points to an unreachable address.
|
||||
func TestBootstrapTokenCmd_UnknownRedis(t *testing.T) {
|
||||
t.Setenv("REDIS_ADDR", "127.0.0.1:1") // port 1 is always unreachable
|
||||
|
||||
ctx := context.Background()
|
||||
err := run(ctx, "bootstrap-token", []string{"-node", "some-uuid"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unreachable Redis, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "redis") && !strings.Contains(err.Error(), "connect") {
|
||||
t.Errorf("error %q should mention redis/connect", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRun_UnknownSubcommand verifies that run returns an error for unrecognised
|
||||
// subcommands (regression guard on the switch statement).
|
||||
func TestRun_UnknownSubcommand(t *testing.T) {
|
||||
err := run(context.Background(), "nonexistent", nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown subcommand, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown subcommand") {
|
||||
t.Errorf("error %q should mention 'unknown subcommand'", err.Error())
|
||||
}
|
||||
}
|
||||
+71
-14
@@ -4,16 +4,19 @@
|
||||
//
|
||||
// Subcommands:
|
||||
//
|
||||
// nodectl create -provider=ID -region=hkg -tier=free|pro [-plan=...] [-key=...]
|
||||
// nodectl destroy -node=ID
|
||||
// nodectl rotate-ip -node=ID
|
||||
// nodectl replace -node=ID [-replacement=UUID]
|
||||
// nodectl rotate-pool -pool=consumable|premium [-concurrency=1|2]
|
||||
// nodectl providers [-pool=consumable|premium]
|
||||
// nodectl create -provider=ID -region=hkg -tier=free|pro [-plan=...] [-key=...]
|
||||
// nodectl destroy -node=ID
|
||||
// nodectl rotate-ip -node=ID
|
||||
// nodectl replace -node=ID [-replacement=UUID]
|
||||
// nodectl rotate-pool -pool=consumable|premium [-concurrency=1|2]
|
||||
// nodectl providers [-pool=consumable|premium]
|
||||
// nodectl bootstrap-token -node=UUID
|
||||
//
|
||||
// Configuration (env, never flags — credentials must not land in shell history):
|
||||
//
|
||||
// DB_DSN MySQL DSN (required)
|
||||
// DB_DSN MySQL DSN (required for most subcommands)
|
||||
// REDIS_ADDR Redis address (default 127.0.0.1:6379, required for bootstrap-token)
|
||||
// REDIS_PASSWORD Redis password (optional)
|
||||
// PROVISION_CONTROL_PLANE_URL agent enrollment URL injected into cloud-init
|
||||
// PROVISION_CLOUD_INIT_TMPL path to infra/cloud-init/node.yaml.tmpl
|
||||
// PROVISION_<VENDOR>_* per-vendor credentials (see providers/)
|
||||
@@ -32,8 +35,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/db"
|
||||
"github.com/wangjia/pangolin/server/internal/mtls"
|
||||
"github.com/wangjia/pangolin/server/internal/provision"
|
||||
"github.com/wangjia/pangolin/server/internal/provision/providers"
|
||||
"github.com/wangjia/pangolin/server/internal/redisutil"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -55,14 +60,16 @@ func usage() {
|
||||
fmt.Fprintln(os.Stderr, strings.TrimSpace(`
|
||||
nodectl — elastic-node control plane CLI
|
||||
|
||||
nodectl create -provider=ID -region=hkg -tier=free|pro [-plan=...]
|
||||
nodectl destroy -node=ID
|
||||
nodectl rotate-ip -node=ID
|
||||
nodectl replace -node=ID [-replacement=UUID]
|
||||
nodectl rotate-pool -pool=consumable|premium [-concurrency=1]
|
||||
nodectl providers [-pool=consumable|premium]
|
||||
nodectl create -provider=ID -region=hkg -tier=free|pro [-plan=...]
|
||||
nodectl destroy -node=ID
|
||||
nodectl rotate-ip -node=ID
|
||||
nodectl replace -node=ID [-replacement=UUID]
|
||||
nodectl rotate-pool -pool=consumable|premium [-concurrency=1]
|
||||
nodectl providers [-pool=consumable|premium]
|
||||
nodectl bootstrap-token -node=UUID
|
||||
|
||||
Config via env: DB_DSN, PROVISION_CONTROL_PLANE_URL, PROVISION_CLOUD_INIT_TMPL,
|
||||
Config via env: DB_DSN, REDIS_ADDR, REDIS_PASSWORD,
|
||||
PROVISION_CONTROL_PLANE_URL, PROVISION_CLOUD_INIT_TMPL,
|
||||
PROVISION_<VENDOR>_* credentials.`))
|
||||
}
|
||||
|
||||
@@ -110,6 +117,8 @@ func run(ctx context.Context, cmd string, args []string) error {
|
||||
return cmdRotatePool(ctx, args)
|
||||
case "providers":
|
||||
return cmdProviders(ctx, args)
|
||||
case "bootstrap-token":
|
||||
return cmdBootstrapToken(ctx, args)
|
||||
case "-h", "--help", "help":
|
||||
usage()
|
||||
return nil
|
||||
@@ -265,3 +274,51 @@ func cmdProviders(ctx context.Context, args []string) error {
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
// cmdBootstrapToken issues a new one-time bootstrap enrollment token for a node
|
||||
// identified by its UUID. The token is stored in Redis with a 15-minute TTL and
|
||||
// printed to stdout so the operator can inject it into cloud-init or pass it
|
||||
// directly to the agent's --bootstrap-token flag for manual re-enrollment.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// REDIS_ADDR=127.0.0.1:6379 nodectl bootstrap-token -node=<uuid>
|
||||
//
|
||||
// The node UUID must already exist in the provision database; this command does
|
||||
// NOT create a node — use "nodectl create" for that.
|
||||
func cmdBootstrapToken(ctx context.Context, args []string) error {
|
||||
fs := flag.NewFlagSet("bootstrap-token", flag.ExitOnError)
|
||||
nodeUUID := fs.String("node", "", "node UUID (required)")
|
||||
_ = fs.Parse(args)
|
||||
if *nodeUUID == "" {
|
||||
return fmt.Errorf("bootstrap-token: -node (UUID) is required")
|
||||
}
|
||||
|
||||
mgr, closeFn, err := buildBootstrapManager()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeFn()
|
||||
|
||||
token, err := mgr.IssueToken(ctx, *nodeUUID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bootstrap-token: issue token: %w", err)
|
||||
}
|
||||
fmt.Println(token)
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildBootstrapManager connects to Redis and returns a BootstrapTokenManager.
|
||||
// The connection uses REDIS_ADDR (default 127.0.0.1:6379) and REDIS_PASSWORD.
|
||||
func buildBootstrapManager() (*mtls.BootstrapTokenManager, func(), error) {
|
||||
addr := os.Getenv("REDIS_ADDR")
|
||||
if addr == "" {
|
||||
addr = "127.0.0.1:6379"
|
||||
}
|
||||
rdb, err := redisutil.New(addr, os.Getenv("REDIS_PASSWORD"), 0)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("bootstrap-token: redis connect: %w", err)
|
||||
}
|
||||
mgr := mtls.NewBootstrapTokenManager(rdb)
|
||||
return mgr, func() { rdb.Close() }, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user