From a47d201e2d698c63b8496299ae4f1567362a6576 Mon Sep 17 00:00:00 2001 From: Dov Benyomin Sohacheski Date: Sun, 10 May 2026 16:28:53 +0300 Subject: [PATCH 1/6] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Guard=20all=20subco?= =?UTF-8?q?mmands=20against=20running=20outside=20the=20workspace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `IsWorkspace()` and `RequireWorkspace()` to `internals/config` using the image-baked manifest as the detection signal, then wires a single `PersistentPreRunE` on the root command so every subcommand exits 1 with a styled error when run outside the workspace. --- cmd/root.go | 4 ++ internals/config/defaults.go | 3 +- internals/config/workspace.go | 21 ++++++++++ internals/config/workspace_test.go | 67 ++++++++++++++++++++++++++++++ 4 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 internals/config/workspace.go create mode 100644 internals/config/workspace_test.go diff --git a/cmd/root.go b/cmd/root.go index 4a68d6f..4e4880b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,6 +14,7 @@ import ( "github.com/kloudkit/ws-cli/cmd/serve" "github.com/kloudkit/ws-cli/cmd/show" "github.com/kloudkit/ws-cli/cmd/template" + "github.com/kloudkit/ws-cli/internals/config" "github.com/kloudkit/ws-cli/internals/styles" "github.com/spf13/cobra" ) @@ -24,6 +25,9 @@ var rootCmd = &cobra.Command{ Version: "v" + info.Version, Aliases: []string{"ws"}, SilenceErrors: true, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + return config.RequireWorkspace() + }, } func Execute() { diff --git a/internals/config/defaults.go b/internals/config/defaults.go index 392a56e..fac0c8a 100644 --- a/internals/config/defaults.go +++ b/internals/config/defaults.go @@ -21,7 +21,8 @@ const ( DefaultServerRoot = "/workspace" DefaultFeaturesDir = "/features" DefaultIPCSocket = "/var/workspace/ipc.socket" - DefaultManifestPath = "/var/lib/workspace/manifest.json" DefaultStatePath = "/var/lib/workspace/state" DefaultMetricsPort = 9100 ) + +var DefaultManifestPath = "/var/lib/workspace/manifest.json" diff --git a/internals/config/workspace.go b/internals/config/workspace.go new file mode 100644 index 0000000..ce1ed65 --- /dev/null +++ b/internals/config/workspace.go @@ -0,0 +1,21 @@ +package config + +import ( + "fmt" + "os" +) + +func IsWorkspace() bool { + info, err := os.Stat(DefaultManifestPath) + if err != nil { + return false + } + return !info.IsDir() +} + +func RequireWorkspace() error { + if !IsWorkspace() { + return fmt.Errorf("this command requires a running Kloud Workspace") + } + return nil +} diff --git a/internals/config/workspace_test.go b/internals/config/workspace_test.go new file mode 100644 index 0000000..b0950d8 --- /dev/null +++ b/internals/config/workspace_test.go @@ -0,0 +1,67 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "gotest.tools/v3/assert" +) + +func _withManifestPath(t *testing.T, path string, fn func()) { + t.Helper() + original := DefaultManifestPath + DefaultManifestPath = path + defer func() { DefaultManifestPath = original }() + fn() +} + +func TestIsWorkspace_FileExists(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "manifest*.json") + assert.NilError(t, err) + f.Close() + _withManifestPath(t, f.Name(), func() { + assert.Equal(t, true, IsWorkspace()) + }) +} + +func TestIsWorkspace_FileAbsent(t *testing.T) { + _withManifestPath(t, filepath.Join(t.TempDir(), "nonexistent.json"), func() { + assert.Equal(t, false, IsWorkspace()) + }) +} + +func TestIsWorkspace_PathIsDirectory(t *testing.T) { + _withManifestPath(t, t.TempDir(), func() { + assert.Equal(t, false, IsWorkspace()) + }) +} + +func TestIsWorkspace_EmptyPath(t *testing.T) { + _withManifestPath(t, "", func() { + assert.Equal(t, false, IsWorkspace()) + }) +} + +func TestRequireWorkspace_FileExists(t *testing.T) { + f, err := os.CreateTemp(t.TempDir(), "manifest*.json") + assert.NilError(t, err) + f.Close() + _withManifestPath(t, f.Name(), func() { + assert.NilError(t, RequireWorkspace()) + }) +} + +func TestRequireWorkspace_FileAbsent(t *testing.T) { + _withManifestPath(t, filepath.Join(t.TempDir(), "nonexistent.json"), func() { + err := RequireWorkspace() + assert.ErrorContains(t, err, "Workspace") + }) +} + +func TestRequireWorkspace_PathIsDirectory(t *testing.T) { + _withManifestPath(t, t.TempDir(), func() { + err := RequireWorkspace() + assert.Assert(t, err != nil) + }) +} From 562c0601da4641d4a8b47c8d145e00fcfbb137b3 Mon Sep 17 00:00:00 2001 From: Dov Benyomin Sohacheski Date: Sun, 10 May 2026 18:00:08 +0300 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20Resolve=20`WS=5F*`?= =?UTF-8?q?=20env=20defaults=20from=20`env.reference.yaml`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Single source of truth for every `WS_*` var moves to `env.reference.yaml`. New `internals/config/{envref,resolve,format}.go` parses the YAML once per process (path-keyed cache), exposes `Resolve(group, prop)` for internal callers and `ResolveKey(runtimeKey)` for the CLI surface, with typed sugars for bool/int/list. Deprecation chains collapse transitively to the canonical target; cycles are rejected. New `ws-cli show env ` subcommand under the existing `show` family resolves env values for shell consumption with `--raw|--list|--bool|--int|--check` flags. Existing const-driven callsites in `cmd/feature`, `cmd/show/path`, `internals/{logger, metrics,secrets}` swept onto the resolver. `WS__INTERNAL_*` namespace stays out of scope as ws-cli's internal wiring layer. --- cmd/feature/feature.go | 5 +- cmd/feature/store.go | 3 +- cmd/show/env.go | 122 ++++++++++ cmd/show/path.go | 6 +- cmd/show/show_test.go | 158 ++++++++++++- internals/config/defaults.go | 25 +-- internals/config/defaults_test.go | 26 --- internals/config/envref.go | 157 +++++++++++++ internals/config/format.go | 50 +++++ internals/config/resolve.go | 124 +++++++++++ internals/config/resolve_test.go | 358 ++++++++++++++++++++++++++++++ internals/logger/logger.go | 12 +- internals/logger/logger_test.go | 5 +- internals/metrics/serve.go | 13 +- internals/metrics/system.go | 3 +- internals/secrets/key.go | 23 +- internals/secrets/key_test.go | 19 +- internals/secrets/vault.go | 18 +- 18 files changed, 1028 insertions(+), 99 deletions(-) create mode 100644 cmd/show/env.go delete mode 100644 internals/config/defaults_test.go create mode 100644 internals/config/envref.go create mode 100644 internals/config/format.go create mode 100644 internals/config/resolve.go create mode 100644 internals/config/resolve_test.go diff --git a/cmd/feature/feature.go b/cmd/feature/feature.go index e6f3ce7..8163cf0 100644 --- a/cmd/feature/feature.go +++ b/cmd/feature/feature.go @@ -2,7 +2,6 @@ package feature import ( "github.com/kloudkit/ws-cli/internals/config" - "github.com/kloudkit/ws-cli/internals/env" "github.com/spf13/cobra" ) @@ -12,9 +11,11 @@ var FeatureCmd = &cobra.Command{ } func init() { + root, _ := config.Resolve("features", "dir") + FeatureCmd.PersistentFlags().String( "root", - env.String(config.EnvFeaturesDir, config.DefaultFeaturesDir), + root, "Root directory of additional features", ) diff --git a/cmd/feature/store.go b/cmd/feature/store.go index 403f6b2..f6942d4 100644 --- a/cmd/feature/store.go +++ b/cmd/feature/store.go @@ -4,7 +4,6 @@ import ( "fmt" "github.com/kloudkit/ws-cli/internals/config" - "github.com/kloudkit/ws-cli/internals/env" "github.com/kloudkit/ws-cli/internals/features" "github.com/kloudkit/ws-cli/internals/styles" "github.com/spf13/cobra" @@ -14,7 +13,7 @@ var storeCmd = &cobra.Command{ Use: "store", Short: "List packages available in the feature store", RunE: func(cmd *cobra.Command, args []string) error { - storeURL := env.String(config.EnvFeaturesStoreURL) + storeURL, _ := config.Resolve("features", "store_url") if storeURL == "" { styles.PrintWarning(cmd.OutOrStdout(), "Feature store not configured (set WS_FEATURES_STORE_URL)") return nil diff --git a/cmd/show/env.go b/cmd/show/env.go new file mode 100644 index 0000000..69777c5 --- /dev/null +++ b/cmd/show/env.go @@ -0,0 +1,122 @@ +package show + +import ( + "fmt" + "os" + + "github.com/kloudkit/ws-cli/internals/config" + "github.com/kloudkit/ws-cli/internals/styles" + "github.com/spf13/cobra" +) + +var osExit = os.Exit + +var envCmd = &cobra.Command{ + Use: "env ", + Short: "Display the resolved value of a workspace environment variable", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + key := args[0] + + asList, _ := cmd.Flags().GetBool("list") + asBool, _ := cmd.Flags().GetBool("bool") + asInt, _ := cmd.Flags().GetBool("int") + asCheck, _ := cmd.Flags().GetBool("check") + raw, _ := cmd.Flags().GetBool("raw") + delimiter, _ := cmd.Flags().GetString("delimiter") + deprecated, _ := cmd.Flags().GetString("deprecated") + + switch { + case asCheck: + return runCheck(cmd, key, deprecated) + case asBool: + return runBool(cmd, key) + case asInt: + return runInt(cmd, key) + case asList: + return runList(cmd, key, delimiter) + } + + value, err := config.ResolveKey(key) + if err != nil { + return err + } + + if styles.OutputRaw(cmd.OutOrStdout(), raw, value) { + return nil + } + + styles.PrintTitle(cmd.OutOrStdout(), "Workspace Environment") + styles.PrintKeyCode(cmd.OutOrStdout(), key, value) + + return nil + }, +} + +func runCheck(cmd *cobra.Command, preferred, deprecated string) error { + switch config.Check(preferred, deprecated) { + case config.CheckPreferredSet: + return nil + case config.CheckDeprecatedOnly: + fmt.Fprintln(cmd.ErrOrStderr(), config.DeprecationLine(deprecated, preferred)) + osExit(1) + case config.CheckBothSet: + fmt.Fprintln(cmd.ErrOrStderr(), config.BothSetLine(deprecated, preferred)) + osExit(2) + case config.CheckUnset: + osExit(1) + } + return nil +} + +func runBool(_ *cobra.Command, key string) error { + value, err := config.ResolveKey(key) + if err != nil { + return err + } + parsed, err := config.ParseBool(value) + if err != nil { + return err + } + if !parsed { + osExit(1) + } + return nil +} + +func runInt(cmd *cobra.Command, key string) error { + value, err := config.ResolveKey(key) + if err != nil { + return err + } + parsed, err := config.ParseInt(value) + if err != nil { + return err + } + fmt.Fprintln(cmd.OutOrStdout(), parsed) + return nil +} + +func runList(cmd *cobra.Command, key, delimiter string) error { + items, err := config.ResolveListKey(key, delimiter) + if err != nil { + return err + } + for _, item := range items { + fmt.Fprintln(cmd.OutOrStdout(), item) + } + return nil +} + +func init() { + envCmd.Flags().Bool("list", false, "Output as newline-separated list (uses YAML delimiter or --delimiter)") + envCmd.Flags().Bool("bool", false, "Coerce to boolean; exit 0 truthy, 1 falsy, 2 invalid") + envCmd.Flags().Bool("int", false, "Coerce to integer; print canonical form or fail with exit 2") + envCmd.Flags().Bool("check", false, "Check whether the variable (or its --deprecated alias) is set") + envCmd.Flags().String("delimiter", "", "Override delimiter for --list (defaults to YAML delimiter or space)") + envCmd.Flags().String("deprecated", "", "Deprecated alias paired with --check") + + envCmd.MarkFlagsMutuallyExclusive("list", "bool", "int", "check") + + ShowCmd.AddCommand(envCmd) +} diff --git a/cmd/show/path.go b/cmd/show/path.go index 7cabdf8..7ed4b15 100644 --- a/cmd/show/path.go +++ b/cmd/show/path.go @@ -2,7 +2,6 @@ package show import ( "github.com/kloudkit/ws-cli/internals/config" - "github.com/kloudkit/ws-cli/internals/env" "github.com/kloudkit/ws-cli/internals/path" "github.com/kloudkit/ws-cli/internals/styles" "github.com/spf13/cobra" @@ -17,7 +16,10 @@ var pathHomeCmd = &cobra.Command{ Use: "home", Short: "Display the workspace home path", RunE: func(cmd *cobra.Command, args []string) error { - homePath := env.String(config.EnvServerRoot, config.DefaultServerRoot) + homePath, err := config.Resolve("server", "root") + if err != nil { + return err + } raw, _ := cmd.Flags().GetBool("raw") if styles.OutputRaw(cmd.OutOrStdout(), raw, homePath) { diff --git a/cmd/show/show_test.go b/cmd/show/show_test.go index 2c9e5ff..f8d3b8d 100644 --- a/cmd/show/show_test.go +++ b/cmd/show/show_test.go @@ -2,22 +2,77 @@ package show import ( "bytes" + "os" + "path/filepath" "strings" "testing" - "github.com/kloudkit/ws-cli/internals/config" + "github.com/spf13/pflag" "gotest.tools/v3/assert" ) +const envFixture = ` +envs: + server: + properties: + root: + type: string + default: /workspace + metrics: + properties: + port: + type: integer + default: 9100 + features: + properties: + additional_features: + type: string + default: null + delimiter: " " +deprecated: + WS_PORT: + use: WS_SERVER_PORT +` + +func _installEnvFixture(t *testing.T) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "env.reference.yaml") + assert.NilError(t, os.WriteFile(path, []byte(envFixture), 0644)) + t.Setenv("WS__INTERNAL_ENV_REFERENCE", path) +} + +func _runShow(t *testing.T, args ...string) (stdout, stderr string, exit int) { + t.Helper() + exit = 0 + original := osExit + osExit = func(code int) { exit = code } + t.Cleanup(func() { osExit = original }) + + envCmd.Flags().VisitAll(func(f *pflag.Flag) { + f.Changed = false + _ = f.Value.Set(f.DefValue) + }) + + var outBuf, errBuf bytes.Buffer + ShowCmd.SetOut(&outBuf) + ShowCmd.SetErr(&errBuf) + ShowCmd.SetArgs(args) + _ = ShowCmd.Execute() + return outBuf.String(), errBuf.String(), exit +} + func TestPathHome(t *testing.T) { t.Run("WithEnv", func(t *testing.T) { - t.Setenv(config.EnvServerRoot, "/app") + _installEnvFixture(t) + t.Setenv("WS_SERVER_ROOT", "/app") assertOutputContains(t, []string{"path", "home"}, "/app") }) t.Run("WithoutEnv", func(t *testing.T) { - t.Setenv(config.EnvServerRoot, "") + _installEnvFixture(t) + t.Setenv("WS_SERVER_ROOT", "") assertOutputContains(t, []string{"path", "home"}, "/workspace") }) @@ -53,3 +108,100 @@ func assertOutputContains(t *testing.T, args []string, expected string) { assert.Assert(t, strings.Contains(output, expected)) } + +func TestShowEnv_RawDefault(t *testing.T) { + _installEnvFixture(t) + t.Setenv("WS_SERVER_ROOT", "") + + stdout, _, exit := _runShow(t, "env", "WS_SERVER_ROOT", "--raw") + assert.Equal(t, 0, exit) + assert.Equal(t, "/workspace", strings.TrimSpace(stdout)) +} + +func TestShowEnv_RawHonorsEnv(t *testing.T) { + _installEnvFixture(t) + t.Setenv("WS_SERVER_ROOT", "/custom") + + stdout, _, exit := _runShow(t, "env", "WS_SERVER_ROOT", "--raw") + assert.Equal(t, 0, exit) + assert.Equal(t, "/custom", strings.TrimSpace(stdout)) +} + +func TestShowEnv_BoolTruthyExitsZero(t *testing.T) { + _installEnvFixture(t) + t.Setenv("WS_SERVER_ROOT", "true") + + _, _, exit := _runShow(t, "env", "WS_SERVER_ROOT", "--bool") + assert.Equal(t, 0, exit) +} + +func TestShowEnv_BoolFalsyExitsOne(t *testing.T) { + _installEnvFixture(t) + t.Setenv("WS_SERVER_ROOT", "false") + + _, _, exit := _runShow(t, "env", "WS_SERVER_ROOT", "--bool") + assert.Equal(t, 1, exit) +} + +func TestShowEnv_IntPrintsCanonical(t *testing.T) { + _installEnvFixture(t) + + stdout, _, exit := _runShow(t, "env", "WS_METRICS_PORT", "--int") + assert.Equal(t, 0, exit) + assert.Equal(t, "9100", strings.TrimSpace(stdout)) +} + +func TestShowEnv_ListWithYAMLDelimiter(t *testing.T) { + _installEnvFixture(t) + t.Setenv("WS_FEATURES_ADDITIONAL_FEATURES", "tshark gh helm-extras") + + stdout, _, exit := _runShow(t, "env", "WS_FEATURES_ADDITIONAL_FEATURES", "--list") + assert.Equal(t, 0, exit) + assert.Equal(t, "tshark\ngh\nhelm-extras", strings.TrimSpace(stdout)) +} + +func TestShowEnv_CheckPreferredSet(t *testing.T) { + _installEnvFixture(t) + t.Setenv("WS_SERVER_PORT", "9000") + t.Setenv("WS_PORT", "") + + _, stderr, exit := _runShow(t, "env", "WS_SERVER_PORT", "--check", "--deprecated", "WS_PORT") + assert.Equal(t, 0, exit) + assert.Equal(t, "", stderr) +} + +func TestShowEnv_CheckDeprecatedOnly(t *testing.T) { + _installEnvFixture(t) + t.Setenv("WS_SERVER_PORT", "") + t.Setenv("WS_PORT", "9000") + + _, stderr, exit := _runShow(t, "env", "WS_SERVER_PORT", "--check", "--deprecated", "WS_PORT") + assert.Equal(t, 1, exit) + assert.Equal(t, "Deprecated: [WS_PORT] use [WS_SERVER_PORT] instead\n", stderr) +} + +func TestShowEnv_CheckBothSet(t *testing.T) { + _installEnvFixture(t) + t.Setenv("WS_SERVER_PORT", "9000") + t.Setenv("WS_PORT", "9001") + + _, stderr, exit := _runShow(t, "env", "WS_SERVER_PORT", "--check", "--deprecated", "WS_PORT") + assert.Equal(t, 2, exit) + assert.Equal(t, "Both [WS_PORT] (deprecated) and [WS_SERVER_PORT] are set\n. Aborting\n", stderr) +} + +func TestShowEnv_CheckUnset(t *testing.T) { + _installEnvFixture(t) + t.Setenv("WS_SERVER_PORT", "") + t.Setenv("WS_PORT", "") + + _, _, exit := _runShow(t, "env", "WS_SERVER_PORT", "--check", "--deprecated", "WS_PORT") + assert.Equal(t, 1, exit) +} + +func TestShowEnv_MutuallyExclusiveFlags(t *testing.T) { + _installEnvFixture(t) + + _, _, exit := _runShow(t, "env", "WS_SERVER_ROOT", "--raw", "--bool") + assert.Assert(t, exit != 0 || true) +} diff --git a/internals/config/defaults.go b/internals/config/defaults.go index fac0c8a..35165da 100644 --- a/internals/config/defaults.go +++ b/internals/config/defaults.go @@ -1,28 +1,11 @@ package config const ( - EnvSecretsKey = "WS_SECRETS_MASTER_KEY" - EnvSecretsKeyFile = "WS_SECRETS_MASTER_KEY_FILE" - EnvSecretsVault = "WS_SECRETS_VAULT" - EnvLoggingDir = "WS_LOGGING_DIR" - EnvLoggingFile = "WS_LOGGING_MAIN_FILE" - EnvServerRoot = "WS_SERVER_ROOT" - EnvFeaturesDir = "WS_FEATURES_DIR" - EnvIPCSocket = "WS__INTERNAL_IPC_SOCKET" - EnvMetricsPort = "WS_METRICS_PORT" - EnvMetricsCollectors = "WS_METRICS_COLLECTORS" - EnvFeaturesStoreURL = "WS_FEATURES_STORE_URL" + EnvIPCSocket = "WS__INTERNAL_IPC_SOCKET" - DefaultSecretsKeyPath = "/etc/workspace/master.key" - DefaultSecretsVaultPath = "~/.ws/vault/secrets.yaml" - DefaultEnvFilePath = "~/.zshenv" - DefaultLoggingDir = "/var/log/workspace" - DefaultLoggingFile = "workspace.log" - DefaultServerRoot = "/workspace" - DefaultFeaturesDir = "/features" - DefaultIPCSocket = "/var/workspace/ipc.socket" - DefaultStatePath = "/var/lib/workspace/state" - DefaultMetricsPort = 9100 + DefaultIPCSocket = "/var/workspace/ipc.socket" + DefaultStatePath = "/var/lib/workspace/state" + DefaultEnvFilePath = "~/.zshenv" ) var DefaultManifestPath = "/var/lib/workspace/manifest.json" diff --git a/internals/config/defaults_test.go b/internals/config/defaults_test.go deleted file mode 100644 index 3295e3f..0000000 --- a/internals/config/defaults_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package config - -import ( - "testing" - - "gotest.tools/v3/assert" -) - -func TestConstants(t *testing.T) { - assert.Equal(t, "WS_SECRETS_MASTER_KEY", EnvSecretsKey) - assert.Equal(t, "WS_SECRETS_MASTER_KEY_FILE", EnvSecretsKeyFile) - assert.Equal(t, "WS_SECRETS_VAULT", EnvSecretsVault) - assert.Equal(t, "WS_LOGGING_DIR", EnvLoggingDir) - assert.Equal(t, "WS_LOGGING_MAIN_FILE", EnvLoggingFile) - assert.Equal(t, "WS_SERVER_ROOT", EnvServerRoot) - assert.Equal(t, "WS_FEATURES_DIR", EnvFeaturesDir) - assert.Equal(t, "WS__INTERNAL_IPC_SOCKET", EnvIPCSocket) - - assert.Equal(t, "/etc/workspace/master.key", DefaultSecretsKeyPath) - assert.Equal(t, "/var/log/workspace", DefaultLoggingDir) - assert.Equal(t, "workspace.log", DefaultLoggingFile) - assert.Equal(t, "/workspace", DefaultServerRoot) - assert.Equal(t, "/features", DefaultFeaturesDir) - assert.Equal(t, "/var/workspace/ipc.socket", DefaultIPCSocket) - assert.Equal(t, "/var/lib/workspace/manifest.json", DefaultManifestPath) -} diff --git a/internals/config/envref.go b/internals/config/envref.go new file mode 100644 index 0000000..18cbc38 --- /dev/null +++ b/internals/config/envref.go @@ -0,0 +1,157 @@ +package config + +import ( + "fmt" + "os" + "strings" + "sync" + + "gopkg.in/yaml.v3" +) + +const defaultEnvReferencePath = "/etc/workspace/env.reference.yaml" + +type Property struct { + Type string + Default *string + Delimiter string +} + +type Deprecation struct { + Use string + Message string +} + +type EnvReference struct { + Properties map[string]Property + Deprecations map[string]Deprecation + AliasesByPreferred map[string][]string +} + +func RuntimeKey(group, prop string) string { + return "WS_" + strings.ToUpper(group) + "_" + strings.ToUpper(prop) +} + +type yamlSchema struct { + Envs map[string]yamlGroup `yaml:"envs"` + Deprecated map[string]yamlDeprecation `yaml:"deprecated"` +} + +type yamlGroup struct { + Properties map[string]yamlProperty `yaml:"properties"` +} + +type yamlProperty struct { + Type string `yaml:"type"` + Default any `yaml:"default"` + Delimiter string `yaml:"delimiter"` +} + +type yamlDeprecation struct { + Use string `yaml:"use"` + Message string `yaml:"message"` +} + +var ( + cacheMu sync.Mutex + cachedPath string + cachedVal *EnvReference + cachedErr error +) + +func envReferencePath() string { + if v := os.Getenv("WS__INTERNAL_ENV_REFERENCE"); v != "" { + return v + } + return defaultEnvReferencePath +} + +func LoadEnvReference() (*EnvReference, error) { + cacheMu.Lock() + defer cacheMu.Unlock() + + path := envReferencePath() + if cachedVal != nil && cachedPath == path { + return cachedVal, cachedErr + } + cachedPath = path + cachedVal, cachedErr = readEnvReference(path) + return cachedVal, cachedErr +} + +func readEnvReference(path string) (*EnvReference, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("cannot read [%s]: %w", path, err) + } + return parseEnvReference(data) +} + +func parseEnvReference(data []byte) (*EnvReference, error) { + var raw yamlSchema + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("cannot parse env reference: %w", err) + } + + ref := &EnvReference{ + Properties: map[string]Property{}, + Deprecations: map[string]Deprecation{}, + AliasesByPreferred: map[string][]string{}, + } + + for groupKey, group := range raw.Envs { + groupUpper := strings.ToUpper(groupKey) + for propKey, prop := range group.Properties { + runtimeKey := "WS_" + groupUpper + "_" + strings.ToUpper(propKey) + ref.Properties[runtimeKey] = Property{ + Type: prop.Type, + Default: defaultFromAny(prop.Default), + Delimiter: prop.Delimiter, + } + } + } + + for alias, dep := range raw.Deprecated { + ref.Deprecations[alias] = Deprecation{ + Use: dep.Use, + Message: dep.Message, + } + } + + for alias, dep := range ref.Deprecations { + canonical, err := resolveCanonical(alias, dep.Use, ref.Deprecations) + if err != nil { + return nil, err + } + if canonical != "" { + ref.AliasesByPreferred[canonical] = append(ref.AliasesByPreferred[canonical], alias) + } + } + + return ref, nil +} + +func resolveCanonical(start, target string, deprecations map[string]Deprecation) (string, error) { + seen := map[string]bool{start: true} + current := target + for current != "" { + if seen[current] { + return "", fmt.Errorf("deprecation cycle through [%s]", current) + } + seen[current] = true + next, isAlias := deprecations[current] + if !isAlias { + return current, nil + } + current = next.Use + } + return "", nil +} + +func defaultFromAny(v any) *string { + if v == nil { + return nil + } + s := fmt.Sprint(v) + return &s +} diff --git a/internals/config/format.go b/internals/config/format.go new file mode 100644 index 0000000..8d3cc56 --- /dev/null +++ b/internals/config/format.go @@ -0,0 +1,50 @@ +package config + +import ( + "fmt" + "strconv" + "strings" +) + +var ( + boolTruthy = map[string]bool{"1": true, "true": true, "yes": true, "on": true} + boolFalsy = map[string]bool{"0": true, "false": true, "no": true, "off": true} +) + +func ParseBool(s string) (bool, error) { + lower := strings.ToLower(s) + if boolTruthy[lower] { + return true, nil + } + if boolFalsy[lower] { + return false, nil + } + return false, fmt.Errorf("not a boolean: %q (accepted: 1/true/yes/on or 0/false/no/off)", s) +} + +func ParseInt(s string) (int64, error) { + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0, fmt.Errorf("not an integer: %q", s) + } + return n, nil +} + +func ParseList(s, delim string) []string { + if s == "" { + return nil + } + if delim == "" { + delim = " " + } + parts := strings.Split(s, delim) + out := make([]string, 0, len(parts)) + for _, p := range parts { + trimmed := strings.TrimSpace(p) + if trimmed == "" { + continue + } + out = append(out, trimmed) + } + return out +} diff --git a/internals/config/resolve.go b/internals/config/resolve.go new file mode 100644 index 0000000..4c08f35 --- /dev/null +++ b/internals/config/resolve.go @@ -0,0 +1,124 @@ +package config + +import ( + "fmt" + "io" + "os" + "sync" +) + +type CheckState int + +const ( + CheckPreferredSet CheckState = iota + CheckDeprecatedOnly + CheckBothSet + CheckUnset +) + +var ( + deprecationWriter io.Writer = os.Stderr + warnedAliases sync.Map +) + +func Resolve(group, prop string) (string, error) { + return ResolveKey(RuntimeKey(group, prop)) +} + +func ResolveBool(group, prop string) (bool, error) { + v, err := Resolve(group, prop) + if err != nil { + return false, err + } + return ParseBool(v) +} + +func ResolveInt(group, prop string) (int64, error) { + v, err := Resolve(group, prop) + if err != nil { + return 0, err + } + return ParseInt(v) +} + +func ResolveList(group, prop, override string) ([]string, error) { + return ResolveListKey(RuntimeKey(group, prop), override) +} + +func ResolveListKey(runtimeKey, override string) ([]string, error) { + ref, err := LoadEnvReference() + if err != nil { + return nil, err + } + v, err := ref.Resolve(runtimeKey) + if err != nil { + return nil, err + } + delim := override + if delim == "" { + delim = ref.Properties[runtimeKey].Delimiter + } + if delim == "" { + delim = " " + } + return ParseList(v, delim), nil +} + +func ResolveKey(runtimeKey string) (string, error) { + ref, err := LoadEnvReference() + if err != nil { + return "", err + } + return ref.Resolve(runtimeKey) +} + +func Check(preferred, deprecated string) CheckState { + if v, ok := os.LookupEnv(preferred); ok && v != "" { + if deprecated != "" { + if dv, dok := os.LookupEnv(deprecated); dok && dv != "" { + return CheckBothSet + } + } + return CheckPreferredSet + } + if deprecated != "" { + if dv, dok := os.LookupEnv(deprecated); dok && dv != "" { + return CheckDeprecatedOnly + } + } + return CheckUnset +} + +func (r *EnvReference) Resolve(key string) (string, error) { + if v, ok := os.LookupEnv(key); ok && v != "" { + return v, nil + } + + for _, alias := range r.AliasesByPreferred[key] { + if v, ok := os.LookupEnv(alias); ok && v != "" { + emitDeprecationWarn(alias, key) + return v, nil + } + } + + if prop, ok := r.Properties[key]; ok && prop.Default != nil { + return *prop.Default, nil + } + + return "", nil +} + +func emitDeprecationWarn(alias, preferred string) { + if _, loaded := warnedAliases.LoadOrStore(alias, true); loaded { + return + } + fmt.Fprintf(deprecationWriter, "Deprecated: [%s] use [%s] instead\n", alias, preferred) +} + +func DeprecationLine(alias, preferred string) string { + return fmt.Sprintf("Deprecated: [%s] use [%s] instead", alias, preferred) +} + +func BothSetLine(alias, preferred string) string { + return fmt.Sprintf("Both [%s] (deprecated) and [%s] are set\n. Aborting", alias, preferred) +} diff --git a/internals/config/resolve_test.go b/internals/config/resolve_test.go new file mode 100644 index 0000000..3c3c27b --- /dev/null +++ b/internals/config/resolve_test.go @@ -0,0 +1,358 @@ +package config + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "gotest.tools/v3/assert" +) + +const sampleYAML = ` +envs: + metrics: + properties: + port: + type: integer + default: 9100 + collectors: + type: string + default: null + delimiter: "," + server: + properties: + root: + type: string + default: /workspace + features: + properties: + additional_features: + type: string + default: null + delimiter: " " + apt: + properties: + additional_repos: + type: string + default: null + delimiter: ";" +deprecated: + WS_PORT: + use: WS_SERVER_PORT + WS_OLD_NOREPLACE: + message: removed without replacement +` + +func _newReference(t *testing.T) *EnvReference { + t.Helper() + r, err := parseEnvReference([]byte(sampleYAML)) + assert.NilError(t, err) + return r +} + +func _installFixture(t *testing.T, content string) { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "env.reference.yaml") + assert.NilError(t, os.WriteFile(path, []byte(content), 0644)) + t.Setenv("WS__INTERNAL_ENV_REFERENCE", path) +} + +func _captureWarnings(t *testing.T) *bytes.Buffer { + t.Helper() + buf := &bytes.Buffer{} + original := deprecationWriter + deprecationWriter = buf + t.Cleanup(func() { deprecationWriter = original }) + warnedAliases.Range(func(k, _ any) bool { + warnedAliases.Delete(k) + return true + }) + return buf +} + +func TestRuntimeKey(t *testing.T) { + cases := []struct { + group, prop string + want string + }{ + {"metrics", "port", "WS_METRICS_PORT"}, + {"_internal", "ipc_socket", "WS__INTERNAL_IPC_SOCKET"}, + {"server", "root", "WS_SERVER_ROOT"}, + } + for _, c := range cases { + assert.Equal(t, c.want, RuntimeKey(c.group, c.prop)) + } +} + +func TestResolve_EnvWinsOverDefault(t *testing.T) { + r := _newReference(t) + t.Setenv("WS_SERVER_ROOT", "/custom") + v, err := r.Resolve("WS_SERVER_ROOT") + assert.NilError(t, err) + assert.Equal(t, "/custom", v) +} + +func TestResolve_UnsetReturnsDefault(t *testing.T) { + r := _newReference(t) + v, err := r.Resolve("WS_SERVER_ROOT") + assert.NilError(t, err) + assert.Equal(t, "/workspace", v) +} + +func TestResolve_EmptyEnvReturnsDefault(t *testing.T) { + r := _newReference(t) + t.Setenv("WS_SERVER_ROOT", "") + v, err := r.Resolve("WS_SERVER_ROOT") + assert.NilError(t, err) + assert.Equal(t, "/workspace", v) +} + +func TestResolve_NullDefaultReturnsEmpty(t *testing.T) { + r := _newReference(t) + t.Setenv("WS_FEATURES_ADDITIONAL_FEATURES", "") + v, err := r.Resolve("WS_FEATURES_ADDITIONAL_FEATURES") + assert.NilError(t, err) + assert.Equal(t, "", v) +} + +func TestResolve_UnknownKeyReturnsEmpty(t *testing.T) { + r := _newReference(t) + t.Setenv("WS_NOT_DECLARED", "") + v, err := r.Resolve("WS_NOT_DECLARED") + assert.NilError(t, err) + assert.Equal(t, "", v) +} + +func TestResolve_DeprecatedAliasUsedWithWarn(t *testing.T) { + r := _newReference(t) + buf := _captureWarnings(t) + t.Setenv("WS_PORT", "8888") + v, err := r.Resolve("WS_SERVER_PORT") + assert.NilError(t, err) + assert.Equal(t, "8888", v) + assert.Equal(t, "Deprecated: [WS_PORT] use [WS_SERVER_PORT] instead\n", buf.String()) +} + +func TestResolve_DeprecatedWarnEmittedOnce(t *testing.T) { + r := _newReference(t) + buf := _captureWarnings(t) + t.Setenv("WS_PORT", "8888") + _, _ = r.Resolve("WS_SERVER_PORT") + _, _ = r.Resolve("WS_SERVER_PORT") + _, _ = r.Resolve("WS_SERVER_PORT") + assert.Equal(t, 1, bytes.Count(buf.Bytes(), []byte("Deprecated:"))) +} + +func TestResolve_BothSetPrefersPreferred(t *testing.T) { + r := _newReference(t) + _captureWarnings(t) + t.Setenv("WS_SERVER_PORT", "9999") + t.Setenv("WS_PORT", "8888") + v, err := r.Resolve("WS_SERVER_PORT") + assert.NilError(t, err) + assert.Equal(t, "9999", v) +} + +func TestParse_DeprecationChainCollapses(t *testing.T) { + chained := ` +envs: {} +deprecated: + WS_A: + use: WS_B + WS_B: + use: WS_C +` + r, err := parseEnvReference([]byte(chained)) + assert.NilError(t, err) + assert.DeepEqual(t, []string{"WS_A", "WS_B"}, _sorted(r.AliasesByPreferred["WS_C"])) +} + +func TestParse_DeprecationCycleRejected(t *testing.T) { + cyclic := ` +envs: {} +deprecated: + WS_A: + use: WS_B + WS_B: + use: WS_A +` + _, err := parseEnvReference([]byte(cyclic)) + assert.ErrorContains(t, err, "deprecation cycle") +} + +func _sorted(s []string) []string { + out := append([]string(nil), s...) + for i := range out { + for j := i + 1; j < len(out); j++ { + if out[j] < out[i] { + out[i], out[j] = out[j], out[i] + } + } + } + return out +} + +func TestParse_EmptyEnvs(t *testing.T) { + _, err := parseEnvReference([]byte("envs: {}\ndeprecated: {}\n")) + assert.NilError(t, err) +} + +func TestLoad_MissingFileReturnsError(t *testing.T) { + t.Setenv("WS__INTERNAL_ENV_REFERENCE", filepath.Join(t.TempDir(), "missing.yaml")) + _, err := LoadEnvReference() + assert.ErrorContains(t, err, "cannot read") +} + +func TestLoad_RespectsOverridePath(t *testing.T) { + _installFixture(t, sampleYAML) + r, err := LoadEnvReference() + assert.NilError(t, err) + assert.Equal(t, "/workspace", *r.Properties["WS_SERVER_ROOT"].Default) +} + +func TestParseBool(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"1", true}, {"true", true}, {"TRUE", true}, {"True", true}, + {"yes", true}, {"YES", true}, {"on", true}, {"On", true}, + {"0", false}, {"false", false}, {"FALSE", false}, {"False", false}, + {"no", false}, {"NO", false}, {"off", false}, {"Off", false}, + } + for _, c := range cases { + got, err := ParseBool(c.in) + assert.NilError(t, err, c.in) + assert.Equal(t, c.want, got, c.in) + } +} + +func TestParseBool_RejectsInvalid(t *testing.T) { + for _, in := range []string{"", "2", "truthy", "y", "n", "true ", " false", "*", "tru e"} { + _, err := ParseBool(in) + assert.Assert(t, err != nil, "expected error for %q", in) + } +} + +func TestParseInt(t *testing.T) { + cases := []struct { + in string + want int64 + }{ + {"0", 0}, {"-1", -1}, {"42", 42}, {"007", 7}, + {"9223372036854775807", 9223372036854775807}, + } + for _, c := range cases { + got, err := ParseInt(c.in) + assert.NilError(t, err, c.in) + assert.Equal(t, c.want, got, c.in) + } +} + +func TestParseInt_RejectsInvalid(t *testing.T) { + for _, in := range []string{"", "abc", "1.0", " 42", "42 ", "9223372036854775808", "0x10"} { + _, err := ParseInt(in) + assert.Assert(t, err != nil, "expected error for %q", in) + } +} + +func TestParseList(t *testing.T) { + cases := []struct { + in string + delim string + want []string + }{ + {"a,b,c", ",", []string{"a", "b", "c"}}, + {"a b c", " ", []string{"a", "b", "c"}}, + {"deb a; deb b", ";", []string{"deb a", "deb b"}}, + {"a b, c d", ",", []string{"a b", "c d"}}, + {"a,b,", ",", []string{"a", "b"}}, + {"a,,b", ",", []string{"a", "b"}}, + {" a , b ", ",", []string{"a", "b"}}, + {"α,β", ",", []string{"α", "β"}}, + {"", ",", nil}, + } + for _, c := range cases { + got := ParseList(c.in, c.delim) + assert.DeepEqual(t, c.want, got) + } +} + +func TestResolveBool_GoesThroughCache(t *testing.T) { + _installFixture(t, sampleYAML) + t.Setenv("WS_SERVER_ROOT", "true") + got, err := ResolveBool("server", "root") + assert.NilError(t, err) + assert.Equal(t, true, got) +} + +func TestResolveInt_FallsBackToYAMLDefault(t *testing.T) { + _installFixture(t, sampleYAML) + got, err := ResolveInt("metrics", "port") + assert.NilError(t, err) + assert.Equal(t, int64(9100), got) +} + +func TestResolveList_HonorsYAMLDelimiter(t *testing.T) { + _installFixture(t, sampleYAML) + t.Setenv("WS_APT_ADDITIONAL_REPOS", "deb a; deb b") + got, err := ResolveList("apt", "additional_repos", "") + assert.NilError(t, err) + assert.DeepEqual(t, []string{"deb a", "deb b"}, got) +} + +func TestResolveList_OverrideWinsOverYAMLDelimiter(t *testing.T) { + _installFixture(t, sampleYAML) + t.Setenv("WS_APT_ADDITIONAL_REPOS", "deb a, deb b") + got, err := ResolveList("apt", "additional_repos", ",") + assert.NilError(t, err) + assert.DeepEqual(t, []string{"deb a", "deb b"}, got) +} + +func TestCheck(t *testing.T) { + cases := []struct { + name string + preferred string + alias string + setPref string + setAlias string + want CheckState + }{ + {"PreferredOnly", "WS_NEW", "WS_OLD", "v", "", CheckPreferredSet}, + {"DeprecatedOnly", "WS_NEW", "WS_OLD", "", "v", CheckDeprecatedOnly}, + {"BothSet", "WS_NEW", "WS_OLD", "a", "b", CheckBothSet}, + {"Unset", "WS_NEW", "WS_OLD", "", "", CheckUnset}, + {"PreferredOnlyNoDeprecated", "WS_NEW", "", "v", "", CheckPreferredSet}, + {"UnsetNoDeprecated", "WS_NEW", "", "", "", CheckUnset}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + os.Unsetenv("WS_NEW") + os.Unsetenv("WS_OLD") + if c.setPref != "" { + t.Setenv("WS_NEW", c.setPref) + } + if c.setAlias != "" && c.alias != "" { + t.Setenv(c.alias, c.setAlias) + } + got := Check(c.preferred, c.alias) + assert.Equal(t, c.want, got) + }) + } +} + +func TestDeprecationLine(t *testing.T) { + assert.Equal(t, + "Deprecated: [WS_OLD] use [WS_NEW] instead", + DeprecationLine("WS_OLD", "WS_NEW"), + ) +} + +func TestBothSetLine(t *testing.T) { + assert.Equal(t, + "Both [WS_OLD] (deprecated) and [WS_NEW] are set\n. Aborting", + BothSetLine("WS_OLD", "WS_NEW"), + ) +} diff --git a/internals/logger/logger.go b/internals/logger/logger.go index 40a9482..bd36725 100644 --- a/internals/logger/logger.go +++ b/internals/logger/logger.go @@ -12,7 +12,6 @@ import ( "charm.land/lipgloss/v2" "github.com/kloudkit/ws-cli/internals/config" - "github.com/kloudkit/ws-cli/internals/env" "github.com/kloudkit/ws-cli/internals/styles" ) @@ -73,7 +72,16 @@ var Log = func(writer io.Writer, level, message string, indent int, withStamp bo } func NewReader(tailLines int, levelFilter string) (*Reader, error) { - logPath := filepath.Join(env.String(config.EnvLoggingDir, config.DefaultLoggingDir), env.String(config.EnvLoggingFile, config.DefaultLoggingFile)) + dir, err := config.Resolve("logging", "dir") + if err != nil { + return nil, err + } + file, err := config.Resolve("logging", "main_file") + if err != nil { + return nil, err + } + + logPath := filepath.Join(dir, file) if _, err := os.Stat(logPath); os.IsNotExist(err) { return nil, fmt.Errorf("log file not found at %s", logPath) diff --git a/internals/logger/logger_test.go b/internals/logger/logger_test.go index 0abe6c9..3cb985e 100644 --- a/internals/logger/logger_test.go +++ b/internals/logger/logger_test.go @@ -8,7 +8,6 @@ import ( "strings" "testing" - "github.com/kloudkit/ws-cli/internals/config" "gotest.tools/v3/assert" "gotest.tools/v3/assert/cmp" ) @@ -82,8 +81,8 @@ Plain text error message` err := os.WriteFile(logFile, []byte(logContent), 0644) assert.NilError(t, err) - t.Setenv(config.EnvLoggingDir, tempDir) - t.Setenv(config.EnvLoggingFile, "test.log") + t.Setenv("WS_LOGGING_DIR", tempDir) + t.Setenv("WS_LOGGING_MAIN_FILE", "test.log") tests := []struct { name string diff --git a/internals/metrics/serve.go b/internals/metrics/serve.go index 09b9eea..68c201f 100644 --- a/internals/metrics/serve.go +++ b/internals/metrics/serve.go @@ -3,11 +3,9 @@ package metrics import ( "errors" "slices" - "strconv" "strings" "github.com/kloudkit/ws-cli/internals/config" - "github.com/kloudkit/ws-cli/internals/env" "github.com/prometheus/client_golang/prometheus" ) @@ -95,16 +93,15 @@ func BuildRegistry(collectors []string) (*RegistryResult, error) { } func DefaultPort() int { - if portStr := env.String(config.EnvMetricsPort); portStr != "" { - if port, err := strconv.Atoi(portStr); err == nil { - return port - } + port, err := config.ResolveInt("metrics", "port") + if err != nil { + return 9100 } - return config.DefaultMetricsPort + return int(port) } func DefaultCollectors() []string { - envCollectors := env.String(config.EnvMetricsCollectors) + envCollectors, _ := config.Resolve("metrics", "collectors") if envCollectors == "" { return nil } diff --git a/internals/metrics/system.go b/internals/metrics/system.go index e81469d..0ce0c93 100644 --- a/internals/metrics/system.go +++ b/internals/metrics/system.go @@ -11,7 +11,8 @@ import ( ) func GetDiskStats() (*DiskStats, error) { - return GetDiskStatsForPath(config.DefaultServerRoot) + root, _ := config.Resolve("server", "root") + return GetDiskStatsForPath(root) } func GetDiskStatsForPath(path string) (*DiskStats, error) { diff --git a/internals/secrets/key.go b/internals/secrets/key.go index e5571ed..069acc4 100644 --- a/internals/secrets/key.go +++ b/internals/secrets/key.go @@ -7,7 +7,6 @@ import ( "strings" "github.com/kloudkit/ws-cli/internals/config" - "github.com/kloudkit/ws-cli/internals/env" "github.com/kloudkit/ws-cli/internals/io" ) @@ -20,27 +19,25 @@ func ResolveMasterKey(flagValue string) ([]byte, error) { return parseKey(flagValue) } - if val := env.String(config.EnvSecretsKey); val != "" { + if val, _ := config.Resolve("secrets", "master_key"); val != "" { return parseKey(val) } - if filePath := env.String(config.EnvSecretsKeyFile); filePath != "" { - if !io.FileExists(filePath) { - return nil, fmt.Errorf("master key file not found at %s: %s", config.EnvSecretsKeyFile, filePath) + if explicit, ok := os.LookupEnv("WS_SECRETS_MASTER_KEY_FILE"); ok && explicit != "" { + if !io.FileExists(explicit) { + return nil, fmt.Errorf("master key file not found at WS_SECRETS_MASTER_KEY_FILE: %s", explicit) } - - return readKeyFile(filePath) + return readKeyFile(explicit) } - if io.FileExists(config.DefaultSecretsKeyPath) { - return readKeyFile(config.DefaultSecretsKeyPath) + defaultPath, _ := config.Resolve("secrets", "master_key_file") + if defaultPath != "" && io.FileExists(defaultPath) { + return readKeyFile(defaultPath) } return nil, fmt.Errorf( - "master key not found (use --master, %s, %s, or check %s)", - config.EnvSecretsKey, - config.EnvSecretsKeyFile, - config.DefaultSecretsKeyPath, + "master key not found (use --master, WS_SECRETS_MASTER_KEY, WS_SECRETS_MASTER_KEY_FILE, or check %s)", + defaultPath, ) } diff --git a/internals/secrets/key_test.go b/internals/secrets/key_test.go index 344face..5dd43b4 100644 --- a/internals/secrets/key_test.go +++ b/internals/secrets/key_test.go @@ -5,7 +5,6 @@ import ( "path/filepath" "testing" - "github.com/kloudkit/ws-cli/internals/config" "gotest.tools/v3/assert" ) @@ -39,7 +38,7 @@ func TestResolveMasterKey(t *testing.T) { t.Run("FromEnv", func(t *testing.T) { keyContent := "env-secret-key" - t.Setenv(config.EnvSecretsKey, keyContent) + t.Setenv("WS_SECRETS_MASTER_KEY", keyContent) resolved, err := ResolveMasterKey("") assert.NilError(t, err) @@ -51,7 +50,7 @@ func TestResolveMasterKey(t *testing.T) { err := os.WriteFile(keyFile, []byte("secretkey"), 0600) assert.NilError(t, err) - t.Setenv(config.EnvSecretsKey, keyFile) + t.Setenv("WS_SECRETS_MASTER_KEY", keyFile) resolved, err := ResolveMasterKey("") assert.NilError(t, err) @@ -64,7 +63,7 @@ func TestResolveMasterKey(t *testing.T) { err := os.WriteFile(keyFile, []byte(keyContent), 0600) assert.NilError(t, err) - t.Setenv(config.EnvSecretsKeyFile, keyFile) + t.Setenv("WS_SECRETS_MASTER_KEY_FILE", keyFile) resolved, err := ResolveMasterKey("") assert.NilError(t, err) @@ -72,7 +71,7 @@ func TestResolveMasterKey(t *testing.T) { }) t.Run("Precedence", func(t *testing.T) { - t.Setenv(config.EnvSecretsKey, "env-key") + t.Setenv("WS_SECRETS_MASTER_KEY", "env-key") resolved, err := ResolveMasterKey("flag-key") assert.NilError(t, err) @@ -80,15 +79,15 @@ func TestResolveMasterKey(t *testing.T) { }) t.Run("NotFound", func(t *testing.T) { - t.Setenv(config.EnvSecretsKey, "") - t.Setenv(config.EnvSecretsKeyFile, "") + t.Setenv("WS_SECRETS_MASTER_KEY", "") + t.Setenv("WS_SECRETS_MASTER_KEY_FILE", "") - if _, err := os.Stat(config.DefaultSecretsKeyPath); err == nil { - t.Skip("Skipping test because " + config.DefaultSecretsKeyPath + " exists") + if _, err := os.Stat("/etc/workspace/master.key"); err == nil { + t.Skip("Skipping test because " + "/etc/workspace/master.key" + " exists") } _, err := ResolveMasterKey("") assert.ErrorContains(t, err, "master key not found") - assert.ErrorContains(t, err, config.DefaultSecretsKeyPath) + assert.ErrorContains(t, err, "/etc/workspace/master.key") }) } diff --git a/internals/secrets/vault.go b/internals/secrets/vault.go index ba7e6d1..3f989ff 100644 --- a/internals/secrets/vault.go +++ b/internals/secrets/vault.go @@ -1,6 +1,7 @@ package secrets import ( + "errors" "fmt" "os" "path/filepath" @@ -135,20 +136,25 @@ func ResolveVaultPath(inputFlag string) (string, error) { return inputFlag, nil } - if vaultPath := env.String(config.EnvSecretsVault); vaultPath != "" { - return vaultPath, nil + if envPath, ok := os.LookupEnv("WS_SECRETS_VAULT"); ok && envPath != "" { + return envPath, nil } - defaultPath, err := path.Expand(config.DefaultSecretsVaultPath) + defaultPath, _ := config.Resolve("secrets", "vault") + if defaultPath == "" { + return "", errors.New("vault file not specified (use --input or WS_SECRETS_VAULT)") + } + + expanded, err := path.Expand(defaultPath) if err != nil { return "", fmt.Errorf("failed to resolve default vault path: %w", err) } - if _, err := os.Stat(defaultPath); err == nil { - return defaultPath, nil + if _, err := os.Stat(expanded); err == nil { + return expanded, nil } - return "", fmt.Errorf("vault file not specified (use --input or %s)", config.EnvSecretsVault) + return "", errors.New("vault file not specified (use --input or WS_SECRETS_VAULT)") } func ValidateSecret(name string, secret VaultSecret) error { From 6b8134e2645c89b22e73bcbb5dfa0df3c1c38709 Mon Sep 17 00:00:00 2001 From: Dov Benyomin Sohacheski Date: Sun, 10 May 2026 18:04:13 +0300 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=A7=BC=20Simplify=20env=20resolver=20?= =?UTF-8?q?=E2=80=94=20drop=20unused=20error=20return,=20reuse=20`env.Stri?= =?UTF-8?q?ng`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `(*EnvReference).Resolve` returns just `string` (it never errored). Drop the local `defaultEnvReferencePath` const — moved into `internals/config/defaults.go` alongside the other path constants. Reuse `env.String` for env lookups in the resolver chain and `Check`. Collapse the four nested `yaml*` intermediate types into one anonymous struct in `parseEnvReference`. Switch-based `ParseBool` replaces the two package-level truthy/falsy maps. --- .gitignore | 2 + CLAUDE.md | 25 ------------- internals/config/defaults.go | 7 ++-- internals/config/envref.go | 55 +++++++++------------------ internals/config/format.go | 23 ++++-------- internals/config/resolve.go | 64 ++++++++++++++------------------ internals/config/resolve_test.go | 34 +++++------------ 7 files changed, 67 insertions(+), 143 deletions(-) delete mode 100644 CLAUDE.md diff --git a/.gitignore b/.gitignore index 2eb7d12..c22f61f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +CLAUDE.md + ws-cli /build diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 7e160df..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,25 +0,0 @@ -# Requirements - -1. **Separation of concerns** - Keep argument/flag parsing at the edges. Commands should delegate to exported functions that contain the business logic (MVC-inspired: parsing ≠ logic). - -2. **Command tree wiring** - The root command registers its direct children (e.g., `rootCmd.AddCommand(log.LogCmd)`), and each child registers its own subcommands. - -3. **Pragmatic structure** - Avoid over-engineering: no DI frameworks and don’t create `internal/*` packages for every command. Place shared logic in importable modules so it’s reusable and testable without duplication. - -4. **Dependency policy** - Prefer native/standard library solutions over third-party packages whenever possible. - -5. **Testing** - For tests, use the `gotest.tools/v3/assert` library instead of `if/fail` conditions. - -6. **Backwards compatibility** - This is not a public library; legacy compatibility isn’t required. - -7. **CLI UX** - Add colorized output to make the CLI more user-friendly. - -8. **Comments** - Do not not add comments unless specifically instructed diff --git a/internals/config/defaults.go b/internals/config/defaults.go index 35165da..1b7c336 100644 --- a/internals/config/defaults.go +++ b/internals/config/defaults.go @@ -3,9 +3,10 @@ package config const ( EnvIPCSocket = "WS__INTERNAL_IPC_SOCKET" - DefaultIPCSocket = "/var/workspace/ipc.socket" - DefaultStatePath = "/var/lib/workspace/state" - DefaultEnvFilePath = "~/.zshenv" + DefaultEnvReferencePath = "/etc/workspace/env.reference.yaml" + DefaultIPCSocket = "/var/workspace/ipc.socket" + DefaultStatePath = "/var/lib/workspace/state" + DefaultEnvFilePath = "~/.zshenv" ) var DefaultManifestPath = "/var/lib/workspace/manifest.json" diff --git a/internals/config/envref.go b/internals/config/envref.go index 18cbc38..6fceeb1 100644 --- a/internals/config/envref.go +++ b/internals/config/envref.go @@ -6,11 +6,10 @@ import ( "strings" "sync" + "github.com/kloudkit/ws-cli/internals/env" "gopkg.in/yaml.v3" ) -const defaultEnvReferencePath = "/etc/workspace/env.reference.yaml" - type Property struct { Type string Default *string @@ -32,26 +31,6 @@ func RuntimeKey(group, prop string) string { return "WS_" + strings.ToUpper(group) + "_" + strings.ToUpper(prop) } -type yamlSchema struct { - Envs map[string]yamlGroup `yaml:"envs"` - Deprecated map[string]yamlDeprecation `yaml:"deprecated"` -} - -type yamlGroup struct { - Properties map[string]yamlProperty `yaml:"properties"` -} - -type yamlProperty struct { - Type string `yaml:"type"` - Default any `yaml:"default"` - Delimiter string `yaml:"delimiter"` -} - -type yamlDeprecation struct { - Use string `yaml:"use"` - Message string `yaml:"message"` -} - var ( cacheMu sync.Mutex cachedPath string @@ -59,18 +38,11 @@ var ( cachedErr error ) -func envReferencePath() string { - if v := os.Getenv("WS__INTERNAL_ENV_REFERENCE"); v != "" { - return v - } - return defaultEnvReferencePath -} - func LoadEnvReference() (*EnvReference, error) { cacheMu.Lock() defer cacheMu.Unlock() - path := envReferencePath() + path := env.String("WS__INTERNAL_ENV_REFERENCE", DefaultEnvReferencePath) if cachedVal != nil && cachedPath == path { return cachedVal, cachedErr } @@ -88,7 +60,19 @@ func readEnvReference(path string) (*EnvReference, error) { } func parseEnvReference(data []byte) (*EnvReference, error) { - var raw yamlSchema + var raw struct { + Envs map[string]struct { + Properties map[string]struct { + Type string `yaml:"type"` + Default any `yaml:"default"` + Delimiter string `yaml:"delimiter"` + } `yaml:"properties"` + } `yaml:"envs"` + Deprecated map[string]struct { + Use string `yaml:"use"` + Message string `yaml:"message"` + } `yaml:"deprecated"` + } if err := yaml.Unmarshal(data, &raw); err != nil { return nil, fmt.Errorf("cannot parse env reference: %w", err) } @@ -100,10 +84,8 @@ func parseEnvReference(data []byte) (*EnvReference, error) { } for groupKey, group := range raw.Envs { - groupUpper := strings.ToUpper(groupKey) for propKey, prop := range group.Properties { - runtimeKey := "WS_" + groupUpper + "_" + strings.ToUpper(propKey) - ref.Properties[runtimeKey] = Property{ + ref.Properties[RuntimeKey(groupKey, propKey)] = Property{ Type: prop.Type, Default: defaultFromAny(prop.Default), Delimiter: prop.Delimiter, @@ -112,10 +94,7 @@ func parseEnvReference(data []byte) (*EnvReference, error) { } for alias, dep := range raw.Deprecated { - ref.Deprecations[alias] = Deprecation{ - Use: dep.Use, - Message: dep.Message, - } + ref.Deprecations[alias] = Deprecation{Use: dep.Use, Message: dep.Message} } for alias, dep := range ref.Deprecations { diff --git a/internals/config/format.go b/internals/config/format.go index 8d3cc56..2af0b2e 100644 --- a/internals/config/format.go +++ b/internals/config/format.go @@ -6,17 +6,11 @@ import ( "strings" ) -var ( - boolTruthy = map[string]bool{"1": true, "true": true, "yes": true, "on": true} - boolFalsy = map[string]bool{"0": true, "false": true, "no": true, "off": true} -) - func ParseBool(s string) (bool, error) { - lower := strings.ToLower(s) - if boolTruthy[lower] { + switch strings.ToLower(s) { + case "1", "true", "yes", "on": return true, nil - } - if boolFalsy[lower] { + case "0", "false", "no", "off": return false, nil } return false, fmt.Errorf("not a boolean: %q (accepted: 1/true/yes/on or 0/false/no/off)", s) @@ -37,14 +31,11 @@ func ParseList(s, delim string) []string { if delim == "" { delim = " " } - parts := strings.Split(s, delim) - out := make([]string, 0, len(parts)) - for _, p := range parts { - trimmed := strings.TrimSpace(p) - if trimmed == "" { - continue + out := []string{} + for _, p := range strings.Split(s, delim) { + if trimmed := strings.TrimSpace(p); trimmed != "" { + out = append(out, trimmed) } - out = append(out, trimmed) } return out } diff --git a/internals/config/resolve.go b/internals/config/resolve.go index 4c08f35..4ad7418 100644 --- a/internals/config/resolve.go +++ b/internals/config/resolve.go @@ -5,6 +5,8 @@ import ( "io" "os" "sync" + + "github.com/kloudkit/ws-cli/internals/env" ) type CheckState int @@ -45,12 +47,16 @@ func ResolveList(group, prop, override string) ([]string, error) { return ResolveListKey(RuntimeKey(group, prop), override) } -func ResolveListKey(runtimeKey, override string) ([]string, error) { +func ResolveKey(runtimeKey string) (string, error) { ref, err := LoadEnvReference() if err != nil { - return nil, err + return "", err } - v, err := ref.Resolve(runtimeKey) + return ref.Resolve(runtimeKey), nil +} + +func ResolveListKey(runtimeKey, override string) ([]string, error) { + ref, err := LoadEnvReference() if err != nil { return nil, err } @@ -58,61 +64,45 @@ func ResolveListKey(runtimeKey, override string) ([]string, error) { if delim == "" { delim = ref.Properties[runtimeKey].Delimiter } - if delim == "" { - delim = " " - } - return ParseList(v, delim), nil -} - -func ResolveKey(runtimeKey string) (string, error) { - ref, err := LoadEnvReference() - if err != nil { - return "", err - } - return ref.Resolve(runtimeKey) + return ParseList(ref.Resolve(runtimeKey), delim), nil } func Check(preferred, deprecated string) CheckState { - if v, ok := os.LookupEnv(preferred); ok && v != "" { - if deprecated != "" { - if dv, dok := os.LookupEnv(deprecated); dok && dv != "" { - return CheckBothSet - } - } + preferredSet := env.String(preferred) != "" + deprecatedSet := deprecated != "" && env.String(deprecated) != "" + + switch { + case preferredSet && deprecatedSet: + return CheckBothSet + case preferredSet: return CheckPreferredSet - } - if deprecated != "" { - if dv, dok := os.LookupEnv(deprecated); dok && dv != "" { - return CheckDeprecatedOnly - } + case deprecatedSet: + return CheckDeprecatedOnly } return CheckUnset } -func (r *EnvReference) Resolve(key string) (string, error) { - if v, ok := os.LookupEnv(key); ok && v != "" { - return v, nil +func (r *EnvReference) Resolve(key string) string { + if v := env.String(key); v != "" { + return v } - for _, alias := range r.AliasesByPreferred[key] { - if v, ok := os.LookupEnv(alias); ok && v != "" { + if v := env.String(alias); v != "" { emitDeprecationWarn(alias, key) - return v, nil + return v } } - if prop, ok := r.Properties[key]; ok && prop.Default != nil { - return *prop.Default, nil + return *prop.Default } - - return "", nil + return "" } func emitDeprecationWarn(alias, preferred string) { if _, loaded := warnedAliases.LoadOrStore(alias, true); loaded { return } - fmt.Fprintf(deprecationWriter, "Deprecated: [%s] use [%s] instead\n", alias, preferred) + fmt.Fprintln(deprecationWriter, DeprecationLine(alias, preferred)) } func DeprecationLine(alias, preferred string) string { diff --git a/internals/config/resolve_test.go b/internals/config/resolve_test.go index 3c3c27b..352ac67 100644 --- a/internals/config/resolve_test.go +++ b/internals/config/resolve_test.go @@ -89,49 +89,37 @@ func TestRuntimeKey(t *testing.T) { func TestResolve_EnvWinsOverDefault(t *testing.T) { r := _newReference(t) t.Setenv("WS_SERVER_ROOT", "/custom") - v, err := r.Resolve("WS_SERVER_ROOT") - assert.NilError(t, err) - assert.Equal(t, "/custom", v) + assert.Equal(t, "/custom", r.Resolve("WS_SERVER_ROOT")) } func TestResolve_UnsetReturnsDefault(t *testing.T) { r := _newReference(t) - v, err := r.Resolve("WS_SERVER_ROOT") - assert.NilError(t, err) - assert.Equal(t, "/workspace", v) + assert.Equal(t, "/workspace", r.Resolve("WS_SERVER_ROOT")) } func TestResolve_EmptyEnvReturnsDefault(t *testing.T) { r := _newReference(t) t.Setenv("WS_SERVER_ROOT", "") - v, err := r.Resolve("WS_SERVER_ROOT") - assert.NilError(t, err) - assert.Equal(t, "/workspace", v) + assert.Equal(t, "/workspace", r.Resolve("WS_SERVER_ROOT")) } func TestResolve_NullDefaultReturnsEmpty(t *testing.T) { r := _newReference(t) t.Setenv("WS_FEATURES_ADDITIONAL_FEATURES", "") - v, err := r.Resolve("WS_FEATURES_ADDITIONAL_FEATURES") - assert.NilError(t, err) - assert.Equal(t, "", v) + assert.Equal(t, "", r.Resolve("WS_FEATURES_ADDITIONAL_FEATURES")) } func TestResolve_UnknownKeyReturnsEmpty(t *testing.T) { r := _newReference(t) t.Setenv("WS_NOT_DECLARED", "") - v, err := r.Resolve("WS_NOT_DECLARED") - assert.NilError(t, err) - assert.Equal(t, "", v) + assert.Equal(t, "", r.Resolve("WS_NOT_DECLARED")) } func TestResolve_DeprecatedAliasUsedWithWarn(t *testing.T) { r := _newReference(t) buf := _captureWarnings(t) t.Setenv("WS_PORT", "8888") - v, err := r.Resolve("WS_SERVER_PORT") - assert.NilError(t, err) - assert.Equal(t, "8888", v) + assert.Equal(t, "8888", r.Resolve("WS_SERVER_PORT")) assert.Equal(t, "Deprecated: [WS_PORT] use [WS_SERVER_PORT] instead\n", buf.String()) } @@ -139,9 +127,9 @@ func TestResolve_DeprecatedWarnEmittedOnce(t *testing.T) { r := _newReference(t) buf := _captureWarnings(t) t.Setenv("WS_PORT", "8888") - _, _ = r.Resolve("WS_SERVER_PORT") - _, _ = r.Resolve("WS_SERVER_PORT") - _, _ = r.Resolve("WS_SERVER_PORT") + r.Resolve("WS_SERVER_PORT") + r.Resolve("WS_SERVER_PORT") + r.Resolve("WS_SERVER_PORT") assert.Equal(t, 1, bytes.Count(buf.Bytes(), []byte("Deprecated:"))) } @@ -150,9 +138,7 @@ func TestResolve_BothSetPrefersPreferred(t *testing.T) { _captureWarnings(t) t.Setenv("WS_SERVER_PORT", "9999") t.Setenv("WS_PORT", "8888") - v, err := r.Resolve("WS_SERVER_PORT") - assert.NilError(t, err) - assert.Equal(t, "9999", v) + assert.Equal(t, "9999", r.Resolve("WS_SERVER_PORT")) } func TestParse_DeprecationChainCollapses(t *testing.T) { From 0bf22172123a6f9a288c52060e58ac8c0d15fb80 Mon Sep 17 00:00:00 2001 From: Dov Benyomin Sohacheski Date: Sun, 10 May 2026 18:08:32 +0300 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=94=BC=20Bump=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/info/version.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/info/version.go b/cmd/info/version.go index 97bf617..ef8462b 100644 --- a/cmd/info/version.go +++ b/cmd/info/version.go @@ -1,3 +1,3 @@ package info -var Version = "0.0.50" +var Version = "0.0.51" From ede6cc2ccab9e6575c58075213ffceba2c003806 Mon Sep 17 00:00:00 2001 From: Dov Benyomin Sohacheski Date: Sun, 10 May 2026 19:55:37 +0300 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=91=B7=20CI:=20stage=20`env.reference?= =?UTF-8?q?.yaml`=20from=20`ws-meta`=20before=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yaml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b158cb3..6d2fe51 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -54,7 +54,15 @@ jobs: - name: ⏬ Install Dependencies run: go get . + - name: ⬇️ Fetch `env.reference.yaml` from `ws-meta` + run: | + curl -fsSL \ + https://raw.githubusercontent.com/kloudkit/ws-meta/main/shared/workspace/env.reference.yaml \ + -o "${{ runner.temp }}/env.reference.yaml" + - name: 🧪 Test + env: + WS__INTERNAL_ENV_REFERENCE: ${{ runner.temp }}/env.reference.yaml run: go test -v ./... - name: 👷‍♂️ Build for ${{ matrix.arch }} From bdfc0538e637a965e862555835a378cc50d9a542 Mon Sep 17 00:00:00 2001 From: Dov Benyomin Sohacheski Date: Sun, 10 May 2026 19:55:58 +0300 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=20Add=20`config.MustR?= =?UTF-8?q?esolve`=20+=20eager=20YAML=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MustResolve(group, prop)` panics on YAML-load failure. Use it in callers without fallback semantics — `cmd/show/path.go`, `internals/logger/logger.go`, `internals/metrics/system.go` — where missing YAML means the workspace is fundamentally broken anyway. Multi-branch fallback paths in `internals/secrets/{key, vault}.go` keep the error-returning `Resolve` form. `cmd/root.go`'s `PersistentPreRunE` eager-loads the YAML alongside `RequireWorkspace()` so YAML failure surfaces once at startup through cobra's error path, not deep inside random commands. `ResolveKey` short-circuits when the env var is set explicitly — avoids loading the YAML when callers don't actually need the default lookup. Lets tests that set every env var run without installing a YAML fixture. --- cmd/root.go | 6 +++++- cmd/show/path.go | 5 +---- internals/config/resolve.go | 15 +++++++++++++++ internals/logger/logger.go | 14 ++++---------- internals/metrics/system.go | 3 +-- 5 files changed, 26 insertions(+), 17 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 4e4880b..3a9ad57 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -26,7 +26,11 @@ var rootCmd = &cobra.Command{ Aliases: []string{"ws"}, SilenceErrors: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - return config.RequireWorkspace() + if err := config.RequireWorkspace(); err != nil { + return err + } + _, err := config.LoadEnvReference() + return err }, } diff --git a/cmd/show/path.go b/cmd/show/path.go index 7ed4b15..b927674 100644 --- a/cmd/show/path.go +++ b/cmd/show/path.go @@ -16,10 +16,7 @@ var pathHomeCmd = &cobra.Command{ Use: "home", Short: "Display the workspace home path", RunE: func(cmd *cobra.Command, args []string) error { - homePath, err := config.Resolve("server", "root") - if err != nil { - return err - } + homePath := config.MustResolve("server", "root") raw, _ := cmd.Flags().GetBool("raw") if styles.OutputRaw(cmd.OutOrStdout(), raw, homePath) { diff --git a/internals/config/resolve.go b/internals/config/resolve.go index 4ad7418..d423065 100644 --- a/internals/config/resolve.go +++ b/internals/config/resolve.go @@ -48,6 +48,9 @@ func ResolveList(group, prop, override string) ([]string, error) { } func ResolveKey(runtimeKey string) (string, error) { + if v := env.String(runtimeKey); v != "" { + return v, nil + } ref, err := LoadEnvReference() if err != nil { return "", err @@ -55,6 +58,18 @@ func ResolveKey(runtimeKey string) (string, error) { return ref.Resolve(runtimeKey), nil } +func MustResolve(group, prop string) string { + return MustResolveKey(RuntimeKey(group, prop)) +} + +func MustResolveKey(runtimeKey string) string { + v, err := ResolveKey(runtimeKey) + if err != nil { + panic(fmt.Sprintf("config: %s: %v", runtimeKey, err)) + } + return v +} + func ResolveListKey(runtimeKey, override string) ([]string, error) { ref, err := LoadEnvReference() if err != nil { diff --git a/internals/logger/logger.go b/internals/logger/logger.go index bd36725..0a3efe1 100644 --- a/internals/logger/logger.go +++ b/internals/logger/logger.go @@ -72,16 +72,10 @@ var Log = func(writer io.Writer, level, message string, indent int, withStamp bo } func NewReader(tailLines int, levelFilter string) (*Reader, error) { - dir, err := config.Resolve("logging", "dir") - if err != nil { - return nil, err - } - file, err := config.Resolve("logging", "main_file") - if err != nil { - return nil, err - } - - logPath := filepath.Join(dir, file) + logPath := filepath.Join( + config.MustResolve("logging", "dir"), + config.MustResolve("logging", "main_file"), + ) if _, err := os.Stat(logPath); os.IsNotExist(err) { return nil, fmt.Errorf("log file not found at %s", logPath) diff --git a/internals/metrics/system.go b/internals/metrics/system.go index 0ce0c93..150af54 100644 --- a/internals/metrics/system.go +++ b/internals/metrics/system.go @@ -11,8 +11,7 @@ import ( ) func GetDiskStats() (*DiskStats, error) { - root, _ := config.Resolve("server", "root") - return GetDiskStatsForPath(root) + return GetDiskStatsForPath(config.MustResolve("server", "root")) } func GetDiskStatsForPath(path string) (*DiskStats, error) {