diff --git a/cmd/root.go b/cmd/root.go index e162eeeb..2942c6c8 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -23,6 +23,7 @@ import ( "os" "path/filepath" + "kitops/pkg/cmd/config" "kitops/pkg/cmd/dev" "kitops/pkg/cmd/info" "kitops/pkg/cmd/inspect" @@ -68,6 +69,29 @@ func RunCommand() *cobra.Command { PersistentPreRunE: func(cmd *cobra.Command, args []string) error { output.SetOut(cmd.OutOrStdout()) output.SetErr(cmd.ErrOrStderr()) + + // Load config from the file (or default if it doesn't exist) + configHome, err := getConfigHome(opts) + if err != nil { + output.Errorf("Failed to read base config directory") + output.Infof("Use the --config flag or set the $%s environment variable to provide a default", constants.KitopsHomeEnvVar) + output.Debugf("Error: %s", err) + return errors.New("exit") + } + + configPath := constants.ConfigFilePath(configHome) + cfg, err := config.LoadConfig(configPath) + if err != nil { + return output.Fatalf("Failed to load config: %s", err) + } + // Override the config values with flag values if the flags were provided + if opts.loglevel == "" && cfg.LogLevel != "" { + opts.loglevel = cfg.LogLevel + } + if opts.progressBars == "" && cfg.Progress != "" { + opts.progressBars = cfg.Progress + } + if err := output.SetLogLevelFromString(opts.loglevel); err != nil { return output.Fatalln(err) } @@ -88,13 +112,6 @@ func RunCommand() *cobra.Command { output.SetProgressBars("none") } - configHome, err := getConfigHome(opts) - if err != nil { - output.Errorf("Failed to read base config directory") - output.Infof("Use the --config flag or set the $%s environment variable to provide a default", constants.KitopsHomeEnvVar) - output.Debugf("Error: %s", err) - return errors.New("exit") - } ctx := context.WithValue(cmd.Context(), constants.ConfigKey{}, configHome) cmd.SetContext(ctx) @@ -142,6 +159,7 @@ func addSubcommands(rootCmd *cobra.Command) { rootCmd.AddCommand(pull.PullCommand()) rootCmd.AddCommand(tag.TagCommand()) rootCmd.AddCommand(list.ListCommand()) + rootCmd.AddCommand(config.ConfigCommand()) rootCmd.AddCommand(inspect.InspectCommand()) rootCmd.AddCommand(info.InfoCommand()) rootCmd.AddCommand(remove.RemoveCommand()) @@ -161,6 +179,7 @@ func Execute() { } func getConfigHome(opts *rootOptions) (string, error) { + // First check if the config path is provided via flags if opts.configHome != "" { output.Debugf("Using config directory from flag: %s", opts.configHome) absHome, err := filepath.Abs(opts.configHome) @@ -170,6 +189,7 @@ func getConfigHome(opts *rootOptions) (string, error) { return absHome, nil } + // Then check if it's provided via environment variable envHome := os.Getenv(constants.KitopsHomeEnvVar) if envHome != "" { output.Debugf("Using config directory from environment variable: %s", envHome) @@ -180,6 +200,7 @@ func getConfigHome(opts *rootOptions) (string, error) { return absHome, nil } + // Finally, fall back to the default path defaultHome, err := constants.DefaultConfigPath() if err != nil { return "", err diff --git a/go.mod b/go.mod index 56dc79e5..4961814f 100644 --- a/go.mod +++ b/go.mod @@ -29,4 +29,5 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/text v0.19.0 ) diff --git a/pkg/cmd/config/cmd.go b/pkg/cmd/config/cmd.go new file mode 100644 index 00000000..2169effb --- /dev/null +++ b/pkg/cmd/config/cmd.go @@ -0,0 +1,166 @@ +// Copyright 2024 The KitOps Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +package config + +import ( + "context" + "kitops/pkg/lib/constants" + "kitops/pkg/output" + + "github.com/spf13/cobra" +) + +const ( + shortDesc = `Manage configuration for KitOps CLI` + longDesc = `Allows setting, getting, listing, and resetting configuration options for the KitOps CLI. + +This command provides functionality to manage configuration settings such as +storage paths, credentials file location, CLI version, and update notification preferences. +The configuration values can be set using specific keys, retrieved for inspection, listed, +or reset to default values.` + + example = `# Set a configuration option +kit config set storageSubpath /path/to/storage + +# Get a configuration option +kit config get storageSubpath + +# List all configuration options +kit config list + +# Reset configuration to default values +kit config reset` +) + +// Root config command. +func ConfigCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: shortDesc, + Long: longDesc, + Example: example, + } + + // Add subcommands to the root config command. + cmd.AddCommand(setCmd()) + cmd.AddCommand(getCmd()) + cmd.AddCommand(listCmd()) + cmd.AddCommand(resetCmd()) + + return cmd +} + +// Subcommand for 'set' +func setCmd() *cobra.Command { + opts := &configOptions{} + cmd := &cobra.Command{ + Use: "set [key] [value]", + Short: "Set a configuration value", + Args: cobra.ExactArgs(2), // Ensure exactly 2 arguments: key and value. + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + opts.key, opts.value = args[0], args[1] + if err := opts.complete(ctx); err != nil { + return output.Fatalf("failed to complete options: %w", err) + } + if err := setConfig(ctx, opts); err != nil { + output.Fatalf("Failed to set config: %s", err) + } + output.Infof("Configuration key '%s' set to '%s'", opts.key, opts.value) + return nil + }, + } + + return cmd +} + +// Subcommand for 'get' +func getCmd() *cobra.Command { + opts := &configOptions{} + cmd := &cobra.Command{ + Use: "get [key]", + Short: "Get a configuration value", + Args: cobra.ExactArgs(1), // Ensure exactly 1 argument: key. + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + opts.key = args[0] + if err := opts.complete(ctx); err != nil { + return output.Fatalf("failed to complete options: %w", err) + } + value, err := getConfig(ctx, opts) + if err != nil { + return output.Fatalf("failed to get config: %w", err) + } + output.Infof("Configuration key '%s': '%s'", opts.key, value) + return nil + }, + } + + return cmd +} + +// Subcommand for 'list' +func listCmd() *cobra.Command { + opts := &configOptions{} + cmd := &cobra.Command{ + Use: "list", + Short: "List all configuration values", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + if err := opts.complete(ctx); err != nil { + return output.Fatalf("failed to complete options: %w", err) + } + if err := listConfig(ctx, opts); err != nil { + return output.Fatalf("failed to list configs: %w", err) + } + return nil + }, + } + + return cmd +} + +// Subcommand for 'reset' +func resetCmd() *cobra.Command { + opts := &configOptions{} + cmd := &cobra.Command{ + Use: "reset", + Short: "Reset configuration to default values", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + if err := opts.complete(ctx); err != nil { + return output.Fatalf("failed to complete options: %w", err) + } + if err := resetConfig(ctx, opts); err != nil { + return output.Fatalf("failed to reset config: %w", err) + } + output.Infof("Configuration reset to default values") + return nil + }, + } + + return cmd +} + +// complete populates configOptions fields. +func (opts *configOptions) complete(ctx context.Context) error { + configHome, ok := ctx.Value(constants.ConfigKey{}).(string) + if !ok { + return output.Fatalf("default config path not set on command context") + } + opts.configHome = configHome + return nil +} diff --git a/pkg/cmd/config/config.go b/pkg/cmd/config/config.go new file mode 100644 index 00000000..7a426077 --- /dev/null +++ b/pkg/cmd/config/config.go @@ -0,0 +1,206 @@ +// Copyright 2024 The KitOps Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 +package config + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/fs" + "kitops/pkg/output" + "os" + "path/filepath" + "reflect" + + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +type Config struct { + LogLevel string `json:"logLevel"` + Progress string `json:"progress"` +} + +// DefaultConfig returns a Config struct with default values. +func DefaultConfig() *Config { + return &Config{ + LogLevel: output.LogLevelInfo.String(), + Progress: "plain", + } +} + +// Set a configuration key and value. +func setConfig(_ context.Context, opts *configOptions) error { + configPath := getConfigPath(opts.profile) + cfg, err := LoadConfig(configPath) + if err != nil { + cfg = DefaultConfig() + } + + // Use reflection to set the field in the config struct. + configValue := reflect.ValueOf(cfg) + if configValue.Kind() == reflect.Ptr { + configValue = configValue.Elem() + } + + // Convert opts.key to lowercase for lookup. + fieldNameLower := cases.Lower(language.Und).String(opts.key) + + // Loop through struct fields to match case-insensitively. + var field reflect.Value + t := configValue.Type() + for i := 0; i < configValue.NumField(); i++ { + structField := t.Field(i) + if cases.Lower(language.Und).String(structField.Name) == fieldNameLower { + field = configValue.Field(i) + break + } + } + + if !field.IsValid() { + return output.Fatalf("unknown configuration key: %s", opts.key) + } + + // Check if the field is settable and of the correct type. + if field.CanSet() && field.Kind() == reflect.String { + field.SetString(opts.value) + } else { + return output.Fatalf("configuration key %s is not of type string or not settable", opts.key) + } + + err = SaveConfig(cfg, configPath) + if err != nil { + return err + } + return nil +} + +// Get a configuration value. +func getConfig(_ context.Context, opts *configOptions) (string, error) { + configPath := getConfigPath(opts.profile) + cfg, err := LoadConfig(configPath) + if err != nil { + return "", err + } + + v := reflect.ValueOf(cfg).Elem().FieldByName(cases.Title(language.Und).String(opts.key)) + if !v.IsValid() { + return "", output.Fatalf("unknown configuration key: %s", opts.key) + } + + return fmt.Sprintf("%v", v.Interface()), nil +} + +// List all configuration values. +func listConfig(_ context.Context, opts *configOptions) error { + configPath := getConfigPath(opts.profile) + cfg, err := LoadConfig(configPath) + if err != nil { + return err + } + + // Use reflection to iterate through fields and print them. + v := reflect.ValueOf(cfg).Elem() + t := v.Type() + for i := 0; i < v.NumField(); i++ { + fmt.Printf("%s: %v\n", t.Field(i).Name, v.Field(i).Interface()) + } + return nil +} + +// Reset configuration to defaults. +func resetConfig(_ context.Context, opts *configOptions) error { + configPath := getConfigPath(opts.profile) + cfg := DefaultConfig() + err := SaveConfig(cfg, configPath) + if err != nil { + return err + } + fmt.Println("Configuration reset to default values.") + return nil +} + +// Load configuration from a file. +func LoadConfig(configPath string) (*Config, error) { + if configPath == "" { + return nil, output.Fatalf("config path is empty") + } + + file, err := os.Open(configPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + // Return default config, but don't save it to the file. + return DefaultConfig(), nil + } + return nil, output.Fatalf("error opening config file: %w", err) + } + defer file.Close() + + var config Config + if err := json.NewDecoder(file).Decode(&config); err != nil { + return nil, output.Fatalf("error decoding config file: %w", err) + } + + // If some fields are empty, fallback to defaults. + defaultConfig := DefaultConfig() + if config.LogLevel == "" { + config.LogLevel = defaultConfig.LogLevel + } + if config.Progress == "" { + config.Progress = defaultConfig.Progress + } + + return &config, nil +} + +// Save configuration to a file. +func SaveConfig(config *Config, configPath string) error { + // Ensure the directory exists before saving the file. + dir := filepath.Dir(configPath) + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + + file, err := os.Create(configPath) + if err != nil { + return err + } + defer file.Close() + + return json.NewEncoder(file).Encode(config) +} + +// Get the config path, either from the profile or default. +func getConfigPath(profile string) string { + configDir := os.Getenv("KITOPS_HOME") + if configDir == "" { + homeDir, _ := os.UserHomeDir() + configDir = filepath.Join(homeDir, ".kitops") + } + if profile != "" { + configDir = filepath.Join(configDir, "profiles", profile) + } + return filepath.Join(configDir, "config.json") +} + +// ConfigOptions struct to store command options. +type configOptions struct { + key string + value string + profile string + configHome string +} diff --git a/pkg/lib/constants/consts.go b/pkg/lib/constants/consts.go index b6a8c68f..b120b4a0 100644 --- a/pkg/lib/constants/consts.go +++ b/pkg/lib/constants/consts.go @@ -50,6 +50,7 @@ const ( // MaxModelRefChain is the maximum number of "parent" modelkits a modelkit may have // by e.g. referring to another modelkit in its .model.path MaxModelRefChain = 10 + ConfigFileName = "config.json" ) var ( @@ -103,6 +104,9 @@ func DefaultConfigPath() (string, error) { func StoragePath(configBase string) string { return filepath.Join(configBase, StorageSubpath) } +func ConfigFilePath(configHome string) string { + return filepath.Join(configHome, ConfigFileName) +} func IngestPath(storageBase string) string { return filepath.Join(storageBase, "ingest") diff --git a/pkg/output/level.go b/pkg/output/level.go index 2f4325a2..8a9c23f2 100644 --- a/pkg/output/level.go +++ b/pkg/output/level.go @@ -35,6 +35,23 @@ const ( LogLevelError ) +func (l LogLevel) String() string { + switch l { + case LogLevelTrace: + return "trace" + case LogLevelDebug: + return "debug" + case LogLevelInfo: + return "info" + case LogLevelWarn: + return "warn" + case LogLevelError: + return "error" + default: + return "unknown" + } +} + var ( colorNone = "\033[0m" colorTrace = "\033[0m" // No color