Skip to content

Commit

Permalink
feat(cli): identities CLI commands (canonical#431)
Browse files Browse the repository at this point in the history
This adds the CLI commands for identities (part of [spec
OP043](https://docs.google.com/document/d/1nASgUt-piV94i1cpFsbEPRk_xFbZt8AskgL97gh1Dgg/edit)),
specifically:

* `identities`: list all identities
* `identity`: show details for a single named identity
* `add-identities`: add one or more new identities
* `update-identities`: update one or more existing identities
(`--replace` for replace operation)
* `remove-identities`: remove one or more identities

In addition, this updates `pebble run` to support an `--identities
<file>` option to allow seeding initial identities when running the
daemon.
  • Loading branch information
benhoyt authored Jul 8, 2024
1 parent d3a84b0 commit 35e2b7c
Show file tree
Hide file tree
Showing 12 changed files with 990 additions and 2 deletions.
103 changes: 103 additions & 0 deletions internals/cli/cmd_add-identities.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright (c) 2024 Canonical Ltd
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License version 3 as
// published by the Free Software Foundation.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package cli

import (
"errors"
"fmt"
"os"
"strconv"

"github.com/canonical/go-flags"
"gopkg.in/yaml.v3"

"github.com/canonical/pebble/client"
)

const cmdAddIdentitiesSummary = "Add new identities"
const cmdAddIdentitiesDescription = `
The add-identities command adds one or more new identities.
The named identities must not yet exist.
For example, to add a local admin named "bob", use YAML like this:
> identities:
> bob:
> access: admin
> local:
> user-id: 42
`

type cmdAddIdentities struct {
client *client.Client

From string `long:"from" required:"1"`
}

func init() {
AddCommand(&CmdInfo{
Name: "add-identities",
Summary: cmdAddIdentitiesSummary,
Description: cmdAddIdentitiesDescription,
ArgsHelp: map[string]string{
"--from": "Path of YAML file to read identities from (required)",
},
New: func(opts *CmdOptions) flags.Commander {
return &cmdAddIdentities{client: opts.Client}
},
})
}

func (cmd *cmdAddIdentities) Execute(args []string) error {
if len(args) > 0 {
return ErrExtraArgs
}

identities, err := readIdentities(cmd.From)
if err != nil {
return err
}
err = cmd.client.AddIdentities(identities)
if err != nil {
return err
}

fmt.Fprintf(Stdout, "Added %s.\n", numItems(len(identities), "new identity", "new identities"))
return nil
}

func readIdentities(path string) (map[string]*client.Identity, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var identities identitiesMap
err = yaml.Unmarshal(data, &identities)
if err != nil {
return nil, fmt.Errorf("cannot unmarshal identities: %w", err)
}
if len(identities.Identities) == 0 {
return nil, errors.New(`no identities to add; did you forget the top-level "identities" key?`)
}
return identities.Identities, nil
}

func numItems(n int, singular, plural string) string {
if n == 1 {
return "1 " + singular
}
return strconv.Itoa(n) + " " + plural
}
142 changes: 142 additions & 0 deletions internals/cli/cmd_add-identities_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright (c) 2024 Canonical Ltd
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License version 3 as
// published by the Free Software Foundation.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.

package cli_test

import (
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"

. "gopkg.in/check.v1"

"github.com/canonical/pebble/internals/cli"
)

func (s *PebbleSuite) TestAddIdentitiesSingle(c *C) {
s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) {
s.checkPostIdentities(c, r, "add", map[string]any{
"bob": map[string]any{
"access": "admin",
"local": map[string]any{
"user-id": 42.0,
},
},
})
fmt.Fprint(w, `{
"type": "sync",
"status-code": 200,
"result": null
}`)
})

path := filepath.Join(c.MkDir(), "identities.yaml")
data := `
identities:
bob:
access: admin
local: {user-id: 42}
`
err := os.WriteFile(path, []byte(data), 0o666)
c.Assert(err, IsNil)

rest, err := cli.ParserForTest().ParseArgs([]string{"add-identities", "--from", path})
c.Assert(err, IsNil)
c.Check(rest, HasLen, 0)
c.Check(s.Stdout(), Equals, "Added 1 new identity.\n")
c.Check(s.Stderr(), Equals, "")
}

func (s *PebbleSuite) TestAddIdentitiesMultiple(c *C) {
s.RedirectClientToTestServer(func(w http.ResponseWriter, r *http.Request) {
s.checkPostIdentities(c, r, "add", map[string]any{
"bob": map[string]any{
"access": "admin",
"local": map[string]any{
"user-id": 42.0,
},
},
"mary": map[string]any{
"access": "read",
"local": map[string]any{
"user-id": 1000.0,
},
},
})
fmt.Fprint(w, `{
"type": "sync",
"status-code": 200,
"result": null
}`)
})

path := filepath.Join(c.MkDir(), "identities.yaml")
data := `
identities:
bob:
access: admin
local: {user-id: 42}
mary:
access: read
local: {user-id: 1000}
`
err := os.WriteFile(path, []byte(data), 0o666)
c.Assert(err, IsNil)

rest, err := cli.ParserForTest().ParseArgs([]string{"add-identities", "--from", path})
c.Assert(err, IsNil)
c.Check(rest, HasLen, 0)
c.Check(s.Stdout(), Equals, "Added 2 new identities.\n")
c.Check(s.Stderr(), Equals, "")
}

func (s *PebbleSuite) TestAddIdentitiesUnmarshalError(c *C) {
path := filepath.Join(c.MkDir(), "identities.yaml")
err := os.WriteFile(path, []byte("}not yaml{"), 0o666)
c.Assert(err, IsNil)

_, err = cli.ParserForTest().ParseArgs([]string{"add-identities", "--from", path})
c.Assert(err, ErrorMatches, `cannot unmarshal identities: .*`)
}

func (s *PebbleSuite) TestAddIdentitiesNoIdentities(c *C) {
path := filepath.Join(c.MkDir(), "identities.yaml")
data := `
bob:
access: admin
local: {user-id: 42}
`
err := os.WriteFile(path, []byte(data), 0o666)
c.Assert(err, IsNil)

_, err = cli.ParserForTest().ParseArgs([]string{"add-identities", "--from", path})
c.Assert(err, ErrorMatches, `no identities to add.*`)
}

func (s *PebbleSuite) checkPostIdentities(c *C, r *http.Request, action string, identities map[string]any) {
c.Check(r.Method, Equals, "POST")
c.Check(r.URL.Path, Equals, "/v1/identities")
body, err := io.ReadAll(r.Body)
c.Assert(err, IsNil)
var m map[string]any
err = json.Unmarshal(body, &m)
c.Assert(err, IsNil)
c.Check(m, DeepEquals, map[string]any{
"action": action,
"identities": identities,
})
}
11 changes: 10 additions & 1 deletion internals/cli/cmd_help.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,10 @@ var HelpCategories = []HelpCategory{{
Label: "Notices",
Description: "manage notices and warnings",
Commands: []string{"warnings", "okay", "notices", "notice", "notify"},
}, {
Label: "Identities", // special-cased in printShortHelp
Description: "manage user identities",
Commands: []string{"identities", "identity", "add-identities", "update-identities", "remove-identities"},
}}

var (
Expand Down Expand Up @@ -265,7 +269,12 @@ func printShortHelp() {
}
}
for _, categ := range HelpCategories {
fmt.Fprintf(Stdout, "%*s: %s\n", maxLen+2, categ.Label, strings.Join(categ.Commands, ", "))
commandsStr := strings.Join(categ.Commands, ", ")
if categ.Label == "Identities" {
// Special case for identities command to avoid a long list here
commandsStr = "identities --help"
}
fmt.Fprintf(Stdout, "%*s: %s\n", maxLen+2, categ.Label, commandsStr)
}
printHelpFooter()
}
Expand Down
Loading

0 comments on commit 35e2b7c

Please sign in to comment.