Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cli): add CLI commands for notices (notices, notice, notify) #298

Merged
merged 59 commits into from
Nov 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
7fbc993
feat(state): add core support for Pebble Notices
benhoyt Sep 4, 2023
79bed4e
Make AddNotice return the notice ID so POST /v1/notices can return it
benhoyt Sep 5, 2023
1b5f188
Clean up tests a bit
benhoyt Sep 5, 2023
a973de6
feat(daemon): add Pebble Notices API
benhoyt Sep 5, 2023
313c894
Tweak comment wording
benhoyt Sep 5, 2023
70f8a7d
Merge branch 'notices-state' into notices-api
benhoyt Sep 5, 2023
41bd09b
feat(client): add Go client for notices
benhoyt Sep 5, 2023
4de78c2
feat(cli): add CLI commands for notices (notices, ack, notice, notify)
benhoyt Sep 6, 2023
a44bedc
Spacing and other tweaks
benhoyt Sep 6, 2023
fde015f
Ensure client uses full nanosecond-resultion time for "after"
benhoyt Sep 6, 2023
f462099
Save last notice ID in state to avoid accidental reuse
benhoyt Sep 6, 2023
709ac37
Note that WaitNotices returns nil slice if timeout elapses
benhoyt Sep 6, 2023
31b6bb7
Comment tweaks per Fred's review; tweaks to repeatAfter logic
benhoyt Sep 14, 2023
a555411
More slight comment tweaks
benhoyt Sep 14, 2023
acde18d
Merge branch 'notices-state' into notices-api
benhoyt Sep 15, 2023
b0e88f8
Make client key regexp a bit tighter (and add ^ and $ anchors!)
benhoyt Sep 15, 2023
7c7a143
Make /v1/notices timeout return empty list of notices (not an error)
benhoyt Sep 15, 2023
54036a2
Merge branch 'notices-api' into notices-cli
benhoyt Sep 15, 2023
8ab3981
Update as /v1/notices no longer returns HTTP gateway timeout
benhoyt Sep 15, 2023
1787922
Merge branch 'notices-cli' into notices-commands
benhoyt Sep 15, 2023
455d6c0
Add "Notices" section to README
benhoyt Sep 15, 2023
c4c6429
Tweak change-update comment
benhoyt Sep 15, 2023
1624a5e
Tweak notices help
benhoyt Sep 15, 2023
f1263d3
Reorder WaitNotices args so options are last; allow nil NoticesOptions
benhoyt Sep 22, 2023
28ef773
Merge branch 'notices-cli' into notices-commands
benhoyt Sep 22, 2023
5f6320d
Fix WaitNotices arg order after merge
benhoyt Sep 22, 2023
4d25e3c
Updates: client->custom, repeat-after=0 means always, multi types&keys
benhoyt Sep 27, 2023
de13c72
Merge branch 'notices-state' into notices-api
benhoyt Sep 27, 2023
85efd41
Updates per spec review
benhoyt Sep 27, 2023
29cf984
Remove max notice length from state (enforce in API)
benhoyt Sep 27, 2023
286b0fd
Merge branch 'notices-state' into notices-api
benhoyt Sep 27, 2023
6383cae
Merge branch 'notices-api' into notices-cli
benhoyt Sep 27, 2023
49e2a86
Updated from spec review
benhoyt Sep 27, 2023
702269e
Merge branch 'notices-cli' into notices-commands
benhoyt Sep 27, 2023
64c7dd7
Updated per spec review
benhoyt Sep 27, 2023
1fa3639
Updates from Gustavo's code review
benhoyt Oct 3, 2023
580a95a
Merge branch 'notices-state' into notices-api
benhoyt Oct 3, 2023
7840544
Updates after merge
benhoyt Oct 3, 2023
403be41
Merge branch 'notices-api' into notices-cli
benhoyt Oct 3, 2023
97f9af1
Move okay command to its own file, include notice ack in okay
benhoyt Oct 3, 2023
f69171d
Renames as per state changes
benhoyt Oct 3, 2023
69ba0ea
Merge branch 'notices-cli' into notices-commands
benhoyt Oct 3, 2023
b715b16
Merge branch 'master' into notices-api
benhoyt Oct 6, 2023
01dd5ee
Merge branch 'notices-api' into notices-cli
benhoyt Oct 6, 2023
5eff73f
Merge branch 'notices-cli' into notices-commands
benhoyt Oct 6, 2023
eeaaae0
Merge branch 'master' into notices-cli
benhoyt Oct 13, 2023
32a1540
Updates per code review
benhoyt Oct 13, 2023
6d74d8e
Merge branch 'notices-cli' into notices-commands
benhoyt Oct 16, 2023
814f5bd
Fixes per notices-cli code review
benhoyt Oct 16, 2023
34a6a47
Merge branch 'master' into notices-commands
benhoyt Oct 16, 2023
83dc6e6
A few tweaks to the README
benhoyt Oct 17, 2023
4727063
Can assign Notice to yamlNotice directly
benhoyt Oct 17, 2023
7b77eb8
Merge branch 'master' into notices-commands
benhoyt Oct 19, 2023
6dc8a08
Code review updates (except the changes to notices.json handling)
benhoyt Oct 27, 2023
d657039
Simplify notices.json handling: just put it at $PEBBLE/notices.json
benhoyt Oct 27, 2023
e6a2af6
Per review discussion, use ~/.config/pebble/cli.json for CLI state
benhoyt Oct 31, 2023
674b9b1
Change Occ back to Occurences
benhoyt Oct 31, 2023
8e608ce
Prefix notices fields with "notices-" in CLI state struct.
benhoyt Nov 16, 2023
95bc70b
Merge branch 'master' into notices-commands
benhoyt Nov 16, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,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).
benhoyt marked this conversation as resolved.
Show resolved Hide resolved
`

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
Loading