diff --git a/.github/workflows/image.yaml b/.github/workflows/image.yaml index 3027242..1fb50df 100644 --- a/.github/workflows/image.yaml +++ b/.github/workflows/image.yaml @@ -39,7 +39,7 @@ jobs: ghcr.io/evryn/kermoo quay.io/evryn/kermoo flavor: | - latest=true + latest=auto tags: | type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} diff --git a/commands/start_command.go b/commands/start_command.go index dfb1d0d..02c8cf6 100644 --- a/commands/start_command.go +++ b/commands/start_command.go @@ -13,12 +13,18 @@ func GetStartCommand() *cobra.Command { Use: "start", Short: "Start the Kermoo foreground service", Run: func(cmd *cobra.Command, args []string) { + config, _ := cmd.Flags().GetString("config") filename, _ := cmd.Flags().GetString("filename") verbosity, _ := cmd.Flags().GetString("verbosity") logger.MustInitLogger(verbosity) - user_config.MustLoadProvidedConfig(filename) + if filename != "" { + logger.Log.Warn("`filename` flag is deprecated and will be removed in a future major release. use `config` flag instead.") + config = filename + } + + user_config.MustLoadPreparedConfig(config) user_config.Prepared.Start() @@ -29,7 +35,8 @@ func GetStartCommand() *cobra.Command { }, } - cmd.Flags().StringP("filename", "f", "", "Path to your .yaml or .json configuration file") + cmd.Flags().StringP("filename", "f", "", "Alias to `config` flag") + cmd.Flags().StringP("config", "c", "", "Your YAML or JSON config content or path to a config file") cmd.Flags().StringP("verbosity", "v", "", "Verbosity level of logging output. Valid values are: debug, info") return cmd diff --git a/docs/examples/docker-compose/config.yaml b/docs/examples/docker-compose/file-based/config.yaml similarity index 100% rename from docs/examples/docker-compose/config.yaml rename to docs/examples/docker-compose/file-based/config.yaml diff --git a/docs/examples/docker-compose/docker-compose.yaml b/docs/examples/docker-compose/file-based/docker-compose.yaml similarity index 62% rename from docs/examples/docker-compose/docker-compose.yaml rename to docs/examples/docker-compose/file-based/docker-compose.yaml index b017bc5..84e4c69 100644 --- a/docs/examples/docker-compose/docker-compose.yaml +++ b/docs/examples/docker-compose/file-based/docker-compose.yaml @@ -5,4 +5,4 @@ services: image: evryn/kermoo command: start volumes: - - "./config.yaml:/home/kerm/.kermoo/config.yaml" \ No newline at end of file + - "./config.yaml:/home/kerm/.kermoo/config.yaml" diff --git a/docs/examples/docker-compose/inline/docker-compose.yaml b/docs/examples/docker-compose/inline/docker-compose.yaml new file mode 100644 index 0000000..7d9218c --- /dev/null +++ b/docs/examples/docker-compose/inline/docker-compose.yaml @@ -0,0 +1,13 @@ +version: '3.7' + +services: + kermoo: + image: evryn/kermoo + command: + - start + - -f + - | + process: + exit: + after: 2s + code: 5 diff --git a/docs/examples/docker-swarm/config.yaml b/docs/examples/docker-swarm-inline/config-based/config.yaml similarity index 100% rename from docs/examples/docker-swarm/config.yaml rename to docs/examples/docker-swarm-inline/config-based/config.yaml diff --git a/docs/examples/docker-swarm/stack.yaml b/docs/examples/docker-swarm-inline/config-based/stack.yaml similarity index 100% rename from docs/examples/docker-swarm/stack.yaml rename to docs/examples/docker-swarm-inline/config-based/stack.yaml diff --git a/docs/examples/docker-swarm-inline/inline/stack.yaml b/docs/examples/docker-swarm-inline/inline/stack.yaml new file mode 100644 index 0000000..7d9218c --- /dev/null +++ b/docs/examples/docker-swarm-inline/inline/stack.yaml @@ -0,0 +1,13 @@ +version: '3.7' + +services: + kermoo: + image: evryn/kermoo + command: + - start + - -f + - | + process: + exit: + after: 2s + code: 5 diff --git a/docs/examples/kubernetes-manifests/configmap.yaml b/docs/examples/kubernetes/kustomization/configmap.yaml similarity index 100% rename from docs/examples/kubernetes-manifests/configmap.yaml rename to docs/examples/kubernetes/kustomization/configmap.yaml diff --git a/docs/examples/kubernetes-manifests/deployment.yaml b/docs/examples/kubernetes/kustomization/deployment.yaml similarity index 100% rename from docs/examples/kubernetes-manifests/deployment.yaml rename to docs/examples/kubernetes/kustomization/deployment.yaml diff --git a/docs/examples/kubernetes-manifests/kustomization.yaml b/docs/examples/kubernetes/kustomization/kustomization.yaml similarity index 100% rename from docs/examples/kubernetes-manifests/kustomization.yaml rename to docs/examples/kubernetes/kustomization/kustomization.yaml diff --git a/docs/examples/kubernetes-manifests/service.yaml b/docs/examples/kubernetes/kustomization/service.yaml similarity index 100% rename from docs/examples/kubernetes-manifests/service.yaml rename to docs/examples/kubernetes/kustomization/service.yaml diff --git a/modules/user_config/loader.go b/modules/user_config/loader.go index 8fe0ebf..cec3917 100644 --- a/modules/user_config/loader.go +++ b/modules/user_config/loader.go @@ -8,65 +8,65 @@ import ( "kermoo/modules/utils" "os" "os/user" + "strings" "go.uber.org/zap" ) -func MustLoadProvidedConfig(filename string) { - uc, err := LoadUserConfig(filename) +func MustLoadPreparedConfig(config string) { + prepared, err := MakePreparedConfig(config) if err != nil { - logger.Log.Fatal("invalid initial user-provided config", zap.Error(err)) + logger.Log.Panic(err.Error()) } - logger.Log.Info("initial configuration is loaded", zap.Any("unprepared", *uc)) + Prepared = *prepared +} - prepared, err := uc.GetPreparedConfig() +// MakePreparedConfig resolves the configuration content and prepares the config object in the following order: +// +// 1. When no config is provided, it autoloads it from `{userHome}/.kermoo/config.[yaml|yml|json]` +// +// 2. When it couldn't autoload from file, it tries to load from `KERMOO_CONFIG` environment variable +// +// 3. When config is given and equals to "-", it tries to read from stdin (Standard Input) pipe +// +// 4. When the config is a file path, it tries to load it from that file. +// +// 5. Otherwise, it considers the content of config as the actual config and tries to parse that. +func MakePreparedConfig(config string) (*PreparedConfigType, error) { + uc, err := makeUserConfig(config) if err != nil { - logger.Log.Fatal("invalid prepared user-provided config", zap.Error(err)) + return nil, fmt.Errorf("invalid config: %v", err) } - logger.Log.Info("prepared configuration is loaded", zap.Any("prepared", prepared)) + prepared, err := uc.GetPreparedConfig() - Prepared = *prepared + if err != nil { + return nil, fmt.Errorf("unable to preapre parsed config: %v", err) + } + + return prepared, nil } -func LoadUserConfig(filename string) (*UserConfigType, error) { +func makeUserConfig(config string) (*UserConfigType, error) { var err error var uc UserConfigType - if filename == "" { - u, err := user.Current() - if err != nil { - return nil, fmt.Errorf("unable to determine current user to autoload config: %v", err) - } - filename = u.HomeDir + "/.kermoo/config.yaml" - } - - if filename == "-" { - logger.Log.Debug("loading configuration from stdin...") + config, err = getResolvedConfig(config) - content, err := readStdin() - if err != nil { - return nil, err - } + if err != nil { + return nil, err + } - uc, err = unmarshal(content) - if err != nil { - return nil, err - } - } else { - logger.Log.Debug("loading configuration from file...", zap.String("filename", filename)) - content, err := readFile(filename) - if err != nil { - return nil, err - } + if config == "" { + return nil, fmt.Errorf("resolved config is empty") + } - uc, err = unmarshal(content) - if err != nil { - return nil, err - } + uc, err = unmarshal(config) + if err != nil { + return nil, err } err = uc.Validate() @@ -78,8 +78,58 @@ func LoadUserConfig(filename string) (*UserConfigType, error) { return &uc, nil } +func getResolvedConfig(config string) (string, error) { + if config == "" { + return getAutoloadedConfig() + } + + if config == "-" { + logger.Log.Debug("loading configuration from stdin...") + + return readStdin() + } + + // TODO: Change the following to something more sophisticated + if !strings.ContainsAny(config, "\n\t{}") { + return readFile(config) + } + + return config, nil +} + +func getAutoloadedConfig() (string, error) { + u, err := user.Current() + if err != nil { + return "", fmt.Errorf("unable to determine current user to autoload config: %v", err) + } + + pathes := []string{ + u.HomeDir + "/.kermoo/config.yaml", + u.HomeDir + "/.kermoo/config.yml", + u.HomeDir + "/.kermoo/config.json", + u.HomeDir + "/.config/kermoo/config.yaml", + u.HomeDir + "/.config/kermoo/config.yml", + u.HomeDir + "/.config/kermoo/config.json", + } + + for _, path := range pathes { + content, err := readFile(u.HomeDir + path) + + if err == nil { + return content, nil + } + } + + content := os.Getenv("KERMOO_CONFIG") + if content != "" { + return content, nil + } + + return "", fmt.Errorf("no config is specified so we tried to autoload config but was unable to load it either from default path (%v) or from the environment variable. kermoo can not live without a config :(", u.HomeDir+"/.kermoo/") +} + func readFile(filename string) (string, error) { - logger.Log.Debug("reading file", zap.String("filename", filename)) + logger.Log.Debug("reading file ...", zap.String("filename", filename)) body, err := os.ReadFile(filename) if err != nil { @@ -90,6 +140,8 @@ func readFile(filename string) (string, error) { } func readStdin() (string, error) { + logger.Log.Debug("reading stdin ...") + stat, _ := os.Stdin.Stat() if (stat.Mode() & os.ModeCharDevice) != 0 { return "", fmt.Errorf("stdin is not available to read from") @@ -109,6 +161,8 @@ func readStdin() (string, error) { } func unmarshal(content string) (UserConfigType, error) { + logger.Log.Debug("unmarshaling config ...", zap.Any("config", content)) + firstChar := string(content[0]) // Content is probably JSON diff --git a/tests/e2e/webserver_test.go b/tests/e2e/webserver_test.go index 7800b5c..c7b04ea 100644 --- a/tests/e2e/webserver_test.go +++ b/tests/e2e/webserver_test.go @@ -211,7 +211,7 @@ func TestWebserverEndToEnd(t *testing.T) { content: static: hello-world fault: - interval: 100ms + interval: 50ms percentage: 50 `, 3*time.Second) @@ -237,7 +237,7 @@ func TestWebserverEndToEnd(t *testing.T) { e2e.Start(` plans: - name: readiness - interval: 100ms + interval: 50ms percentage: 40 webServers: - port: 8080 diff --git a/tests/units/stubs/invalid_structure.json b/tests/units/stubs/invalid_structure.json index dac0563..87871cc 100644 --- a/tests/units/stubs/invalid_structure.json +++ b/tests/units/stubs/invalid_structure.json @@ -1,8 +1,7 @@ { - "schemaVersion": "invalid-schema-version", "process": { "exit": { - "after": "10ms to 1s100ms", + "after": "something invalid", "code": 2 } } diff --git a/tests/units/stubs/invalid_structure.yaml b/tests/units/stubs/invalid_structure.yaml index fff9008..a89e031 100644 --- a/tests/units/stubs/invalid_structure.yaml +++ b/tests/units/stubs/invalid_structure.yaml @@ -1,5 +1,4 @@ -schemaVersion: "invalid-schema-version" process: exit: - after: 10ms to 1s100ms + after: something invalid code: 2 \ No newline at end of file diff --git a/tests/units/stubs/valid.json b/tests/units/stubs/valid.json index 0a75b71..e00d8d4 100644 --- a/tests/units/stubs/valid.json +++ b/tests/units/stubs/valid.json @@ -1,5 +1,4 @@ { - "schemaVersion": "1", "process": { "exit": { "after": "10ms to 1s100ms", diff --git a/tests/units/stubs/valid.yaml b/tests/units/stubs/valid.yaml index 9b7c72c..5d33ca1 100644 --- a/tests/units/stubs/valid.yaml +++ b/tests/units/stubs/valid.yaml @@ -1,4 +1,3 @@ -schemaVersion: "1" process: exit: after: 10ms to 1s100ms diff --git a/tests/units/user_config/user_config_test.go b/tests/units/user_config/user_config_test.go index 310d4c3..683b089 100644 --- a/tests/units/user_config/user_config_test.go +++ b/tests/units/user_config/user_config_test.go @@ -6,125 +6,201 @@ import ( "os" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestLoadUserConfig(t *testing.T) { - logger.MustInitLogger("fatal") +const ROOT = "../../.." - root := "../../.." +const ( + PATH_VALID_YAML = ROOT + "/tests/units/stubs/valid.yaml" + PATH_VALID_JSON = ROOT + "/tests/units/stubs/valid.json" + PATH_INVALID_YAML = ROOT + "/tests/units/stubs/invalid.yaml" + PATH_INVALID_JSON = ROOT + "/tests/units/stubs/invalid.json" + PATH_INVALSTRUCTURE_YAML = ROOT + "/tests/units/stubs/invalid_structure.yaml" + PATH_INVALSTRUCTURE_JSON = ROOT + "/tests/units/stubs/invalid_structure.json" +) + +func TestMakeConfigFromFilename(t *testing.T) { + logger.MustInitLogger("fatal") tt := []struct { - name string - filename string - isError bool - errMsg string - stdin string + name string + filename string + expectsError bool }{ - // { - // name: "filename is empty", - // filename: "", - // isError: true, - // errMsg: "provided filename is empty", - // }, { - name: "filename is stdin and valid json", - filename: "-", - isError: false, - stdin: "{\"schemaVersion\":\"1\",\"process\":{\"exit\":{\"after\":\"10ms to 1s100ms\",\"code\":2}}}", + name: "valid json file", + filename: PATH_VALID_JSON, }, { - name: "filename is stdin and valid yaml", - filename: "-", - isError: false, - stdin: "schemaVersion: \"1\"\nprocess:\n exit:\n after:\n 10ms to 1s100ms\n code: 2", + name: "valid yaml file", + filename: PATH_VALID_YAML, }, { - name: "valid json file", - filename: root + "/tests/units/stubs/valid.json", - isError: false, + name: "invalid json file", + filename: PATH_INVALID_JSON, + expectsError: true, }, { - name: "valid yaml file", - filename: root + "/tests/units/stubs/valid.yaml", - isError: false, + name: "invalid yaml file", + filename: PATH_INVALID_YAML, + expectsError: true, }, { - name: "invalid json file", - filename: root + "/tests/units/stubs/invalid.json", - isError: true, - errMsg: "unable to unmarshal json content", + name: "non-existent file", + filename: "/path/to/non_existent.json", + expectsError: true, }, { - name: "invalid yaml file", - filename: root + "/tests/units/stubs/invalid.yaml", - isError: true, - errMsg: "invalid yaml configuration", + name: "parsable json but invalid structure", + filename: PATH_INVALSTRUCTURE_JSON, + expectsError: true, }, { - name: "non-existent file", - filename: root + "/tests/units/stubs/non_existent.json", - isError: true, - errMsg: "unable to read file", + name: "parsable yaml but invalid structure", + filename: PATH_INVALSTRUCTURE_YAML, + expectsError: true, }, + } + + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + _, err := user_config.MakePreparedConfig(tc.filename) + + if tc.expectsError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestMakeConfigFromStdin(t *testing.T) { + logger.MustInitLogger("fatal") + + tt := []struct { + name string + stdin string + expectsError bool + }{ { - name: "valid json but does not match user_config_type", - filename: root + "/tests/units/stubs/invalid_structure.json", - isError: true, - errMsg: "schema version is not supported", + name: "valid json", + stdin: getFileContent(t, PATH_VALID_JSON), }, { - name: "valid yaml but does not match user_config_type", - filename: root + "/tests/units/stubs/invalid_structure.yaml", - isError: true, - errMsg: "schema version is not supported", + name: "valid yaml", + stdin: getFileContent(t, PATH_VALID_YAML), + }, + + { + name: "invalid json", + stdin: getFileContent(t, PATH_INVALID_JSON), + expectsError: true, + }, + { + name: "invalid yaml", + stdin: getFileContent(t, PATH_INVALID_YAML), + expectsError: true, + }, + { + name: "parsable json with invalid structure", + stdin: getFileContent(t, PATH_INVALSTRUCTURE_JSON), + expectsError: true, }, { - name: "stdin is not available", - filename: "-", - isError: true, - errMsg: "stdin is not available to read from", + name: "parsable yaml with invalid structure", + stdin: getFileContent(t, PATH_INVALSTRUCTURE_YAML), + expectsError: true, }, } for _, tc := range tt { t.Run(tc.name, func(t *testing.T) { - // Temporarily replace os.Stdin if we're testing with filename == "-" - if tc.filename == "-" && tc.stdin != "" { + if tc.stdin != "" { tmpfile, err := os.CreateTemp("", "stdin") if err != nil { - t.Fatalf("Failed to create temporary file: %v", err) + t.Fatalf("failed to create temporary file: %v", err) } defer os.Remove(tmpfile.Name()) tmpfile.WriteString(tc.stdin) - tmpfile.Seek(0, 0) // rewind + tmpfile.Seek(0, 0) oldStdin := os.Stdin - defer func() { os.Stdin = oldStdin }() // Restore original Stdin + defer func() { os.Stdin = oldStdin }() os.Stdin = tmpfile } - uc, err := user_config.LoadUserConfig(tc.filename) + _, err := user_config.MakePreparedConfig("-") - if tc.isError { + if tc.expectsError { require.Error(t, err) - if err != nil { - assert.Contains(t, err.Error(), tc.errMsg) - } } else { require.NoError(t, err) + } + }) + } +} - pc, err := uc.GetPreparedConfig() +func TestMakeConfigFromString(t *testing.T) { + logger.MustInitLogger("fatal") - require.NoError(t, err, "prepared config is problematic") + tt := []struct { + name string + content string + expectsError bool + }{ + { + name: "valid json", + content: getFileContent(t, PATH_VALID_JSON), + }, + { + name: "valid yaml", + content: getFileContent(t, PATH_VALID_YAML), + }, - err = pc.Validate() + { + name: "invalid json", + content: getFileContent(t, PATH_INVALID_JSON), + expectsError: true, + }, + { + name: "invalid yaml", + content: getFileContent(t, PATH_INVALID_YAML), + expectsError: true, + }, + { + name: "parsable json with invalid structure", + content: getFileContent(t, PATH_INVALSTRUCTURE_JSON), + expectsError: true, + }, + { + name: "parsable yaml with invalid structure", + content: getFileContent(t, PATH_INVALSTRUCTURE_YAML), + expectsError: true, + }, + } - require.NoError(t, err, "prepared config is invalid") - } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + _, err := user_config.MakePreparedConfig(tc.content) + if tc.expectsError { + require.Error(t, err) + } else { + require.NoError(t, err) + } }) } } + +func getFileContent(t *testing.T, filename string) string { + bytes, err := os.ReadFile(filename) + + if err != nil { + t.Fatal("unable to load file from "+filename, err) + } + + return string(bytes) +}