From f1cec3934a1ec8559f4c5fc63c254d6b488a7674 Mon Sep 17 00:00:00 2001 From: Achille Roussel Date: Tue, 30 May 2023 16:52:16 -0700 Subject: [PATCH] timecraft: add configuration file Signed-off-by: Achille Roussel --- internal/cmd/config.go | 227 +++++++++++++++++++++++++++++++++++++++ internal/cmd/describe.go | 14 +-- internal/cmd/export.go | 17 ++- internal/cmd/get.go | 15 +-- internal/cmd/help.go | 7 ++ internal/cmd/profile.go | 24 +++-- internal/cmd/replay.go | 24 ++--- internal/cmd/root.go | 37 +------ internal/cmd/run.go | 32 +++--- 9 files changed, 300 insertions(+), 97 deletions(-) create mode 100644 internal/cmd/config.go diff --git a/internal/cmd/config.go b/internal/cmd/config.go new file mode 100644 index 00000000..7f93cd92 --- /dev/null +++ b/internal/cmd/config.go @@ -0,0 +1,227 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + + "github.com/stealthrocket/timecraft/internal/object" + "github.com/stealthrocket/timecraft/internal/print/human" + "github.com/stealthrocket/timecraft/internal/timemachine" + "gopkg.in/yaml.v3" +) + +const configUsage = ` +Usage: timecraft config [options] + +Options: + -c, --config Path to the timecraft configuration file (overrides TIMECRAFTCONFIG) + --edit Open $EDITOR to edit the configuration + -h, --help Show usage information + -o, --ouptut format Output format, one of: text, json, yaml +` + +var ( + // Path to the timecraft configuration; this is set to the value of the + // TIMECRAFTCONFIG environment variable if it exists, and can be overwritten + // by the --config option. + configPath human.Path = "~/.timecraft/config.yaml" +) + +func init() { + if v := os.Getenv("TIMECRAFTCONFIG"); v != "" { + configPath = human.Path(v) + } +} + +func config(ctx context.Context, args []string) error { + var ( + edit bool + output = outputFormat("text") + ) + + flagSet := newFlagSet("timecraft config", configUsage) + boolVar(flagSet, &edit, "edit") + customVar(flagSet, &output, "o", "output") + parseFlags(flagSet, args) + + r, path, err := openConfig() + if err != nil { + return err + } + defer r.Close() + + if edit { + editor := os.Getenv("EDITOR") + if editor == "" { + return errors.New(`$EDITOR is not set`) + } + shell := os.Getenv("SHELL") + if shell == "" { + shell = "/bin/sh" + } + + if err := os.MkdirAll(filepath.Dir(path), 0777); err != nil { + if !errors.Is(err, fs.ErrExist) { + return err + } + } + + tmp, err := createTempFile(path, r) + if err != nil { + return err + } + defer os.Remove(tmp) + + p, err := os.StartProcess(shell, []string{shell, "-c", editor + " " + tmp}, &os.ProcAttr{ + Files: []*os.File{ + 0: os.Stdin, + 1: os.Stdout, + 2: os.Stderr, + }, + }) + if err != nil { + return err + } + if _, err := p.Wait(); err != nil { + return err + } + f, err := os.Open(tmp) + if err != nil { + return err + } + defer f.Close() + if _, err := readConfig(f); err != nil { + return fmt.Errorf("not applying configuration updates because the file has a syntax error: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + return err + } + } + + config, err := loadConfig() + if err != nil { + return err + } + + w := io.Writer(os.Stdout) + for { + switch output { + case "json": + e := json.NewEncoder(w) + e.SetEscapeHTML(false) + e.SetIndent("", " ") + _ = e.Encode(config) + case "yaml": + e := yaml.NewEncoder(w) + e.SetIndent(2) + _ = e.Encode(config) + _ = e.Close() + default: + r, _, err := openConfig() + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + output = "yaml" + continue + } + return err + } + defer r.Close() + _, _ = io.Copy(w, r) + } + return nil + } +} + +type configuration struct { + Registry struct { + Location string `json:"location"` + } `json:"registry"` +} + +func defaultConfig() *configuration { + c := new(configuration) + c.Registry.Location = "~/.timecraft" + return c +} + +func openConfig() (io.ReadCloser, string, error) { + path, err := configPath.Resolve() + if err != nil { + return nil, path, err + } + f, err := os.Open(path) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return nil, path, err + } + c := defaultConfig() + b, _ := yaml.Marshal(c) + return io.NopCloser(bytes.NewReader(b)), path, nil + } + return f, path, nil +} + +func loadConfig() (*configuration, error) { + r, _, err := openConfig() + if err != nil { + return nil, err + } + defer r.Close() + return readConfig(r) +} + +func readConfig(r io.Reader) (*configuration, error) { + c := defaultConfig() + d := yaml.NewDecoder(r) + d.KnownFields(true) + if err := d.Decode(c); err != nil { + return nil, err + } + return c, nil +} + +func (c *configuration) createRegistry() (*timemachine.Registry, error) { + p, err := human.Path(c.Registry.Location).Resolve() + if err != nil { + return nil, err + } + if err := os.Mkdir(filepath.Dir(p), 0777); err != nil { + if !errors.Is(err, fs.ErrExist) { + return nil, err + } + } + return c.openRegistry() +} + +func (c *configuration) openRegistry() (*timemachine.Registry, error) { + p, err := human.Path(c.Registry.Location).Resolve() + if err != nil { + return nil, err + } + store, err := object.DirStore(p) + if err != nil { + return nil, err + } + registry := &timemachine.Registry{ + Store: store, + } + return registry, nil +} + +func createTempFile(path string, r io.Reader) (string, error) { + dir, file := filepath.Split(path) + w, err := os.CreateTemp(dir, "."+file+".*") + if err != nil { + return "", err + } + defer w.Close() + _, err = io.Copy(w, r) + return w.Name(), err +} diff --git a/internal/cmd/describe.go b/internal/cmd/describe.go index 4667786d..e89ef16d 100644 --- a/internal/cmd/describe.go +++ b/internal/cmd/describe.go @@ -47,20 +47,18 @@ Examples: 0 27 1 1.68 KiB 3.88 KiB 1.62 KiB 58.27% Options: + -c, --config Path to the timecraft configuration file (overrides TIMECRAFTCONFIG) -h, --help Show this usage information -o, --ouptut format Output format, one of: text, json, yaml - -r, --registry path Path to the timecraft registry (default to ~/.timecraft) ` func describe(ctx context.Context, args []string) error { var ( - output = outputFormat("text") - registryPath = human.Path("~/.timecraft") + output = outputFormat("text") ) flagSet := newFlagSet("timecraft describe", describeUsage) customVar(flagSet, &output, "o", "output") - customVar(flagSet, ®istryPath, "r", "registry") args = parseFlags(flagSet, args) if len(args) == 0 { @@ -71,13 +69,15 @@ func describe(ctx context.Context, args []string) error { if err != nil { return err } - resourceIDs := args[1:] if len(resourceIDs) == 0 { return fmt.Errorf(`no resources were specified, use 'timecraft describe %s '`, resource.typ) } - - registry, err := openRegistry(registryPath) + config, err := loadConfig() + if err != nil { + return err + } + registry, err := config.openRegistry() if err != nil { return err } diff --git a/internal/cmd/export.go b/internal/cmd/export.go index 620fea6e..3dfa5785 100644 --- a/internal/cmd/export.go +++ b/internal/cmd/export.go @@ -8,7 +8,6 @@ import ( "github.com/google/uuid" "github.com/stealthrocket/timecraft/format" - "github.com/stealthrocket/timecraft/internal/print/human" ) const exportUsage = ` @@ -23,17 +22,12 @@ Usage: timecraft export resource to stdout. Options: - -h, --help Show this usage information - -r, --registry path Path to the timecraft registry (default to ~/.timecraft) + -c, --config Path to the timecraft configuration file (overrides TIMECRAFTCONFIG) + -h, --help Show this usage information ` func export(ctx context.Context, args []string) error { - var ( - registryPath = human.Path("~/.timecraft") - ) - flagSet := newFlagSet("timecraft export", exportUsage) - customVar(flagSet, ®istryPath, "r", "registry") args = parseFlags(flagSet, args) if len(args) != 3 { @@ -44,8 +38,11 @@ func export(ctx context.Context, args []string) error { if err != nil { return err } - - registry, err := openRegistry(registryPath) + config, err := loadConfig() + if err != nil { + return err + } + registry, err := config.openRegistry() if err != nil { return err } diff --git a/internal/cmd/get.go b/internal/cmd/get.go index 412e9884..0585f70c 100644 --- a/internal/cmd/get.go +++ b/internal/cmd/get.go @@ -45,9 +45,9 @@ Examples: } Options: + -c, --config Path to the timecraft configuration file (overrides TIMECRAFTCONFIG) -h, --help Show this usage information -o, --ouptut format Output format, one of: text, json, yaml - -r, --registry path Path to the timecraft registry (default to ~/.timecraft) ` type resource struct { @@ -116,14 +116,12 @@ var resources = [...]resource{ func get(ctx context.Context, args []string) error { var ( - timeRange = timemachine.Since(time.Unix(0, 0)) - output = outputFormat("text") - registryPath = human.Path("~/.timecraft") + timeRange = timemachine.Since(time.Unix(0, 0)) + output = outputFormat("text") ) flagSet := newFlagSet("timecraft get", getUsage) customVar(flagSet, &output, "o", "output") - customVar(flagSet, ®istryPath, "r", "registry") args = parseFlags(flagSet, args) if len(args) != 1 { @@ -133,8 +131,11 @@ func get(ctx context.Context, args []string) error { if err != nil { return err } - - registry, err := openRegistry(registryPath) + config, err := loadConfig() + if err != nil { + return err + } + registry, err := config.openRegistry() if err != nil { return err } diff --git a/internal/cmd/help.go b/internal/cmd/help.go index e84e5102..f76c248e 100644 --- a/internal/cmd/help.go +++ b/internal/cmd/help.go @@ -21,9 +21,14 @@ Debugging Commands: profile Generate performance profile from execution records Other Commands: + config View or edit the timecraft configuration help Show usage information about timecraft commands version Show the timecraft version information +Global Options: + -c, --config Path to the timecraft configuration file (overrides TIMECRAFTCONFIG) + -h, --help Show usage information + For a description of each command, run 'timecraft help '.` func help(ctx context.Context, args []string) error { @@ -41,6 +46,8 @@ func help(ctx context.Context, args []string) error { } switch cmd { + case "config": + msg = configUsage case "describe": msg = describeUsage case "export": diff --git a/internal/cmd/profile.go b/internal/cmd/profile.go index 4d78d7da..8050c93c 100644 --- a/internal/cmd/profile.go +++ b/internal/cmd/profile.go @@ -47,6 +47,7 @@ Example: (web page opens in browser) Options: + -c, --config Path to the timecraft configuration file (overrides TIMECRAFTCONFIG) -d, --duration duration Amount of time that the profiler will be running for (default to the process up time) --export type:path Exports the generated profiles, type is one of cpu or memory (may be repeated) -h, --help Show this usage information @@ -57,11 +58,10 @@ Options: func profile(ctx context.Context, args []string) error { var ( - exports = stringMap{} - output = outputFormat("text") - startTime = human.Time{} - duration = human.Duration(1 * time.Minute) - registryPath = human.Path("~/.timecraft") + exports = stringMap{} + output = outputFormat("text") + startTime = human.Time{} + duration = human.Duration(1 * time.Minute) ) flagSet := newFlagSet("timecraft profile", profileUsage) @@ -69,7 +69,6 @@ func profile(ctx context.Context, args []string) error { customVar(flagSet, &output, "o", "output") customVar(flagSet, &duration, "d", "duration") customVar(flagSet, &startTime, "t", "start-time") - customVar(flagSet, ®istryPath, "r", "registry") args = parseFlags(flagSet, args) if len(args) != 1 { @@ -91,8 +90,11 @@ func profile(ctx context.Context, args []string) error { if err != nil { return errors.New(`malformed process id passed as argument (not a UUID)`) } - - registry, err := openRegistry(registryPath) + config, err := loadConfig() + if err != nil { + return err + } + registry, err := config.openRegistry() if err != nil { return err } @@ -113,11 +115,11 @@ func profile(ctx context.Context, args []string) error { if err != nil { return err } - config, err := registry.LookupConfig(ctx, process.Config.Digest) + processConfig, err := registry.LookupConfig(ctx, process.Config.Digest) if err != nil { return err } - module, err := registry.LookupModule(ctx, config.Modules[0].Digest) + module, err := registry.LookupModule(ctx, processConfig.Modules[0].Digest) if err != nil { return err } @@ -167,7 +169,7 @@ func profile(ctx context.Context, args []string) error { hostModuleInstance := wazergo.MustInstantiate(ctx, runtime, hostModule, wasi_snapshot_preview1.WithWASI(system)) ctx = wazergo.WithModuleInstance(ctx, hostModuleInstance) - if err := exec(ctx, runtime, compiledModule); err != nil { + if err := instantiate(ctx, runtime, compiledModule); err != nil { return err } diff --git a/internal/cmd/replay.go b/internal/cmd/replay.go index edb1aec1..55e08dbc 100644 --- a/internal/cmd/replay.go +++ b/internal/cmd/replay.go @@ -9,7 +9,6 @@ import ( "github.com/google/uuid" "github.com/stealthrocket/wasi-go" - "github.com/stealthrocket/timecraft/internal/print/human" "github.com/stealthrocket/timecraft/internal/timemachine" "github.com/stealthrocket/timecraft/internal/timemachine/wasicall" "github.com/stealthrocket/wasi-go/imports/wasi_snapshot_preview1" @@ -21,19 +20,17 @@ const replayUsage = ` Usage: timecraft replay [options] Options: - -h, --help Show this usage information - -r, --registry path Path to the timecraft registry (default to ~/.timecraft) - -T, --trace Enable strace-like logging of host function calls + -c, --config Path to the timecraft configuration file (overrides TIMECRAFTCONFIG) + -h, --help Show this usage information + -T, --trace Enable strace-like logging of host function calls ` func replay(ctx context.Context, args []string) error { var ( - registryPath = human.Path("~/.timecraft") - trace = false + trace = false ) flagSet := newFlagSet("timecraft replay", replayUsage) - customVar(flagSet, ®istryPath, "r", "registry") boolVar(flagSet, &trace, "T", "trace") args = parseFlags(flagSet, args) @@ -45,8 +42,11 @@ func replay(ctx context.Context, args []string) error { if err != nil { return errors.New(`malformed process id passed as argument (not a UUID)`) } - - registry, err := openRegistry(registryPath) + config, err := loadConfig() + if err != nil { + return err + } + registry, err := config.openRegistry() if err != nil { return err } @@ -59,11 +59,11 @@ func replay(ctx context.Context, args []string) error { if err != nil { return err } - config, err := registry.LookupConfig(ctx, process.Config.Digest) + processConfig, err := registry.LookupConfig(ctx, process.Config.Digest) if err != nil { return err } - module, err := registry.LookupModule(ctx, config.Modules[0].Digest) + module, err := registry.LookupModule(ctx, processConfig.Modules[0].Digest) if err != nil { return err } @@ -104,5 +104,5 @@ func replay(ctx context.Context, args []string) error { hostModuleInstance := wazergo.MustInstantiate(ctx, runtime, hostModule, wasi_snapshot_preview1.WithWASI(system)) ctx = wazergo.WithModuleInstance(ctx, hostModuleInstance) - return exec(ctx, runtime, compiledModule) + return instantiate(ctx, runtime, compiledModule) } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 6741e3ef..38e4b75a 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -18,19 +18,13 @@ package cmd import ( "context" - "errors" "flag" "fmt" "io" - "io/fs" "log" _ "net/http/pprof" - "os" "strings" - "github.com/stealthrocket/timecraft/internal/object" - "github.com/stealthrocket/timecraft/internal/print/human" - "github.com/stealthrocket/timecraft/internal/timemachine" "golang.org/x/exp/maps" "golang.org/x/exp/slices" ) @@ -78,6 +72,8 @@ func Root(ctx context.Context, args ...string) int { var err error cmd, args := args[0], args[1:] switch cmd { + case "config": + err = config(ctx, args) case "describe": err = describe(ctx, args) case "export": @@ -185,37 +181,10 @@ func (m stringMap) Set(value string) error { return nil } -func createRegistry(path human.Path) (*timemachine.Registry, error) { - p, err := path.Resolve() - if err != nil { - return nil, err - } - if err := os.Mkdir(p, 0777); err != nil { - if !errors.Is(err, fs.ErrExist) { - return nil, err - } - } - return openRegistry(human.Path(p)) -} - -func openRegistry(path human.Path) (*timemachine.Registry, error) { - p, err := path.Resolve() - if err != nil { - return nil, err - } - store, err := object.DirStore(p) - if err != nil { - return nil, err - } - registry := &timemachine.Registry{ - Store: store, - } - return registry, nil -} - func newFlagSet(cmd, usage string) *flag.FlagSet { flagSet := flag.NewFlagSet(cmd, flag.ExitOnError) flagSet.Usage = func() { fmt.Println(usage) } + customVar(flagSet, &configPath, "c", "config") return flagSet } diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 3690bf9b..c5ba81aa 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -11,7 +11,6 @@ import ( "time" "github.com/google/uuid" - "github.com/stealthrocket/timecraft/format" "github.com/stealthrocket/timecraft/internal/object" "github.com/stealthrocket/timecraft/internal/print/human" @@ -19,7 +18,6 @@ import ( "github.com/stealthrocket/timecraft/internal/timemachine/wasicall" "github.com/stealthrocket/wasi-go" "github.com/stealthrocket/wasi-go/imports" - "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/sys" ) @@ -28,6 +26,7 @@ const runUsage = ` Usage: timecraft run [options] [--] [args...] Options: + -c, --config Path to the timecraft configuration file (overrides TIMECRAFTCONFIG) -D, --dial addr Expose a socket connected to the specified address -e, --env name=value Pass an environment variable to the guest module -h, --help Show this usage information @@ -36,21 +35,19 @@ Options: -R, --record Enable recording of the guest module execution --record-batch-size size Number of records written per batch (default to 4096) --record-compression type Compression to use when writing records, either snappy or zstd (default to zstd) - -r, --registry path Path to the timecraft registry (default to ~/.timecraft) -T, --trace Enable strace-like logging of host function calls ` func run(ctx context.Context, args []string) error { var ( - envs stringList - listens stringList - dials stringList - batchSize = human.Count(4096) - compression = compression("zstd") - sockets = sockets("auto") - registryPath = human.Path("~/.timecraft") - record = false - trace = false + envs stringList + listens stringList + dials stringList + batchSize = human.Count(4096) + compression = compression("zstd") + sockets = sockets("auto") + record = false + trace = false ) flagSet := newFlagSet("timecraft run", runUsage) @@ -58,7 +55,6 @@ func run(ctx context.Context, args []string) error { customVar(flagSet, &listens, "L", "listen") customVar(flagSet, &dials, "D", "dial") customVar(flagSet, &sockets, "S", "sockets") - customVar(flagSet, ®istryPath, "r", "registry") boolVar(flagSet, &trace, "T", "trace") boolVar(flagSet, &record, "R", "record") customVar(flagSet, &batchSize, "record-batch-size") @@ -72,7 +68,11 @@ func run(ctx context.Context, args []string) error { return errors.New(`missing "--" separator before the module path`) } - registry, err := createRegistry(registryPath) + config, err := loadConfig() + if err != nil { + return err + } + registry, err := config.createRegistry() if err != nil { return err } @@ -201,10 +201,10 @@ func run(ctx context.Context, args []string) error { } defer system.Close(ctx) - return exec(ctx, runtime, wasmModule) + return instantiate(ctx, runtime, wasmModule) } -func exec(ctx context.Context, runtime wazero.Runtime, compiledModule wazero.CompiledModule) error { +func instantiate(ctx context.Context, runtime wazero.Runtime, compiledModule wazero.CompiledModule) error { module, err := runtime.InstantiateModule(ctx, compiledModule, wazero.NewModuleConfig(). WithStartFunctions()) if err != nil {