Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

[feat:] Added Config Command to Kitops CLI #523

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 35 additions & 10 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"os"
"path/filepath"

"kitops/pkg/cmd/config"
"kitops/pkg/cmd/dev"
"kitops/pkg/cmd/info"
"kitops/pkg/cmd/inspect"
Expand Down Expand Up @@ -55,6 +56,36 @@ 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 := filepath.Join(configHome, "config.json")
amisevsk marked this conversation as resolved.
Show resolved Hide resolved
cfg, err := config.LoadConfig(configPath)
if err != nil {
if !os.IsNotExist(err) { // If the config file exists but there's an error, report it
return output.Fatalf("Failed to load config: %s", err)
}
cfg = config.DefaultConfig() // Load default config if file doesn't exist
amisevsk marked this conversation as resolved.
Show resolved Hide resolved
}

// Override the flags with config values if present
if cfg.LogLevel != "" {
opts.loglevel = cfg.LogLevel
}
if cfg.Progress != "" {
opts.progressBars = cfg.Progress
}
if cfg.Verbose != 0 {
opts.verbosity = cfg.Verbose
}

amisevsk marked this conversation as resolved.
Show resolved Hide resolved
if err := output.SetLogLevelFromString(opts.loglevel); err != nil {
return output.Fatalln(err)
}
Expand All @@ -75,13 +106,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)

Expand Down Expand Up @@ -129,6 +153,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())
Expand All @@ -148,29 +173,29 @@ 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)
amisevsk marked this conversation as resolved.
Show resolved Hide resolved
absHome, err := filepath.Abs(opts.configHome)
if err != nil {
return "", fmt.Errorf("failed to get absolute path for %s: %w", opts.configHome, err)
}
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)
absHome, err := filepath.Abs(envHome)
if err != nil {
return "", fmt.Errorf("failed to get absolute path for %s: %w", constants.KitopsHomeEnvVar, err)
}
return absHome, nil
}

// Finally, fall back to the default path
defaultHome, err := constants.DefaultConfigPath()
if err != nil {
return "", err
}
output.Debugf("Using default config directory: %s", defaultHome)
return defaultHome, nil
}
145 changes: 145 additions & 0 deletions pkg/cmd/config/cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
package config

// 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
amisevsk marked this conversation as resolved.
Show resolved Hide resolved

import (
"context"
"fmt"
"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`
)

func (opts *configOptions) complete(ctx context.Context, args []string) error {
if len(args) == 0 {
return fmt.Errorf("no configuration key provided")
}
opts.key = args[0]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way that config command is implemented is set/get/list/reset are not subcommands but rather args. Therefore the args[0] in here receives the value of the subcommand (get/set/list/reset). This works for no argument subcommands (list/reset) but everything else does not work.

I think a better way to do this would be to add the (set/get/list/reset) as subcommands.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored to include set/get/list/reset as subcommands.


if len(args) > 1 {
opts.value = args[1]
}

configHome, ok := ctx.Value(constants.ConfigKey{}).(string)
if !ok {
return fmt.Errorf("default config path not set on command context")
}
opts.configHome = configHome

return nil
}

// ConfigCommand represents the config command
func ConfigCommand() *cobra.Command {
opts := &configOptions{}

cmd := &cobra.Command{
Use: "config [set|get|list|reset] <key> [value]",
amisevsk marked this conversation as resolved.
Show resolved Hide resolved
Short: shortDesc,
Long: longDesc,
Example: example,
RunE: runCommand(opts),
}

cmd.Args = cobra.MinimumNArgs(1)
cmd.Flags().SortFlags = false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not needed if there are no flags.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored


return cmd
}

func runCommand(opts *configOptions) func(cmd *cobra.Command, args []string) error {
return func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()

// Handle each command and its required/optional arguments
switch args[0] {
case "set":
if len(args) < 3 {
return output.Fatalf("Missing key or value for 'set'. Usage: kit config set <key> <value>")
}
opts.key, opts.value = args[1], args[2]
if err := opts.complete(ctx, args); err != nil {
return output.Fatalf("Invalid arguments: %s", err)
}
if err := setConfig(ctx, opts); err != nil {
return output.Fatalf("Failed to set config: %s", err)
}
output.Infof("Configuration key '%s' set to '%s'", opts.key, opts.value)

case "get":
if len(args) < 2 {
return output.Fatalf("Missing key for 'get'. Usage: kit config get <key>")
}
opts.key = args[1]
if err := opts.complete(ctx, args); err != nil {
return output.Fatalf("Invalid arguments: %s", err)
}
value, err := getConfig(ctx, opts)
if err != nil {
return output.Fatalf("Failed to get config: %s", err)
}
output.Infof("Configuration key '%s': '%s'", opts.key, value)

case "list":
// No key required for 'list'
if err := opts.complete(ctx, args); err != nil {
return output.Fatalf("Invalid arguments: %s", err)
}
if err := listConfig(ctx, opts); err != nil {
return output.Fatalf("Failed to list configs: %s", err)
}

case "reset":
// No key required for 'reset'
if err := opts.complete(ctx, args); err != nil {
return output.Fatalf("Invalid arguments: %s", err)
}
if err := resetConfig(ctx, opts); err != nil {
return output.Fatalf("Failed to reset config: %s", err)
}
output.Infof("Configuration reset to default values")

default:
return output.Fatalf("Unknown command: %s. Available commands are: set, get, list, reset", args[0])
}

return nil
}
}
149 changes: 149 additions & 0 deletions pkg/cmd/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package config

import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
)

type Config struct {
LogLevel string `json:"log_level"`
amisevsk marked this conversation as resolved.
Show resolved Hide resolved
Progress string `json:"progress"`
ConfigDir string `json:"config_dir"`
amisevsk marked this conversation as resolved.
Show resolved Hide resolved
Verbose int `json:"verbose"`
amisevsk marked this conversation as resolved.
Show resolved Hide resolved
}

// DefaultConfig returns a Config struct with default values.
func DefaultConfig() *Config {
return &Config{
LogLevel: "info",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should use the constants from the output package

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you confirm if we should also do this for Progress and ConfigDir as well? If so, we will need to change the var names in output package for Progress to Capitalise and export

Progress: "plain",
ConfigDir: "",
Verbose: 0,
}
}

// 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() // Start with defaults if config doesn't exist.
}
amisevsk marked this conversation as resolved.
Show resolved Hide resolved

v := reflect.ValueOf(cfg).Elem().FieldByName(strings.Title(opts.key))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace strings.Title with cases.Title from the golang.org/x/text/cases package.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Refactored!

if !v.IsValid() {
return fmt.Errorf("unknown configuration key: %s", opts.key)
}

v.SetString(opts.value)
err = SaveConfig(cfg, configPath)
if err != nil {
return err
}
fmt.Printf("Config '%s' set to '%s'\n", opts.key, opts.value)
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(strings.Title(opts.key))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you also update this to use cases.Title?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refactored

if !v.IsValid() {
return "", fmt.Errorf("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, fmt.Errorf("config path is empty")
}

file, err := os.Open(configPath)
if err != nil {
if os.IsNotExist(err) {
return DefaultConfig(), nil // Return default config if file doesn't exist.
}
return nil, err
}
defer file.Close()

var config Config
if err := json.NewDecoder(file).Decode(&config); err != nil {
return nil, err
}
return &config, nil
}

// Save configuration to a file.
func SaveConfig(config *Config, configPath string) error {
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
}