Rename daemon CLI command to cache (#4808)

* internal/cmd/wrapper: add deprecation wrapper

The deprecation wrapper can be used to display a warning to callers
when a command is deprecated.

* all: rename daemon to cache

Rename the boundary daemon command to boundary cache. This
avoids confusion with future client/daemons.

* changes daemon to cache in nav

---------

Co-authored-by: Robin Beck <stellarsquall@protonmail.ch>
This commit is contained in:
Johan Brandhorst-SatzkornandRobin Beck authored and GitHub committed 2024-05-22 11:11:48 -07:00
1 parent be7d72d690
commit e64443c66f
34 files changed
+554 -299

No files matched your search

+213
View File
@@ -0,0 +1,213 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cache
import (
"context"
"errors"
"fmt"
"os"
"strings"
"github.com/hashicorp/boundary/api"
"github.com/hashicorp/boundary/internal/clientcache/internal/client"
"github.com/hashicorp/boundary/internal/clientcache/internal/daemon"
"github.com/hashicorp/boundary/internal/cmd/base"
"github.com/mitchellh/cli"
"github.com/posener/complete"
)
var (
_ cli.Command = (*AddTokenCommand)(nil)
_ cli.CommandAutocomplete = (*AddTokenCommand)(nil)
)
type AddTokenCommand struct {
*base.Command
}
func (c *AddTokenCommand) Synopsis() string {
return "Add an auth token to a running boundary cache"
}
func (c *AddTokenCommand) Help() string {
helpText := `
Usage: boundary cache add-token [options]
Add an auth token to the cache:
$ boundary cache add-token
For a full list of examples, please see the documentation.
` + c.Flags().Help()
return strings.TrimSpace(helpText)
}
func (c *AddTokenCommand) Flags() *base.FlagSets {
set := c.FlagSet(base.FlagSetOutputFormat)
f := set.NewFlagSet("Connection Options")
f.StringVar(&base.StringVar{
Name: base.FlagNameAddr,
Target: &c.FlagAddr,
EnvVar: api.EnvBoundaryAddr,
Completion: complete.PredictAnything,
Usage: "Addr of the Boundary controller, as a complete URL (e.g. https://boundary.example.com:9200).",
})
f = set.NewFlagSet("Client Options")
f.StringVar(&base.StringVar{
Name: "token-name",
Target: &c.FlagTokenName,
EnvVar: base.EnvTokenName,
Usage: `If specified, the given value will be used as the name when storing the token in the system credential store. This can allow switching user identities for different commands.`,
})
f.StringVar(&base.StringVar{
Name: "keyring-type",
Target: &c.FlagKeyringType,
Default: "auto",
EnvVar: base.EnvKeyringType,
Usage: `The type of keyring to use. Defaults to "auto" which will use the Windows credential manager, OSX keychain, or cross-platform password store depending on platform. Set to "none" to disable keyring functionality. Available types, depending on platform, are: "wincred", "keychain", "pass", and "secret-service".`,
})
f.StringVar(&base.StringVar{
Name: "token",
Target: &c.FlagToken,
Usage: `A URL pointing to a file on disk (file://) from which a token will be read or an env var (env://) from which the token will be read. Overrides the "token-name" parameter.`,
})
f.BoolVar(&base.BoolVar{
Name: "output-curl-string",
Target: &c.FlagOutputCurlString,
Usage: "Instead of executing the request, print an equivalent cURL command string and exit.",
})
return set
}
func (c *AddTokenCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
func (c *AddTokenCommand) AutocompleteFlags() complete.Flags {
return c.Flags().Completions()
}
func (c *AddTokenCommand) Run(args []string) int {
ctx := c.Context
f := c.Flags()
if err := f.Parse(args); err != nil {
c.PrintCliError(err)
return base.CommandUserError
}
client, err := c.Client()
if err != nil {
c.PrintCliError(err)
return base.CommandCliError
}
// We ignore the error here since not having a keyring on the platform
// returns an error but can be treated as the keyring type being set to
// none.
keyringType, tokenName, _ := c.DiscoverKeyringTokenInfo()
resp, apiErr, err := c.Add(ctx, c.UI, client, keyringType, tokenName)
if err != nil {
c.PrintCliError(err)
return base.CommandCliError
}
if apiErr != nil {
c.PrintApiError(apiErr, "Error from cache when adding a token")
return base.CommandApiError
}
switch base.Format(c.UI) {
case "json":
if ok := c.PrintJsonItem(resp); !ok {
return base.CommandCliError
}
case "table":
c.UI.Output("The cache add-token operation completed successfully.")
}
return base.CommandSuccess
}
// Add builds the UpsertTokenRequest using the client's address and token,
// and trying to leverage the keyring. It then sends the request to the cache.
// The passed in cli.Ui is used to print out any errors when looking up the
// auth token from the keyring. This allows background operations calling this
// method to pass in a silent UI to suppress any output.
func (c *AddTokenCommand) Add(ctx context.Context, ui cli.Ui, apiClient *api.Client, keyringType, tokenName string) (*api.Response, *api.Error, error) {
pa := daemon.UpsertTokenRequest{
BoundaryAddr: apiClient.Addr(),
}
token := apiClient.Token()
if token == "" {
return nil, nil, errors.New("The client auth token is empty.")
}
if parts := strings.SplitN(token, "_", 4); len(parts) == 3 {
pa.AuthTokenId = strings.Join(parts[:2], "_")
} else {
return nil, nil, errors.New("The client provided auth token is not in the proper format.")
}
var opts []client.Option
if c.FlagOutputCurlString {
opts = append(opts, client.WithOutputCurlString())
pa.AuthToken = "/*token*/"
} else {
pa.AuthToken = token
}
switch keyringType {
case "", base.NoneKeyring:
// we don't need to do anything else in this situation since the keyring
// doesn't play a part in the request.
default:
// Just because the keyring type is set doesn't mean the token to add
// is contained in it. For example, if the token was intercepted
// from an authentication request with '-format json' then token is
// not stored in the keyring, even if a keyring is provided.
// Try to read the token from the keyring in a best effort way. Ignore
// any errors since the keyring may not be present on the system.
at := base.ReadTokenFromKeyring(ui, keyringType, tokenName)
if at != nil && (token == "" || pa.AuthTokenId == at.Id) {
pa.Keyring = &daemon.KeyringToken{
KeyringType: keyringType,
TokenName: tokenName,
}
// since the auth token is stored in the keyring, we can share it
// through the keyring instead of passing it through the request.
pa.AuthToken = ""
}
}
dotPath, err := DefaultDotDirectory(ctx)
if err != nil {
return nil, nil, err
}
return addToken(ctx, dotPath, &pa, opts...)
}
func addToken(ctx context.Context, daemonPath string, p *daemon.UpsertTokenRequest, opt ...client.Option) (*api.Response, *api.Error, error) {
addr := daemon.SocketAddress(daemonPath)
_, err := os.Stat(addr.Path)
if addr.Scheme == "unix" && err != nil {
return nil, nil, fmt.Errorf("Error when detecting if the domain socket is present: %w.", err)
}
c, err := client.New(ctx, addr)
if err != nil {
return nil, nil, fmt.Errorf("Error when making a new client: %w.", err)
}
resp, err := c.Post(ctx, "/v1/tokens", p, opt...)
if err != nil {
return nil, nil, fmt.Errorf("Error when sending request to the cache: %w.", err)
}
apiErr, err := resp.Decode(nil)
return resp, apiErr, err
}
+60
View File
@@ -0,0 +1,60 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cache
import (
"strings"
"github.com/hashicorp/boundary/internal/cmd/base"
"github.com/mitchellh/cli"
"github.com/posener/complete"
)
var (
_ cli.Command = (*StartCommand)(nil)
_ cli.CommandAutocomplete = (*StartCommand)(nil)
)
type CacheCommand struct {
*base.Command
}
func (c *CacheCommand) Synopsis() string {
return "Manages the client side Boundary cache"
}
func (c *CacheCommand) Help() string {
helpText := `
Usage: boundary cache [sub command] [options]
This command allows interacting with the Boundary cache.
Start a cache:
$ boundary cache start
For a full list of examples, please see the documentation.
`
return strings.TrimSpace(helpText)
}
func (c *CacheCommand) Flags() *base.FlagSets {
return c.FlagSet(base.FlagSetNone)
}
func (c *CacheCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
func (c *CacheCommand) AutocompleteFlags() complete.Flags {
return c.Flags().Completions()
}
func (c *CacheCommand) Run(args []string) int {
if len(args) >= 2 && args[1] == "daemon" {
c.UI.Warn("The `boundary daemon` command is deprecated, use `boundary cache` instead")
}
return cli.RunResultHelp
}
+162
View File
@@ -0,0 +1,162 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cache
import (
"bytes"
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/hashicorp/boundary/internal/event"
"github.com/hashicorp/go-hclog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWithEventer(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
l, loc, err := logFile(ctx, dir, 1)
require.NoError(t, err)
assert.Equal(t, filepath.Join(dir, logFileName), loc)
t.Cleanup(func() {
assert.NoError(t, l.Close())
})
var logLock sync.Mutex
logger := hclog.New(&hclog.LoggerOptions{
Output: l,
Level: hclog.Debug,
JSONFormat: false,
Mutex: &logLock,
})
require.NoError(t, event.InitFallbackLogger(logger))
cfg := event.EventerConfig{
AuditEnabled: false,
ObservationsEnabled: true,
SysEventsEnabled: true,
Sinks: []*event.SinkConfig{
{
Name: "default",
EventTypes: []event.Type{event.EveryType},
Format: event.JSONHclogSinkFormat,
Type: event.WriterSink,
WriterConfig: &event.WriterSinkTypeConfig{
Writer: l,
},
},
},
}
require.NoError(t, event.InitSysEventer(logger, &logLock, "test", event.WithEventerConfig(&cfg)))
// Measured to have at least 1 record written in a rotated log file.
for i := 0; i < 8129; i++ {
event.WriteSysEvent(ctx, "test.caller", "this is a test event I am writing out.")
}
require.NoError(t, l.Close())
ret, err := os.ReadDir(dir)
require.NoError(t, err)
require.GreaterOrEqual(t, len(ret), 2)
t.Run("new logfile valid event", func(t *testing.T) {
out, err := os.ReadFile(filepath.Join(dir, logFileName))
require.NoError(t, err)
t.Logf("Got output of length %d", len(out))
jd := json.NewDecoder(bytes.NewReader(out))
m := make(map[string]interface{})
assert.NoError(t, jd.Decode(&m), fmt.Sprintf("parsing %q", out))
})
}
func TestRotation(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
l, loc, err := logFile(ctx, dir, 1)
require.NoError(t, err)
assert.Equal(t, filepath.Join(dir, logFileName), loc)
t.Cleanup(func() {
assert.NoError(t, l.Close())
})
fi, err := os.Stat(filepath.Join(dir, logFileName))
require.NoError(t, err)
assert.Zero(t, fi.Size())
assert.Equal(t, fs.FileMode(0o600), fi.Mode())
// write 1 mb and see it all contained in a single log file
toWrite := make([]byte, 1024)
for i := 0; i < 1024; i++ {
_, err := rand.Read(toWrite)
require.NoError(t, err)
_, err = l.Write(toWrite)
require.NoError(t, err)
}
ret, err := os.ReadDir(dir)
require.NoError(t, err)
require.Len(t, ret, 1)
assert.Equal(t, ret[0].Name(), logFileName)
// write 1 more byte and see that it caused the log file to rotate
_, err = l.Write([]byte("1"))
require.NoError(t, err)
ret, err = os.ReadDir(dir)
require.NoError(t, err)
// GreaterOrEqual is used here because while backups are compressed, both
// the renamed backup .log and the log.gz files exist
require.GreaterOrEqual(t, len(ret), 2)
assert.True(t, strings.HasPrefix(ret[0].Name(), "cache"))
assert.True(t, strings.HasPrefix(ret[1].Name(), "cache"))
// write another 1 mb and see a third log file is created
for i := 0; i < 1024; i++ {
_, err := rand.Read(toWrite)
require.NoError(t, err)
_, err = l.Write(toWrite)
require.NoError(t, err)
}
ret, err = os.ReadDir(dir)
require.NoError(t, err)
require.GreaterOrEqual(t, len(ret), 3)
// and a 4th (3 backups plus the cache.log file)
for i := 0; i < 1024; i++ {
_, err := rand.Read(toWrite)
require.NoError(t, err)
_, err = l.Write(toWrite)
require.NoError(t, err)
}
// Rotation causes the log files to be compressed after they are rotated.
// If the test ends before all the files have been compressed and the old
// .log files removed then cleanup fails with an error.
for {
ret, err = os.ReadDir(dir)
require.NoError(t, err)
require.GreaterOrEqual(t, len(ret), 4)
filtered := slices.DeleteFunc(ret, func(f fs.DirEntry) bool {
return strings.HasSuffix(f.Name(), "cache.log") ||
strings.HasSuffix(f.Name(), ".gz")
})
if len(filtered) == 0 {
// Indicates all the rotated log files have finished being
// compressed and can now be removed without new files being
// written to the directory.
break
}
time.Sleep(10 * time.Millisecond)
}
}
+69
View File
@@ -0,0 +1,69 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
//go:build !windows
// +build !windows
package cache
import (
"context"
stderrors "errors"
"os"
"github.com/hashicorp/boundary/internal/errors"
"github.com/sevlyar/go-daemon"
)
func writePidFile(ctx context.Context, pidFile string) (pidCleanup, error) {
const op = "cache.writePidFile"
// Determine if we should clean up the file after we are done or if
// it should stick around in the case of lock aquision error since this
// file didn't create it.
var pidExists bool
_, err := os.Stat(pidFile)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return noopPidCleanup, errors.Wrap(ctx, err, op)
}
if err == nil {
pidExists = true
}
f, err := os.OpenFile(pidFile, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return noopPidCleanup, errors.Wrap(ctx, err, op, errors.WithMsg("opening file"))
}
closeAndDeleteFileFn := func() error {
err := f.Close()
if !pidExists {
err = stderrors.Join(err, os.Remove(pidFile))
}
return err
}
l := daemon.NewLockFile(f)
if err := l.Lock(); err != nil {
return closeAndDeleteFileFn, errors.Wrap(ctx, err, op)
}
// Now that we have acquired the lock and verified we own the pid file
// we can remove it always when cleaning up.
unlockAndCleanFn := func() error {
return l.Remove()
}
if err := l.WritePid(); err != nil {
return unlockAndCleanFn, errors.Wrap(ctx, err, op)
}
return unlockAndCleanFn, nil
}
func pidFileInUse(ctx context.Context, pidFile string) (*os.Process, error) {
const op = "cache.pidFileInUse"
if pidFile == "" {
return nil, errors.New(ctx, errors.InvalidParameter, op, "pid filename is empty")
}
proc, err := (&daemon.Context{PidFileName: pidFile}).Search()
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
return proc, nil
}
+61
View File
@@ -0,0 +1,61 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cache
import (
"context"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func TestPidInUse(t *testing.T) {
ctx := context.Background()
dotPath := t.TempDir()
pidPath := filepath.Join(dotPath, "boundary.pid")
used, err := pidFileInUse(ctx, pidPath)
assert.NoError(t, err)
assert.Nil(t, used)
workingPidCleanup1, err := writePidFile(ctx, pidPath)
assert.NoError(t, err)
used, err = pidFileInUse(ctx, pidPath)
assert.NoError(t, err)
assert.NotNil(t, used)
failingPidCleanup, err := writePidFile(ctx, pidPath)
assert.Error(t, err)
used, err = pidFileInUse(ctx, pidPath)
assert.NoError(t, err)
assert.NotNil(t, used)
assert.NoError(t, failingPidCleanup())
used, err = pidFileInUse(ctx, pidPath)
assert.NoError(t, err)
assert.NotNil(t, used)
assert.NoError(t, workingPidCleanup1())
used, err = pidFileInUse(ctx, pidPath)
assert.NoError(t, err)
assert.Nil(t, used)
workingPidCleanup2, err := writePidFile(ctx, pidPath)
assert.NoError(t, err)
used, err = pidFileInUse(ctx, pidPath)
assert.NoError(t, err)
assert.NotNil(t, used)
assert.NoError(t, workingPidCleanup2())
used, err = pidFileInUse(ctx, pidPath)
assert.NoError(t, err)
assert.Nil(t, used)
}
+100
View File
@@ -0,0 +1,100 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
//go:build windows
// +build windows
package cache
import (
"bytes"
"context"
stderrors "errors"
"fmt"
"io"
"os"
"strings"
"github.com/hashicorp/boundary/internal/errors"
"golang.org/x/sys/windows"
)
func writePidFile(ctx context.Context, pidFile string) (pidCleanup, error) {
const op = "cache.writePidFile"
// Create the file for writing, and set shared read so following processes
// cannot open this file for writing.
fd, err := windows.CreateFile(&(windows.StringToUTF16(pidFile)[0]), windows.GENERIC_WRITE,
windows.FILE_SHARE_READ, nil, windows.OPEN_ALWAYS, windows.FILE_ATTRIBUTE_NORMAL, 0)
if err != nil {
return noopPidCleanup, errors.Wrap(ctx, err, op)
}
cleanup := func() error {
var err error
if inErr := windows.CloseHandle(fd); inErr != nil {
err = stderrors.Join(err, errors.Wrap(ctx, inErr, op, errors.WithMsg("handler close")))
}
if inErr := windows.DeleteFile(&(windows.StringToUTF16(pidFile)[0])); inErr != nil {
err = stderrors.Join(err, errors.Wrap(ctx, inErr, op, errors.WithMsg("removing file")))
}
return err
}
if _, err := windows.Seek(fd, 0, windows.FILE_BEGIN); err != nil {
return cleanup, errors.Wrap(ctx, err, op)
}
b := bytes.NewBuffer(nil)
if _, err := fmt.Fprint(b, os.Getpid()); err != nil {
return cleanup, errors.Wrap(ctx, err, op, errors.WithMsg("writing file buffer"))
}
var fileLen int
if fileLen, err = windows.Write(fd, b.Bytes()); err != nil {
return cleanup, errors.Wrap(ctx, err, op, errors.WithMsg("writing buffer to file"))
}
if err = windows.Ftruncate(fd, int64(fileLen)); err != nil {
return cleanup, errors.Wrap(ctx, err, op)
}
return cleanup, windows.Fsync(fd)
}
func pidFileInUse(ctx context.Context, pidFile string) (*os.Process, error) {
const op = "cache.pidFileInUse"
if pidFile == "" {
return nil, errors.New(ctx, errors.InvalidParameter, op, "pid filename is empty")
}
var err error
var file *os.File
if file, err = os.OpenFile(pidFile, os.O_RDONLY, 0o640); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("OpenFile"))
}
if file == nil {
return nil, nil
}
defer func() {
file.Close()
}()
if _, err = file.Seek(0, io.SeekStart); err != nil {
return nil, err
}
var pid int
_, err = fmt.Fscan(file, &pid)
if err != nil {
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("Fscan"))
}
p, err := os.FindProcess(pid)
if err != nil {
if strings.Contains(err.Error(), "The parameter is incorrect") {
return nil, errors.New(ctx, errors.NotFound, op, "cannot find process")
}
// we failed to get the process for whatever reason
return nil, errors.Wrap(ctx, err, op, errors.WithMsg("FindProcess %d", pid))
}
if p == nil {
return nil, errors.New(ctx, errors.NotFound, op, "cannot find process")
}
return p, nil
}
+338
View File
@@ -0,0 +1,338 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cache
import (
"context"
stderrors "errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"github.com/hashicorp/boundary/internal/clientcache/internal/daemon"
"github.com/hashicorp/boundary/internal/cmd/base"
"github.com/hashicorp/boundary/internal/errors"
"github.com/mitchellh/cli"
"github.com/mitchellh/go-homedir"
"github.com/posener/complete"
"gopkg.in/natefinch/lumberjack.v2"
)
const (
dotDirname = ".boundary"
pidFileName = "cache.pid"
logFileName = "cache.log"
// Mark of process as having been started in the background
backgroundEnvName = "_BOUNDARY_CACHE_BACKGROUND"
backgroundEnvVal = "1"
)
var (
_ cli.Command = (*StartCommand)(nil)
_ cli.CommandAutocomplete = (*StartCommand)(nil)
)
type StartCommand struct {
*base.Command
flagRefreshInterval time.Duration
flagRecheckSupportInterval time.Duration
flagMaxSearchStaleness time.Duration
flagMaxSearchRefreshTimeout time.Duration
flagDatabaseUrl string
flagLogLevel string
flagLogFormat string
flagStoreDebug bool
flagBackground bool
}
func (c *StartCommand) Synopsis() string {
return "Start a Boundary cache"
}
func (c *StartCommand) Help() string {
helpText := `
Usage: boundary cache start [options]
Start a cache:
$ boundary cache start
For a full list of examples, please see the documentation.
` + c.Flags().Help()
return strings.TrimSpace(helpText)
}
func (c *StartCommand) Flags() *base.FlagSets {
set := c.FlagSet(base.FlagSetNone)
f := set.NewFlagSet("Command Options")
f.StringVar(&base.StringVar{
Name: "log-level",
Target: &c.flagLogLevel,
EnvVar: "BOUNDARY_LOG_LEVEL",
Completion: complete.PredictSet("trace", "debug", "info", "warn", "err"),
Usage: "Log verbosity level, mostly as a fallback for events. Supported values (in order of more detail to less) are " +
"\"trace\", \"debug\", \"info\", \"warn\", and \"err\".",
})
f.StringVar(&base.StringVar{
Name: "log-format",
Target: &c.flagLogFormat,
Completion: complete.PredictSet("standard", "json"),
Usage: `Log format, mostly as a fallback for events. Supported values are "standard" and "json".`,
})
f.StringVar(&base.StringVar{
Name: "database-url",
Target: &c.flagDatabaseUrl,
Usage: `If set, specifies the URL used to connect to the sqlite database (store) for caching. This can refer to a file on disk (file://) from which a URL will be read; an env var (env://) from which the URL will be read; or a direct database URL.`,
Hidden: true,
})
f.DurationVar(&base.DurationVar{
Name: "refresh-interval",
Target: &c.flagRefreshInterval,
Usage: `Specifies the interval between refresh token supported cache refreshes.`,
Default: daemon.DefaultRefreshInterval,
})
f.DurationVar(&base.DurationVar{
Name: "recheck-support-interval",
Target: &c.flagRecheckSupportInterval,
Usage: `Specifies the interval between checking if a boundary instances is supported when it previously was not.`,
Default: daemon.DefaultRecheckSupportInterval,
Hidden: true,
})
f.DurationVar(&base.DurationVar{
Name: "max-search-staleness",
Target: &c.flagMaxSearchStaleness,
Usage: `Specifies the duration of time that can pass since the resource was last updated before performing a search waits for the resources being refreshed first.`,
Default: daemon.DefaultSearchStaleness,
})
f.DurationVar(&base.DurationVar{
Name: "max-search-refresh-timeout",
Target: &c.flagMaxSearchRefreshTimeout,
Usage: `If a search request triggers a best effort refresh, this specifies how long the refresh should run before timing out.`,
Default: daemon.DefaultSearchRefreshTimeout,
})
f.BoolVar(&base.BoolVar{
Name: "store-debug",
Target: &c.flagStoreDebug,
Default: false,
Usage: `Turn on sqlite query debugging. This is deprecated. Users should use -log-level=debug instead.`,
Aliases: []string{"d"},
Hidden: true,
})
f.BoolVar(&base.BoolVar{
Name: "background",
Target: &c.flagBackground,
Default: false,
Usage: `Run the cache daemon in the background`,
})
return set
}
func (c *StartCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
func (c *StartCommand) AutocompleteFlags() complete.Flags {
return c.Flags().Completions()
}
func (c *StartCommand) Run(args []string) int {
ctx, cancel := context.WithCancel(c.Context)
defer cancel()
var err error
f := c.Flags()
if err := f.Parse(args); err != nil {
c.PrintCliError(err)
return base.CommandUserError
}
dotDir, err := DefaultDotDirectory(ctx)
if err != nil {
return base.CommandCliError
}
if err := os.MkdirAll(dotDir, 0o700); err != nil {
c.PrintCliError(err)
return base.CommandCliError
}
continueRun, writers, cleanup, err := c.makeBackground(ctx, dotDir)
defer func() {
if cleanup != nil {
cleanup()
}
}()
if err != nil {
c.PrintCliError(err)
return base.CommandCliError
}
if !continueRun {
return base.CommandSuccess
}
// TODO: print something out for the spawner to consume in case they can easily
// report if the daemon started or not.
lf, logFileName, err := logFile(ctx, dotDir, 5)
if err != nil {
c.PrintCliError(err)
return base.CommandCliError
}
defer lf.Close()
writers = append(writers, lf)
if c.flagStoreDebug {
c.UI.Warn("The -store-debug flag is now ignored. Use -log-level=debug instead for debugging purposes.")
}
cfg := &daemon.Config{
ContextCancel: cancel,
RefreshInterval: c.flagRefreshInterval,
RecheckSupportInterval: c.flagRecheckSupportInterval,
MaxSearchStaleness: c.flagMaxSearchStaleness,
MaxSearchRefreshTimeout: c.flagMaxSearchRefreshTimeout,
DatabaseUrl: c.flagDatabaseUrl,
LogLevel: c.flagLogLevel,
LogFormat: c.flagLogFormat,
LogWriter: io.MultiWriter(writers...),
LogFileName: logFileName,
DotDirectory: dotDir,
RunningInBackground: os.Getenv(backgroundEnvName) == backgroundEnvVal,
}
srv, err := daemon.New(ctx, cfg)
if err != nil {
c.UI.Error(err.Error())
return base.CommandUserError
}
var srvErr error
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
srvErr = srv.Serve(ctx, c)
}()
// This is a blocking call. We rely on the c.ShutdownCh to cancel this
// context when sigterm or sigint is received.
<-ctx.Done()
if err := srv.Shutdown(ctx); err != nil {
c.PrintCliError(err)
return base.CommandCliError
}
wg.Wait()
if srvErr != nil {
c.PrintCliError(srvErr)
return base.CommandCliError
}
return base.CommandSuccess
}
// DefaultDotDirectory returns the default path to the boundary dot directory.
func DefaultDotDirectory(ctx context.Context) (string, error) {
const op = "cache.DefaultDotDirectory"
homeDir, err := homedir.Dir()
if err != nil {
return "", errors.Wrap(ctx, err, op)
}
return filepath.Join(homeDir, dotDirname), nil
}
// logFile returns a log file which is rotated after it reaches the provided
// maximum size in mb before being rotated out. The rotated out log file gets
// a suffix that matches the time that the rotation happened. Up to 3 log files
// are saved as backup. When a new log file is rotated and there is already 3
// backups created, the oldest one is deleted.
func logFile(ctx context.Context, dotDir string, maxSizeMb int) (io.WriteCloser, string, error) {
const op = "cache.logFile"
logFilePath := filepath.Join(dotDir, logFileName)
{
// Ensure the file is created with the desired permissions.
logFile, err := os.OpenFile(logFilePath, os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return nil, "", errors.Wrap(ctx, err, op)
}
logFile.Close()
}
logFile := &lumberjack.Logger{
Filename: logFilePath,
MaxSize: maxSizeMb,
MaxBackups: 3,
Compress: true,
}
return logFile, logFilePath, nil
}
func (c *StartCommand) makeBackground(ctx context.Context, dotDir string) (bool, []io.Writer, pidCleanup, error) {
const op = "cache.makeBackground"
writers := []io.Writer{}
pidPath := filepath.Join(dotDir, pidFileName)
if running, err := pidFileInUse(ctx, pidPath); running != nil {
return false, writers, noopPidCleanup, stderrors.New("The cache is already running.")
} else if err != nil && !errors.Match(errors.T(errors.NotFound), err) {
return false, writers, noopPidCleanup, fmt.Errorf("Error when checking if the cache pid is in use: %w.", err)
}
if !c.flagBackground && os.Getenv(backgroundEnvName) != backgroundEnvVal {
writers = append(writers, os.Stderr)
}
if !c.flagBackground || os.Getenv(backgroundEnvName) == backgroundEnvVal {
// We are either already running in the background or background was
// not requested. Write the pid file and continue.
cleanup, err := writePidFile(ctx, pidPath)
if err != nil {
return false, writers, noopPidCleanup, errors.Wrap(ctx, err, op)
}
return true, writers, cleanup, nil
}
absPath, err := os.Executable()
if err != nil {
return false, writers, noopPidCleanup, errors.Wrap(ctx, err, op)
}
env := os.Environ()
env = append(env, fmt.Sprintf("%s=%s", backgroundEnvName, backgroundEnvVal))
args := []string{"cache", "start"}
args = append(args, "-refresh-interval", c.flagRefreshInterval.String())
args = append(args, "-max-search-staleness", c.flagMaxSearchStaleness.String())
args = append(args, "-max-search-refresh-timeout", c.flagMaxSearchRefreshTimeout.String())
args = append(args, "-recheck-support-interval", c.flagRecheckSupportInterval.String())
if c.flagLogLevel != "" {
args = append(args, "-log-level", c.flagLogLevel)
}
if c.flagLogFormat != "" {
args = append(args, "-log-format", c.flagLogFormat)
}
if c.flagDatabaseUrl != "" {
args = append(args, "-database-url", c.flagDatabaseUrl)
}
cmd := exec.Command(absPath, args...)
cmd.Env = env
if err = cmd.Start(); err != nil {
return false, writers, noopPidCleanup, errors.Wrap(ctx, err, op)
}
// TODO: Read the output from the child process for a brief time
// to see if we can identify any errors that might arise.
return false, writers, noopPidCleanup, nil
}
type pidCleanup func() error
var noopPidCleanup pidCleanup = func() error { return nil }
+230
View File
@@ -0,0 +1,230 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cache
import (
"context"
stderr "errors"
"fmt"
"os"
"strings"
"time"
"github.com/hashicorp/boundary/api"
"github.com/hashicorp/boundary/internal/clientcache/internal/client"
"github.com/hashicorp/boundary/internal/clientcache/internal/daemon"
"github.com/hashicorp/boundary/internal/cmd/base"
"github.com/hashicorp/boundary/internal/errors"
"github.com/mitchellh/cli"
"github.com/posener/complete"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
var errCacheNotRunning = stderr.New("The cache process is not running.")
var (
_ cli.Command = (*AddTokenCommand)(nil)
_ cli.CommandAutocomplete = (*AddTokenCommand)(nil)
)
type StatusCommand struct {
*base.Command
}
func (c *StatusCommand) Synopsis() string {
return "Get the status information of the running boundary cache"
}
func (c *StatusCommand) Help() string {
helpText := `
Usage: boundary cache status [options]
Get the status of the boundary cache:
$ boundary cache status
For a full list of examples, please see the documentation.
` + c.Flags().Help()
return strings.TrimSpace(helpText)
}
func (c *StatusCommand) Flags() *base.FlagSets {
set := c.FlagSet(base.FlagSetOutputFormat)
f := set.NewFlagSet("Client Options")
f.BoolVar(&base.BoolVar{
Name: "output-curl-string",
Target: &c.FlagOutputCurlString,
Usage: "Instead of executing the request, print an equivalent cURL command string and exit.",
})
return set
}
func (c *StatusCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
func (c *StatusCommand) AutocompleteFlags() complete.Flags {
return c.Flags().Completions()
}
func (c *StatusCommand) Run(args []string) int {
ctx := c.Context
f := c.Flags()
if err := f.Parse(args); err != nil {
c.PrintCliError(err)
return base.CommandUserError
}
resp, result, apiErr, err := c.Status(ctx)
if err != nil {
c.PrintCliError(err)
return base.CommandCliError
}
if apiErr != nil {
c.PrintApiError(apiErr, "Error from cache when getting the status")
return base.CommandApiError
}
switch base.Format(c.UI) {
case "json":
if ok := c.PrintJsonItem(resp); !ok {
return base.CommandCliError
}
default:
c.UI.Output(printStatusTable(result))
}
return base.CommandSuccess
}
func (c *StatusCommand) Status(ctx context.Context) (*api.Response, *daemon.StatusResult, *api.Error, error) {
dotPath, err := DefaultDotDirectory(ctx)
if err != nil {
return nil, nil, nil, err
}
var opts []client.Option
if c.FlagOutputCurlString {
opts = append(opts, client.WithOutputCurlString())
}
return status(ctx, dotPath, opts...)
}
func status(ctx context.Context, daemonPath string, opt ...client.Option) (*api.Response, *daemon.StatusResult, *api.Error, error) {
const op = "cache.status"
addr := daemon.SocketAddress(daemonPath)
_, err := os.Stat(addr.Path)
if addr.Scheme == "unix" && err != nil {
return nil, nil, nil, errCacheNotRunning
}
c, err := client.New(ctx, addr)
if err != nil {
return nil, nil, nil, err
}
resp, err := c.Get(ctx, "/v1/status", nil, opt...)
if err != nil {
return nil, nil, nil, errors.Wrap(ctx, err, op, errors.WithMsg("client do failed"))
}
res := &daemon.StatusResult{}
apiErr, err := resp.Decode(&res)
if err != nil {
return nil, nil, nil, fmt.Errorf("Error when sending request to the cache: %w.", err)
}
if apiErr != nil {
return resp, nil, apiErr, nil
}
return resp, res, nil, nil
}
func printStatusTable(status *daemon.StatusResult) string {
nonAttributeMap := map[string]any{
"User Count": len(status.Users),
}
if len(status.SocketAddress) > 0 {
nonAttributeMap["Domain Socket"] = status.SocketAddress
}
if len(status.LogLocation) > 0 {
nonAttributeMap["Log Location"] = status.LogLocation
}
if len(status.Version) > 0 {
nonAttributeMap["Version"] = status.Version
}
if status.Uptime > 0 {
nonAttributeMap["Uptime"] = status.Uptime.Round(time.Second)
}
maxLength := base.MaxAttributesLength(nonAttributeMap, nil, nil)
ret := []string{
"",
"Status:",
base.WrapMap(2, maxLength+2, nonAttributeMap),
}
if len(status.Users) > 0 {
ret = append(ret, printUsersTable(status.Users)...)
}
return base.WrapForHelpText(ret)
}
func printUsersTable(us []daemon.UserStatus) []string {
ret := []string{
"",
}
for _, u := range us {
ret = append(ret, " User:")
nonAttributeMap := map[string]any{
"Id": u.Id,
"Address": u.BoundaryInstance.Address,
"AuthToken Count": len(u.AuthTokens),
"Search Support": u.BoundaryInstance.CacheSupport,
}
if u.BoundaryInstance.LastSupportCheck > 0 {
nonAttributeMap["Since Search Support Check"] = u.BoundaryInstance.LastSupportCheck.Round(time.Second)
}
maxLength := base.MaxAttributesLength(nonAttributeMap, nil, nil)
ret = append(ret,
base.WrapMap(4, maxLength+4, nonAttributeMap),
)
for _, at := range u.AuthTokens {
nonAttributeMap := map[string]any{
"Id": at.Id,
}
if at.KeyringReferences > 0 {
nonAttributeMap["In Keyring"] = "true"
}
maxLength := base.MaxAttributesLength(nonAttributeMap, nil, nil)
ret = append(ret,
" AuthToken:",
base.WrapMap(6, maxLength+6, nonAttributeMap),
)
}
for _, r := range u.Resources {
nonAttributeMap := map[string]any{
"Count": r.Count,
}
if r.RefreshToken != nil {
nonAttributeMap["Since Last Refresh"] = r.RefreshToken.LastUsed.Round(time.Second)
nonAttributeMap["Since Initial Fetch"] = r.RefreshToken.Age.Round(time.Second)
}
if r.LastError != nil {
nonAttributeMap["Since Last Error"] = r.LastError.LastReturned.Round(time.Second)
nonAttributeMap["Last Error Message"] = r.LastError.Error
}
maxLength := base.MaxAttributesLength(nonAttributeMap, nil, nil)
ret = append(ret,
fmt.Sprintf(" %s:", cases.Title(language.English).String(r.Name)),
base.WrapMap(6, maxLength+6, nonAttributeMap),
)
}
}
return ret
}
+70
View File
@@ -0,0 +1,70 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cache
import (
"context"
"strings"
"github.com/hashicorp/boundary/internal/cmd/base"
"github.com/hashicorp/boundary/internal/errors"
"github.com/posener/complete"
)
type StopCommand struct {
*base.Command
}
func (c *StopCommand) Synopsis() string {
return "Stop the Boundary cache"
}
func (c *StopCommand) Help() string {
helpText := `
Usage: boundary cache stop
Stop a cache:
$ boundary cache stop
For a full list of examples, please see the documentation.
` + c.Flags().Help()
return strings.TrimSpace(helpText)
}
func (c *StopCommand) Flags() *base.FlagSets {
set := c.FlagSet(base.FlagSetNone)
return set
}
func (c *StopCommand) AutocompleteArgs() complete.Predictor {
return complete.PredictNothing
}
func (c *StopCommand) AutocompleteFlags() complete.Flags {
return c.Flags().Completions()
}
func (c *StopCommand) Run(args []string) int {
ctx, cancel := context.WithCancel(c.Context)
c.Context = ctx
c.ContextCancel = cancel
f := c.Flags()
if err := f.Parse(args); err != nil {
c.PrintCliError(err)
return base.CommandUserError
}
if err := c.stop(c.Context); err != nil {
if errors.Match(errors.T(errors.NotFound), err) {
c.PrintCliError(errCacheNotRunning)
} else {
c.PrintCliError(err)
}
return base.CommandUserError
}
return base.CommandSuccess
}
+42
View File
@@ -0,0 +1,42 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
//go:build !windows
// +build !windows
package cache
import (
"context"
"errors"
"fmt"
"path/filepath"
"syscall"
"github.com/hashicorp/boundary/internal/util"
)
// stop will send a term signal to the cache to shut down.
func (c *StopCommand) stop(ctx context.Context) error {
switch {
case util.IsNil(ctx):
return errors.New("Invalid parameter provided to stop: context is missing")
}
dotPath, err := DefaultDotDirectory(ctx)
if err != nil {
return fmt.Errorf("Error when getting default dot directory: %w", err)
}
pidPath := filepath.Join(dotPath, pidFileName)
p, err := pidFileInUse(ctx, pidPath)
if err != nil {
return fmt.Errorf("Error when checking if the cache's pid file is in use: %w", err)
}
if p == nil {
return errors.New("The cache is not running.")
}
if err := p.Signal(syscall.SIGTERM); err != nil {
return fmt.Errorf("Error when sending sigterm to cache process: %w", err)
}
return nil
}
+80
View File
@@ -0,0 +1,80 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
//go:build windows
// +build windows
package cache
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/hashicorp/boundary/api"
"github.com/hashicorp/boundary/internal/clientcache/internal/client"
"github.com/hashicorp/boundary/internal/clientcache/internal/daemon"
"github.com/hashicorp/boundary/internal/util"
)
// stop will send a term signal to the cache to shut down.
func (c *StopCommand) stop(ctx context.Context) error {
switch {
case util.IsNil(ctx):
return errors.New("Invalid parameter provided to stop: context is missing")
}
dotPath, err := DefaultDotDirectory(ctx)
if err != nil {
return fmt.Errorf("Error when getting default dot directory: %w", err)
}
apiErr, err := stopThroughHandler(ctx, dotPath)
switch {
case err != nil, apiErr != nil:
var errMsg string
if err != nil {
errMsg = err.Error()
} else if apiErr != nil {
errMsg = apiErr.Message
}
c.UI.Warn(fmt.Sprintf("Failed stopping the cache through the handler: %q, now killing the process", errMsg))
default:
// there wasn't an error stopping it through the handler. No need to
// force kill the process
return nil
}
pidPath := filepath.Join(dotPath, pidFileName)
p, err := pidFileInUse(ctx, pidPath)
if err != nil {
if os.IsNotExist(err) {
return errors.New("Unable to stop the cache: pid file not found.")
}
return fmt.Errorf("Error when trying to stop the cache: %w", err)
}
if p == nil {
return errors.New("Cache process was not found.")
}
if err := p.Kill(); err != nil {
return fmt.Errorf("Error when killing the process: %w.", err)
}
return nil
}
func stopThroughHandler(ctx context.Context, dotPath string) (*api.Error, error) {
addr := daemon.SocketAddress(dotPath)
c, err := client.New(ctx, addr)
if err != nil {
return nil, err
}
resp, err := c.Post(ctx, "/v1/stop", nil)
if err != nil {
return nil, fmt.Errorf("Error when sending request to the cache: %w.", err)
}
apiErr, err := resp.Decode(nil)
return apiErr, err
}
+133
View File
@@ -0,0 +1,133 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package cache
import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/hashicorp/boundary/internal/clientcache/internal/daemon"
"github.com/hashicorp/boundary/internal/cmd/base"
"github.com/hashicorp/boundary/internal/cmd/wrapper"
"github.com/mitchellh/cli"
)
func init() {
if err := wrapper.RegisterSuccessfulCommandCallback("clientcache", hook); err != nil {
panic(err)
}
}
// hook is the callback that is registered with the wrapper package to be called.
// The daemon is not started and the token is not added to the cache if the flag
// SkipCacheDaemon is set.
func hook(ctx context.Context, baseCmd *base.Command, token string) {
if baseCmd.FlagSkipCacheDaemon {
return
}
if startDaemon(ctx, baseCmd) {
addTokenToCache(ctx, baseCmd, token)
}
}
// startDaemon attempts to start a daemon and returns true if we have attempted to start
// the daemon and either it was successful or it was already running.
func startDaemon(ctx context.Context, baseCmd *base.Command) bool {
// Ignore errors related to checking if the process is already running since
// this can fall back to running the process.
if dotPath, err := DefaultDotDirectory(ctx); err == nil {
pidPath := filepath.Join(dotPath, pidFileName)
if running, _ := pidFileInUse(ctx, pidPath); running != nil {
// return true since it is already running, no need to run it again.
return true
}
}
cmdName, err := os.Executable()
if err != nil {
baseCmd.UI.Error(fmt.Sprintf("unable to find boundary binary for cache startup: %s", err.Error()))
return false
}
var stdErr bytes.Buffer
cmd := exec.Command(cmdName, "cache", "start", "-background")
cmd.Stderr = &stdErr
// We use Run here instead of Start because the command spawns off a subprocess and returns.
// We do not want to send the request to add a persona to the cache until we know the daemon
// has started up.
err = cmd.Run()
return err == nil || strings.Contains(stdErr.String(), "already running")
}
// silentUi should not be used in situations where the UI is expected to be
// prompt the user for input.
func silentUi() *cli.BasicUi {
return &cli.BasicUi{
Writer: io.Discard,
ErrorWriter: io.Discard,
}
}
// addTokenToCache runs AddTokenCommand with the token used in, or retrieved by
// the wrapped command.
func addTokenToCache(ctx context.Context, baseCmd *base.Command, token string) bool {
com := AddTokenCommand{Command: base.NewCommand(baseCmd.UI)}
client, err := baseCmd.Client()
if err != nil {
return false
}
keyringType, tokName, err := baseCmd.DiscoverKeyringTokenInfo()
if err != nil && token == "" {
return false
}
if token != "" {
client.SetToken(token)
}
// Since the daemon might have just started, we need to wait until it can
// respond to our requests
waitCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
if err := waitForDaemon(waitCtx); err != nil {
// TODO: Print the result of this out into a log in the dot directory
return false
}
// We do not want to print errors out from our background interactions with
// the daemon so use the silentUi to toss out anything that shouldn't be used
_, apiErr, err := com.Add(ctx, silentUi(), client, keyringType, tokName)
return err == nil && apiErr == nil
}
// waitForDaemon continually looks for the unix socket until it is found or the
// provided context is done. It returns an error if the unix socket is not found
// before the context is done.
func waitForDaemon(ctx context.Context) error {
dotPath, err := DefaultDotDirectory(ctx)
if err != nil {
return err
}
timer := time.NewTimer(0)
addr := daemon.SocketAddress(dotPath)
_, err = os.Stat(addr.Path)
for os.IsNotExist(err) {
select {
case <-timer.C:
case <-ctx.Done():
return ctx.Err()
}
_, err = os.Stat(addr.Path)
timer.Reset(10 * time.Millisecond)
}
return nil
}