diff --git a/cli/auth_account_command.go b/cli/auth_account_command.go index 213b3619..b2265534 100644 --- a/cli/auth_account_command.go +++ b/cli/auth_account_command.go @@ -24,6 +24,7 @@ import ( au "github.com/nats-io/natscli/internal/auth" iu "github.com/nats-io/natscli/internal/util" + "gopkg.in/yaml.v3" "github.com/AlecAivazis/survey/v2" "github.com/choria-io/fisk" @@ -102,6 +103,11 @@ type authAccountCommand struct { tags []string rmTags []string signingKey string + mapSource string + mapTarget string + mapWeight uint + mapCluster string + inputFile string } func configureAuthAccountCommand(auth commandHost) { @@ -280,6 +286,31 @@ func configureAuthAccountCommand(auth commandHost) { skrm.Flag("key", "The key to remove").StringVar(&c.skRole) skrm.Flag("operator", "Operator to act on").StringVar(&c.operatorName) skrm.Flag("force", "Removes without prompting").Short('f').UnNegatableBoolVar(&c.force) + + mappings := acct.Command("mappings", "Manage account level subject mapping and partitioning").Alias("m") + + mappingsaAdd := mappings.Command("add", "Add a new mapping").Alias("new").Alias("a").Action(c.mappingAddAction) + mappingsaAdd.Arg("account", "Account to create the mappings on").StringVar(&c.accountName) + mappingsaAdd.Arg("source", "The source subject of the mapping").StringVar(&c.mapSource) + mappingsaAdd.Arg("target", "The target subject of the mapping").StringVar(&c.mapTarget) + mappingsaAdd.Arg("weight", "The weight (%) of the mapping").Default("100").UintVar(&c.mapWeight) + mappingsaAdd.Arg("cluster", "Limit the mappings to a specific cluster").StringVar(&c.mapCluster) + mappingsaAdd.Flag("operator", "Operator to act on").StringVar(&c.operatorName) + mappingsaAdd.Flag("config", "json or yaml file to read configuration from").ExistingFileVar(&c.inputFile) + + mappingsls := mappings.Command("ls", "List mappings").Alias("list").Action(c.mappingListAction) + mappingsls.Arg("account", "Account to list the mappings from").StringVar(&c.accountName) + mappingsls.Flag("operator", "Operator to act on").StringVar(&c.operatorName) + + mappingsrm := mappings.Command("rm", "Remove a mapping").Action(c.mappingRmAction) + mappingsrm.Arg("account", "Account to remove the mappings from").StringVar(&c.accountName) + mappingsrm.Arg("source", "The source subject of the mapping").StringVar(&c.mapSource) + mappingsrm.Flag("operator", "Operator to act on").StringVar(&c.operatorName) + + mappingsinfo := mappings.Command("info", "Show information about a mapping").Alias("i").Alias("show").Alias("view").Action(c.mappingInfoAction) + mappingsinfo.Arg("account", "Account to inspect the mappings from").StringVar(&c.accountName) + mappingsinfo.Arg("source", "The source subject of the mapping").StringVar(&c.mapSource) + mappingsinfo.Flag("operator", "Operator to act on").StringVar(&c.operatorName) } func (c *authAccountCommand) selectAccount(pick bool) (*ab.AuthImpl, ab.Operator, ab.Account, error) { @@ -1101,3 +1132,213 @@ func (c *authAccountCommand) validTiers(acct ab.Account) []int8 { return tiers } + +func (c *authAccountCommand) loadMappingsConfig() (map[string][]ab.Mapping, error) { + if c.inputFile != "" { + f, err := os.ReadFile(c.inputFile) + if err != nil { + return nil, err + } + + var mappings map[string][]ab.Mapping + err = yaml.Unmarshal(f, &mappings) + if err != nil { + return nil, fmt.Errorf("unable to load config file: %s", err) + } + return mappings, nil + } + return nil, nil + +} + +func (c *authAccountCommand) mappingAddAction(_ *fisk.ParseContext) error { + var err error + mappings := map[string][]ab.Mapping{} + if c.inputFile != "" { + mappings, err = c.loadMappingsConfig() + if err != nil { + return err + } + } + + auth, _, acct, err := c.selectAccount(true) + if err != nil { + return err + } + + if c.inputFile == "" { + if c.mapSource == "" { + err := iu.AskOne(&survey.Input{ + Message: "Source subject", + Help: "The source subject of the mapping", + }, &c.mapSource, survey.WithValidator(survey.Required)) + if err != nil { + return err + } + } + + if c.mapTarget == "" { + err := iu.AskOne(&survey.Input{ + Message: "Target subject", + Help: "The target subject of the mapping", + }, &c.mapTarget, survey.WithValidator(survey.Required)) + if err != nil { + return err + } + } + + mapping := ab.Mapping{Subject: c.mapTarget, Weight: uint8(c.mapWeight)} + if c.mapCluster != "" { + mapping.Cluster = c.mapCluster + } + // check if there are mappings already set for the source + currentMappings := acct.SubjectMappings().Get(c.mapSource) + if len(currentMappings) > 0 { + // Check that we don't overwrite the current mapping + for _, m := range currentMappings { + if m.Subject == c.mapTarget { + return fmt.Errorf("mapping %s -> %s already exists", c.mapSource, c.mapTarget) + } + } + } + currentMappings = append(currentMappings, mapping) + mappings[c.mapSource] = currentMappings + } + + for subject, m := range mappings { + err = acct.SubjectMappings().Set(subject, m...) + if err != nil { + return err + } + } + + err = auth.Commit() + if err != nil { + return err + } + + return c.fShowMappings(os.Stdout, mappings) +} + +func (c *authAccountCommand) mappingInfoAction(_ *fisk.ParseContext) error { + _, _, acct, err := c.selectAccount(true) + if err != nil { + return err + } + + accountMappings := acct.SubjectMappings().List() + if len(accountMappings) == 0 { + fmt.Println("No mappings defined") + return nil + } + + if c.mapSource == "" { + err = iu.AskOne(&survey.Select{ + Message: "Select a mapping to inspect", + Options: accountMappings, + PageSize: iu.SelectPageSize(len(accountMappings)), + }, &c.mapSource) + if err != nil { + return err + } + } + + mappings := map[string][]ab.Mapping{ + c.mapSource: acct.SubjectMappings().Get(c.mapSource), + } + + return c.fShowMappings(os.Stdout, mappings) +} + +func (c *authAccountCommand) mappingListAction(_ *fisk.ParseContext) error { + _, _, acct, err := c.selectAccount(true) + if err != nil { + return err + } + + mappings := acct.SubjectMappings().List() + if len(mappings) == 0 { + fmt.Println("No mappings defined") + return nil + } + + tbl := iu.NewTableWriter(opts(), "Subject mappings for account %s", acct.Name()) + tbl.AddHeaders("Source Subject", "Target Subject", "Weight", "Cluster") + + for _, fromMapping := range acct.SubjectMappings().List() { + subjectMaps := acct.SubjectMappings().Get(fromMapping) + for _, m := range subjectMaps { + tbl.AddRow(fromMapping, m.Subject, m.Weight, m.Cluster) + } + } + + fmt.Println(tbl.Render()) + return nil +} + +func (c *authAccountCommand) mappingRmAction(_ *fisk.ParseContext) error { + auth, _, acct, err := c.selectAccount(true) + if err != nil { + return err + } + + mappings := acct.SubjectMappings().List() + if len(mappings) == 0 { + fmt.Println("No mappings defined") + return nil + } + + if c.mapSource == "" { + err = iu.AskOne(&survey.Select{ + Message: "Select a mapping to delete", + Options: mappings, + PageSize: iu.SelectPageSize(len(mappings)), + }, &c.mapSource) + if err != nil { + return err + } + } + + err = acct.SubjectMappings().Delete(c.mapSource) + if err != nil { + return err + } + + err = auth.Commit() + if err != nil { + return err + } + + fmt.Printf("Deleted mapping {%s}\n", c.mapSource) + return nil +} + +func (c *authAccountCommand) fShowMappings(w io.Writer, mappings map[string][]ab.Mapping) error { + out, err := c.showMappings(mappings) + if err != nil { + return err + } + + _, err = fmt.Fprintln(w, out) + return err +} + +func (c *authAccountCommand) showMappings(mappings map[string][]ab.Mapping) (string, error) { + cols := newColumns("Subject mappings") + cols.AddSectionTitle("Configuration") + for source, m := range mappings { + totalWeight := 0 + for _, wm := range m { + cols.AddRow("Source", source) + cols.AddRow("Target", wm.Subject) + cols.AddRow("Weight", wm.Weight) + cols.AddRow("Cluster", wm.Cluster) + cols.AddRow("", "") + totalWeight += int(wm.Weight) + } + cols.AddRow("Total weight:", totalWeight) + cols.AddRow("", "") + } + + return cols.Render() +} diff --git a/nats/auth_command_test.go b/nats/auth_command_test.go new file mode 100644 index 00000000..d1e2bd5b --- /dev/null +++ b/nats/auth_command_test.go @@ -0,0 +1,209 @@ +// Copyright 2025 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 +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +const ( + TEST_DIR = "/tmp/natscli/auth_command_test" +) + +var ( + JSON = ` +{ + "test.a": [ + { + "subject": "test.b", + "weight": 100, + "cluster": "test_cluster" + } + ] +} +` + + YAML = ` +test.a: + - subject: test.b + weight: 100 + cluster: test_cluster +` +) + +func setup(operator, account string, t *testing.T) { + teardown(t) + err := os.Setenv("XDG_CONFIG_HOME", TEST_DIR) + if err != nil { + t.Error(err) + } + err = os.Setenv("XDG_DATA_HOME", TEST_DIR) + if err != nil { + t.Error(err) + } + + runNatsCli(t, fmt.Sprintf("auth operator add %s", operator)) + runNatsCli(t, fmt.Sprintf("auth account add --operator=%s --defaults %s", operator, account)) +} + +func teardown(t *testing.T) { + err := os.RemoveAll(TEST_DIR) + if err != nil { + t.Error(err) + } + err = os.Unsetenv("NSC_HOME") + if err != nil { + t.Error(err) + } +} + +func TestMapping(t *testing.T) { + t.Run("--add", func(t *testing.T) { + accountName, operatorName := "test_account", "test_operator" + setup(operatorName, accountName, t) + t.Cleanup(func() { + teardown(t) + }) + + fields := map[string]*regexp.Regexp{ + "source": regexp.MustCompile("Source: test.a"), + "target": regexp.MustCompile("Target: test.b"), + "weight": regexp.MustCompile("Weight: 100"), + "totalWeight": regexp.MustCompile("Total weight: 100"), + } + + output := runNatsCli(t, fmt.Sprintf("auth account mappings add %s test.a test.b 100 --operator=%s", accountName, operatorName)) + + for name, pattern := range fields { + if !pattern.Match(output) { + t.Errorf("%s value does not match expected %s", name, pattern) + } + } + }) + + t.Run("--add from config", func(t *testing.T) { + tests := []struct { + name string + fileExt string + data string + }{ + {"--add from config json", "json", JSON}, + {"--add from config yaml", "yaml", YAML}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + accountName, operatorName := "test_account", "test_operator" + setup(operatorName, accountName, t) + t.Cleanup(func() { teardown(t) }) + + fields := map[string]*regexp.Regexp{ + "source": regexp.MustCompile("Source: test.a"), + "target": regexp.MustCompile("Target: test.b"), + "weight": regexp.MustCompile("Weight: 100"), + "cluster": regexp.MustCompile("Cluster: test_cluster"), + "totalWeight": regexp.MustCompile("Total weight: 100"), + } + + fp := filepath.Join(TEST_DIR, fmt.Sprintf("test.%s", tt.fileExt)) + file, err := os.OpenFile(fp, os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + t.Fatalf("Error opening file: %s", err) + } + defer file.Close() + + _, err = file.WriteString(strings.TrimSpace(tt.data)) + if err != nil { + t.Fatalf("Error writing to file: %s", err) + } + + output := runNatsCli(t, fmt.Sprintf("auth account mappings add %s --operator=%s --config=%s", accountName, operatorName, fp)) + + for name, pattern := range fields { + if !pattern.Match(output) { + t.Errorf("%s value does not match expected %s", name, pattern) + } + } + }) + } + }) + t.Run("--ls", func(t *testing.T) { + accountName, operatorName := "test_account", "test_operator" + setup(operatorName, accountName, t) + t.Cleanup(func() { + teardown(t) + }) + + colums := map[string]*regexp.Regexp{ + "top": regexp.MustCompile("Subject mappings for account test_account"), + "middle": regexp.MustCompile("Source Subject │ Target Subject │ Weight │ Cluster"), + "bottom": regexp.MustCompile("test.a │ test.b │ 100"), + } + + runNatsCli(t, fmt.Sprintf("auth account mappings add %s test.a test.b 100 --operator=%s", accountName, operatorName)) + output := runNatsCli(t, fmt.Sprintf("auth account mappings ls %s --operator=%s", accountName, operatorName)) + + for name, pattern := range colums { + if !pattern.Match(output) { + t.Errorf("%s value does not match expected %s", name, pattern) + } + } + }) + + t.Run("--info", func(t *testing.T) { + accountName, operatorName := "test_account", "test_operator" + setup(operatorName, accountName, t) + t.Cleanup(func() { + teardown(t) + }) + + fields := map[string]*regexp.Regexp{ + "source": regexp.MustCompile("Source: test.a"), + "target": regexp.MustCompile("Target: test.b"), + "weight": regexp.MustCompile("Weight: 100"), + "totalWeight": regexp.MustCompile("Total weight: 100"), + } + + runNatsCli(t, fmt.Sprintf("auth account mappings add %s test.a test.b 100 --operator=%s", accountName, operatorName)) + output := runNatsCli(t, fmt.Sprintf("auth account mappings info %s test.a --operator=%s", accountName, operatorName)) + + for name, pattern := range fields { + if !pattern.Match(output) { + t.Errorf("%s value does not match expected %s", name, pattern) + } + } + }) + + t.Run("--delete", func(t *testing.T) { + accountName, operatorName := "test_account", "test_operator" + setup(operatorName, accountName, t) + t.Cleanup(func() { + teardown(t) + }) + + expected := regexp.MustCompile("Deleted mapping {test.a}") + + runNatsCli(t, fmt.Sprintf("auth account mappings add %s test.a test.b 100 --operator=%s", accountName, operatorName)) + output := runNatsCli(t, fmt.Sprintf("auth account mappings rm %s test.a --operator=%s", accountName, operatorName)) + + if !expected.Match(output) { + t.Errorf("failed to delete mapping: %s", output) + } + }) +}