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:
wangjia
2026-06-16 23:34:24 +08:00
parent 981f79aca9
commit 363ef0ba66
2 changed files with 184 additions and 14 deletions
+71 -14
View File
@@ -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
}