Repository: nats-io/nsc
Pull request #551 (2023-02-24 20:29:43)

[FEAT] export an entire environment (jwts and keys)
added `--include-jwtws` option to make it easy to export not only the keys, but also the configurations in the current operator tree. Paired with `nsc fix` you can recreate/mode an nsc environment.

aricart: FIXES #428 

Patch:
diff --git a/cmd/addoperator.go b/cmd/addoperator.go
index 34fffa8..82b073f 100644
--- a/cmd/addoperator.go
+++ b/cmd/addoperator.go
@@ -1,5 +1,5 @@
 /*
- * Copyright 2018-2021 The NATS Authors
+ * Copyright 2018-2023 The NATS Authors
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
@@ -39,6 +39,9 @@ func createAddOperatorCmd() *cobra.Command {
 			if err := RunStoreLessAction(cmd, args, &params); err != nil {
 				return err
 			}
+			if NscCwdOnly {
+				return nil
+			}
 			return GetConfig().SetOperator(params.name)
 		},
 	}
diff --git a/cmd/defaults.go b/cmd/defaults.go
index ccfcae9..a937de7 100644
--- a/cmd/defaults.go
+++ b/cmd/defaults.go
@@ -1,5 +1,5 @@
 /*
- * Copyright 2018-2022 The NATS Authors
+ * Copyright 2018-2023 The NATS Authors
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
@@ -23,11 +23,10 @@ import (
 	"strings"
 	"time"
 
-	"github.com/nats-io/nsc/v2/cmd/store"
-	"github.com/nats-io/nsc/v2/home"
-
 	"github.com/nats-io/jwt/v2"
 	"github.com/nats-io/nats.go"
+	"github.com/nats-io/nsc/v2/cmd/store"
+	"github.com/nats-io/nsc/v2/home"
 )
 
 // NscHomeEnv the folder for the config file
@@ -203,8 +202,8 @@ func LoadOrInit(configDir string, dataDir string, keystoreDir string) (*ToolConf
 		return nil, err
 	}
 
-	// is the struct modified from the file
-	if (ToolConfig{}) == config {
+	if !NscCwdOnly && (ToolConfig{}) == config {
+		// is the struct modified from the file - so this is a first run here
 		config.GithubUpdates = github
 
 		config.LastUpdate = time.Now().UTC().Unix() // this is not "true" but avoids the initial check
diff --git a/cmd/exportkeys.go b/cmd/exportkeys.go
index 98daae6..480c3ef 100644
--- a/cmd/exportkeys.go
+++ b/cmd/exportkeys.go
@@ -1,5 +1,5 @@
 /*
- * Copyright 2018-2022 The NATS Authors
+ * Copyright 2018-2023 The NATS Authors
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
@@ -21,9 +21,8 @@ import (
 	"os"
 	"path/filepath"
 
-	"github.com/spf13/cobra"
-
 	"github.com/nats-io/nsc/v2/cmd/store"
+	"github.com/spf13/cobra"
 )
 
 func createExportKeysCmd() *cobra.Command {
@@ -47,6 +46,10 @@ accounts and users. These keys may be referenced in a different  operator contex
 The --filter flag allows you to specify a few letters in a public key and export only 
 those keys that matching the filter (provided the key type matches --operator, --account,
 --user (or --all).
+
+The --include-jwts flag will export the JWT configurations named by their identity key.
+This allows you to export an entire configuration tree of operators, accounts, users
+and their keys to a directory. To recreate an environment from this export use 'nsc fix'.
 `,
 		Example: `nsc export keys --dir <path> (exports the current operator, account and users keys)
 nsc export keys --operator --accounts --users (exports current operators, all accounts, and users)
@@ -77,6 +80,7 @@ nsc export keys --account <name> (changes the account context to the specified a
 	cmd.Flags().StringVarP(&params.Dir, "dir", "d", "", "directory to export keys to")
 	cmd.Flags().BoolVarP(&params.Force, "force", "F", false, "overwrite existing files")
 	cmd.Flags().BoolVarP(&params.Remove, "remove", "R", false, "removes the original key file from the keyring after exporting it")
+	cmd.Flags().BoolVarP(&params.IncludeJwts, "include-jwts", "", false, "include jwts")
 	cmd.MarkFlagRequired("dir")
 
 	return cmd
@@ -87,9 +91,10 @@ func init() {
 }
 
 type ExportKeysParams struct {
-	Force  bool
-	Remove bool
-	Dir    string
+	Force       bool
+	Remove      bool
+	Dir         string
+	IncludeJwts bool
 	KeyCollectorParams
 }
 
@@ -154,6 +159,25 @@ func (p *ExportKeysParams) Run(ctx ActionCtx) (store.Status, error) {
 		wj = append(wj, j)
 	}
 
+	if p.IncludeJwts {
+		for _, k := range keys.KeyList {
+			if k.Jwt == nil {
+				continue
+			}
+			var j ExportJob
+			j.description = fmt.Sprintf("%s.jwt (%s)", k.Pub, k.Name)
+			j.filepath = filepath.Join(p.Dir, fmt.Sprintf("%s.jwt", k.Pub))
+			_, err = os.Stat(j.filepath)
+			if os.IsNotExist(err) || (err == nil && p.Force) {
+				j.data = k.Jwt
+			} else {
+				sr.AddError("%#q already exists - specify --force to overwrite", j.filepath)
+				continue
+			}
+			wj = append(wj, j)
+		}
+	}
+
 	if keys.Len() == 0 {
 		return nil, errors.New("no keys found to export")
 	}
@@ -164,7 +188,7 @@ func (p *ExportKeysParams) Run(ctx ActionCtx) (store.Status, error) {
 
 	for _, j := range wj {
 		if j.filepath != "" {
-			j.err = os.WriteFile(j.filepath, j.data, 0700)
+			j.err = os.WriteFile(j.filepath, j.data, 0600)
 			if j.err != nil {
 				sr.AddError("error exporting %q: %v", j.description, j.err)
 			} else {
@@ -184,7 +208,7 @@ func (p *ExportKeysParams) Run(ctx ActionCtx) (store.Status, error) {
 			sr.AddWarning("skipped %q - no seed available", j.description)
 		}
 	}
-	return sr, err
+	return sr, nil
 }
 
 type ExportJob struct {
diff --git a/cmd/keycollector.go b/cmd/keycollector.go
index 05e08f8..bb46b34 100644
--- a/cmd/keycollector.go
+++ b/cmd/keycollector.go
@@ -1,5 +1,5 @@
 /*
- * Copyright 2018-2022 The NATS Authors
+ * Copyright 2018-2023 The NATS Authors
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
@@ -78,6 +78,10 @@ func (p *KeyCollectorParams) handleOperator(ctx ActionCtx) (KeyList, error) {
 	oki.ExpectedKind = nkeys.PrefixByteOperator
 	oki.Pub = oc.Subject
 	oki.Resolve(sctx.KeyStore)
+	oki.Jwt, err = sctx.Store.ReadRawOperatorClaim()
+	if err != nil {
+		return nil, err
+	}
 	keys = append(keys, &oki)
 
 	for _, k := range oc.SigningKeys {
@@ -108,6 +112,10 @@ func (p *KeyCollectorParams) handleAccount(ctx ActionCtx, parent string, name st
 	aki.Name = ac.Name
 	aki.ExpectedKind = nkeys.PrefixByteAccount
 	aki.Pub = ac.Subject
+	aki.Jwt, err = s.ReadRawAccountClaim(name)
+	if err != nil {
+		return nil, err
+	}
 	aki.Resolve(ks)
 	keys = append(keys, &aki)
 
@@ -167,6 +175,10 @@ func (p *KeyCollectorParams) handleUser(ctx ActionCtx, account string, name stri
 	uki.Name = uc.Name
 	uki.Pub = uc.Subject
 	uki.ExpectedKind = nkeys.PrefixByteUser
+	uki.Jwt, err = s.ReadRawUserClaim(account, name)
+	if err != nil {
+		return nil, err
+	}
 	uki.Resolve(ks)
 	return &uki, nil
 }
@@ -290,6 +302,7 @@ type Key struct {
 	Curve        bool
 	KeyPath      string
 	Invalid      bool
+	Jwt          []byte
 }
 
 func (k *Key) Resolve(ks store.KeyStore) {
diff --git a/cmd/list.go b/cmd/list.go
index 7c10252..c46def1 100644
--- a/cmd/list.go
+++ b/cmd/list.go
@@ -58,7 +58,8 @@ func createListOperatorsCmd() *cobra.Command {
 			}
 			operators := config.ListOperators()
 			if len(operators) == 0 {
-				fmt.Println("no operators defined - init an environment")
+				fmt.Printf("no operators defined in store dir %q\n"+
+					"init an environment or change the store root with `env --store <dir>`", config.StoreRoot)
 			} else {
 				sort.Strings(operators)
 				var infos []*EntryInfo


Test patch:
diff --git a/cmd/adduser_test.go b/cmd/adduser_test.go
index 903dbd2..ae54f6f 100644
--- a/cmd/adduser_test.go
+++ b/cmd/adduser_test.go
@@ -16,14 +16,13 @@
 package cmd
 
 import (
-	"github.com/stretchr/testify/assert"
 	"os"
 	"path/filepath"
 	"testing"
 	"time"
 
 	"github.com/nats-io/nkeys"
-
+	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
 
diff --git a/cmd/exportkeys_test.go b/cmd/exportkeys_test.go
index 2ac6d38..bfc0ba3 100644
--- a/cmd/exportkeys_test.go
+++ b/cmd/exportkeys_test.go
@@ -1,5 +1,5 @@
 /*
- * Copyright 2018-2022 The NATS Authors
+ * Copyright 2018-2023 The NATS Authors
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
@@ -236,3 +236,25 @@ func Test_ExportXKeyInContext(t *testing.T) {
 	require.NoError(t, err)
 	requireExportedKey(t, exportDir, xPK)
 }
+
+func Test_ExportKeyJwt(t *testing.T) {
+	ts := NewTestStore(t, "O")
+	defer ts.Done(t)
+
+	ts.AddAccount(t, "A")
+	ts.AddUser(t, "A", "U")
+
+	o := ts.GetOperatorPublicKey(t)
+	a := ts.GetAccountPublicKey(t, "A")
+	u := ts.GetUserPublicKey(t, "A", "U")
+
+	exportDir := filepath.Join(ts.Dir, "export")
+	_, _, err := ExecuteCmd(createExportKeysCmd(), "--dir", exportDir, "-A", "--include-jwts")
+	require.NoError(t, err)
+	requireExportedKey(t, exportDir, o)
+	require.FileExists(t, filepath.Join(exportDir, fmt.Sprintf("%s.jwt", o)))
+	requireExportedKey(t, exportDir, a)
+	require.FileExists(t, filepath.Join(exportDir, fmt.Sprintf("%s.jwt", a)))
+	requireExportedKey(t, exportDir, u)
+	require.FileExists(t, filepath.Join(exportDir, fmt.Sprintf("%s.jwt", u)))
+}
diff --git a/cmd/validate_test.go b/cmd/validate_test.go
index f81024a..4ae1c4f 100644
--- a/cmd/validate_test.go
+++ b/cmd/validate_test.go
@@ -18,6 +18,7 @@ package cmd
 import (
 	"os"
 	"path/filepath"
+	"strings"
 	"testing"
 
 	"github.com/nats-io/nsc/v2/cmd/store"
@@ -30,10 +31,10 @@ func Test_ValidateNoOperator(t *testing.T) {
 	storeDir := filepath.Join(ts.Dir, "store")
 	require.NoError(t, os.Mkdir(storeDir, 0777))
 	require.DirExists(t, storeDir)
-
 	_, _, err := ExecuteCmd(createValidateCommand())
 	require.Error(t, err)
-	require.Contains(t, err.Error(), "set an operator")
+	require.True(t, strings.Contains(err.Error(), "set an operator") ||
+		strings.Contains(err.Error(), "no such file or directory"))
 }
 
 func Test_ValidateNoAccount(t *testing.T) {