Skip to content

Commit

Permalink
feat(cli): add CLI commands for notices (notices, notice, notify) (#298)
Browse files Browse the repository at this point in the history
This adds the CLI commands for Pebble Notices see spec JU048.

The new commands are:

* `pebble notices [--type=<type> --key=<key> --timeout=<duration>]`
  list (or wait for) notices
* `pebble notice <id-or-type> [<key>]`
  fetch details about a single notice by ID or type+key
* `pebble notify [--repeat-after=<duration>] <key> [<name=value>...]`
  record a client ("custom") notice

The PR also updates the existing `pebble okay` command to
acknowledge notices (as well as warnings).

I've also added a section to the README explaining notices and the
associated commands.
  • Loading branch information
benhoyt authored Nov 20, 2023
1 parent c974cd5 commit 010e042
Show file tree
Hide file tree
Showing 16 changed files with 1,151 additions and 44 deletions.
76 changes: 76 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,82 @@ pebble_service: svc2 # default label for Loki
```


### Notices

Pebble includes a subsystem called *notices*, which allows the user to introspect various events that occur in the Pebble server, as well as record custom client events. The server saves notices to disk, so they persist across restarts, and expire after a notice-defined interval.

Each notice is uniquely identified by its *type* and *key* combination, and the notice's count of occurences is incremented every time a notice with that type and key combination occurs.

Each notice records the time it first occurred, the time it last occurred, and the time it last repeated.

A *repeat* happens when a notice occurs with the same type and key as a prior notice, and either the notice has no "repeat after" duration (the default), or the notice happens after the provided "repeat after" interval (since the prior notice). Thus, specifying "repeat after" prevents a notice from appearing again if it happens more frequently than desired.

In addition, a notice records optional *data* (string key-value pairs) from the last occurrence.

These notice types are currently available:

<!-- TODO: * `change-update`: recorded whenever a change is first spawned or its status is updated. The key for this type of notice is the change ID, and the notice's data includes the change `kind`. -->

* `custom`: a custom client notice reported via `pebble notify`. The key and any data is provided by the user. The key must be in the format `mydomain.io/mykey` to ensure well-namespaced notice keys.

<!-- TODO: * `warning`: Pebble warnings are implemented in terms of notices. The key for this type of notice is the human-readable warning message. -->

To record `custom` notices, use `pebble notify`:

```
$ pebble notify example.com/foo
Recorded notice 1
$ pebble notify example.com/foo
Recorded notice 1
$ pebble notify other.com/bar name=value [email protected] # two data fields
Recorded notice 2
$ pebble notify example.com/foo
Recorded notice 1
```

The `pebble notices` command lists notices not yet acknowledged, ordered by the last-repeated time (oldest first). After it runs, the notices that were shown may then be acknowledged by running `pebble okay`. When a notice repeats (see above), it needs to be acknowledged again.

```
$ pebble notices
ID Type Key First Repeated Occ
1 custom example.com/foo today at 16:16 NZST today at 16:16 NZST 3
2 custom other.com/bar today at 16:16 NZST today at 16:16 NZST 1
```
To fetch details about a single notice, use `pebble notice`, which displays the output in YAML format. You can fetch a notice either by ID or by type/key combination.
To fetch the notice with ID "1":
```
$ pebble notice 1
id: "1"
type: custom
key: example.com/foo
first-occurred: 2023-09-15T04:16:09.179395298Z
last-occurred: 2023-09-15T04:16:19.487035209Z
last-repeated: 2023-09-15T04:16:09.179395298Z
occurrences: 3
expire-after: 168h0m0s
```
To fetch the notice with type "custom" and key "other.com/bar":
```
$ pebble notice custom other.com/bar
id: "2"
type: custom
key: other.com/bar
first-occurred: 2023-09-15T04:16:17.180049768Z
last-occurred: 2023-09-15T04:16:17.180049768Z
last-repeated: 2023-09-15T04:16:17.180049768Z
occurrences: 1
last-data:
name: value
email: [email protected]
expire-after: 168h0m0s
```
## Container usage
Pebble works well as a local service manager, but if running Pebble in a separate container, you can use the exec and file management APIs to coordinate with the remote system over the shared unix socket.
Expand Down
82 changes: 82 additions & 0 deletions internals/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,17 @@
package cli

import (
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/user"
"path/filepath"
"regexp"
"strings"
"time"
"unicode"
"unicode/utf8"

Expand Down Expand Up @@ -364,3 +367,82 @@ func getEnvPaths() (pebbleDir string, socketPath string) {
}
return pebbleDir, socketPath
}

type cliState struct {
NoticesLastListed time.Time `json:"notices-last-listed"`
NoticesLastOkayed time.Time `json:"notices-last-okayed"`
}

type fullCLIState struct {
// Map of socket path to individual cliState instance.
Pebble map[string]*cliState `json:"pebble"`
}

// TODO(benhoyt): add file locking to properly handle multi-user access
func loadCLIState() (*cliState, error) {
fullState, err := loadFullCLIState()
if err != nil {
return nil, err
}
_, socketPath := getEnvPaths()
st, ok := fullState.Pebble[socketPath]
if !ok {
return &cliState{}, nil
}
return st, nil
}

func loadFullCLIState() (*fullCLIState, error) {
path := cliStatePath()
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
st := &fullCLIState{
Pebble: make(map[string]*cliState),
}
return st, nil
}
return nil, err
}

var fullState fullCLIState
err = json.Unmarshal(data, &fullState)
if err != nil {
return nil, err
}
return &fullState, nil
}

func saveCLIState(state *cliState) error {
fullState, err := loadFullCLIState()
if err != nil {
return err
}

_, socketPath := getEnvPaths()
fullState.Pebble[socketPath] = state

data, err := json.Marshal(fullState)
if err != nil {
return err
}

path := cliStatePath()
err = os.MkdirAll(filepath.Dir(path), 0o700)
if err != nil {
return err
}
err = os.WriteFile(path, data, 0o600)
if err != nil {
return err
}
return nil
}

func cliStatePath() string {
configDir := os.Getenv("XDG_CONFIG_HOME")
if configDir == "" {
configDir = os.ExpandEnv("$HOME/.config")
}
return filepath.Join(configDir, "pebble", "cli.json")
}
54 changes: 49 additions & 5 deletions internals/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,12 @@ func Test(t *testing.T) { TestingT(t) }

type BasePebbleSuite struct {
testutil.BaseTest
stdin *bytes.Buffer
stdout *bytes.Buffer
stderr *bytes.Buffer
password string
pebbleDir string
stdin *bytes.Buffer
stdout *bytes.Buffer
stderr *bytes.Buffer
password string
pebbleDir string
cliStatePath string

AuthFile string
}
Expand Down Expand Up @@ -56,6 +57,14 @@ func (s *BasePebbleSuite) SetUpTest(c *C) {
s.AddCleanup(cli.FakeIsStdinTTY(false))

os.Setenv("PEBBLE_LAST_WARNING_TIMESTAMP_FILENAME", filepath.Join(c.MkDir(), "warnings.json"))

oldConfigHome := os.Getenv("XDG_CONFIG_HOME")
s.AddCleanup(func() {
os.Setenv("XDG_CONFIG_HOME", oldConfigHome)
})
configHome := c.MkDir()
os.Setenv("XDG_CONFIG_HOME", configHome)
s.cliStatePath = filepath.Join(configHome, "pebble", "cli.json")
}

func (s *BasePebbleSuite) TearDownTest(c *C) {
Expand Down Expand Up @@ -158,3 +167,38 @@ func (s *PebbleSuite) TestGetEnvPaths(c *C) {
c.Assert(pebbleDir, Equals, "/bar")
c.Assert(socketPath, Equals, "/path/to/socket")
}

func (s *PebbleSuite) readCLIState(c *C) map[string]any {
data, err := os.ReadFile(s.cliStatePath)
c.Assert(err, IsNil)
var fullState map[string]any
err = json.Unmarshal(data, &fullState)
c.Assert(err, IsNil)

socketMap, ok := fullState["pebble"].(map[string]any)
if !ok {
c.Fatalf("expected socket map, got %#v", fullState["pebble"])
}

_, socketPath := cli.GetEnvPaths()
v, ok := socketMap[socketPath]
if !ok {
c.Fatalf("expected state map, got %#v", socketMap[socketPath])
}
return v.(map[string]any)
}

func (s *PebbleSuite) writeCLIState(c *C, st map[string]any) {
_, socketPath := cli.GetEnvPaths()
fullState := map[string]any{
"pebble": map[string]any{
socketPath: st,
},
}
err := os.MkdirAll(filepath.Dir(s.cliStatePath), 0o700)
c.Assert(err, IsNil)
data, err := json.Marshal(fullState)
c.Assert(err, IsNil)
err = os.WriteFile(s.cliStatePath, data, 0o600)
c.Assert(err, IsNil)
}
6 changes: 3 additions & 3 deletions internals/cli/cmd_help.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,9 @@ var HelpCategories = []HelpCategory{{
Description: "manage changes and their tasks",
Commands: []string{"changes", "tasks"},
}, {
Label: "Warnings",
Description: "manage warnings",
Commands: []string{"warnings", "okay"},
Label: "Notices",
Description: "manage notices and warnings",
Commands: []string{"warnings", "okay", "notices", "notice", "notify"},
}}

var (
Expand Down
1 change: 1 addition & 0 deletions internals/cli/cmd_logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ func (cmd *cmdLogs) Execute(args []string) error {
}

// Needed because signal.NotifyContext is Go 1.16+
// TODO(benhoyt): this can go away now that we're on Go 1.20
func notifyContext(parent context.Context, signals ...os.Signal) context.Context {
ctx, cancel := context.WithCancel(parent)
// Need a buffered channel in case the signal arrives between the
Expand Down
103 changes: 103 additions & 0 deletions internals/cli/cmd_notice.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright (c) 2023 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 (
"fmt"
"time"

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

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

const cmdNoticeSummary = "Fetch a single notice"
const cmdNoticeDescription = `
The notice command fetches a single notice, either by ID (1-arg variant), or
by unique type and key combination (2-arg variant).
`

type cmdNotice struct {
client *client.Client

Positional struct {
IDOrType string `positional-arg-name:"<id-or-type>" required:"1"`
Key string `positional-arg-name:"<key>"`
} `positional-args:"yes"`
}

func init() {
AddCommand(&CmdInfo{
Name: "notice",
Summary: cmdNoticeSummary,
Description: cmdNoticeDescription,
ArgsHelp: map[string]string{},
New: func(opts *CmdOptions) flags.Commander {
return &cmdNotice{client: opts.Client}
},
})
}

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

var notice *client.Notice
if cmd.Positional.Key != "" {
notices, err := cmd.client.Notices(&client.NoticesOptions{
Types: []client.NoticeType{client.NoticeType(cmd.Positional.IDOrType)},
Keys: []string{cmd.Positional.Key},
})
if err != nil {
return err
}
if len(notices) == 0 {
return fmt.Errorf("cannot find %s notice with key %q", cmd.Positional.IDOrType, cmd.Positional.Key)
}
notice = notices[0]
} else {
var err error
notice, err = cmd.client.Notice(cmd.Positional.IDOrType)
if err != nil {
return err
}
}

// Notice can be assigned directly to yamlNotice as only the tags are different.
yn := yamlNotice(*notice)

b, err := yaml.Marshal(yn)
if err != nil {
return err
}
fmt.Fprint(Stdout, string(b)) // yaml.Marshal includes the trailing newline
return nil
}

// yamlNotice exists to add "yaml" tags to the Notice fields.
type yamlNotice struct {
ID string `yaml:"id"`
Type client.NoticeType `yaml:"type"`
Key string `yaml:"key"`
FirstOccurred time.Time `yaml:"first-occurred"`
LastOccurred time.Time `yaml:"last-occurred"`
LastRepeated time.Time `yaml:"last-repeated"`
Occurrences int `yaml:"occurrences"`
LastData map[string]string `yaml:"last-data,omitempty"`
RepeatAfter time.Duration `yaml:"repeat-after,omitempty"`
ExpireAfter time.Duration `yaml:"expire-after,omitempty"`
}
Loading

0 comments on commit 010e042

Please sign in to comment.