From 782365d45c7b4785702ee949bd90ac30f35d40c0 Mon Sep 17 00:00:00 2001 From: Aleksandar Stojanov Date: Mon, 24 Jun 2024 16:51:50 +0200 Subject: [PATCH 1/5] feature: read configuration from file closes #76 Signed-off-by: Aleksandar Stojanov --- README.md | 2 +- main.go | 21 ++- main_test.go | 38 ++++- pkg/cmd.go | 90 ++++++++++-- pkg/cmd_test.go | 316 +++++++++++++++++++++++++++++++++++++++++- pkg/generator.go | 14 +- pkg/generator_test.go | 72 +++++----- pkg/utils.go | 36 +++-- pkg/utils_test.go | 57 ++++++++ schema.yaml | 12 ++ values.schema.json | 51 +++++++ 11 files changed, 630 insertions(+), 79 deletions(-) create mode 100644 schema.yaml create mode 100644 values.schema.json diff --git a/README.md b/README.md index fdbcaba..9319bdc 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ This is a great tool for adding git hooks to your project. You can find it's doc ```bash $ helm schema -help -usage: helm schema [-input STR] [-draft INT] [-output STR] +Usage: helm schema [options...] -draft int Draft version (4, 6, 7, 2019, or 2020) (default 2020) -indent int diff --git a/main.go b/main.go index f355f09..d1e6e31 100644 --- a/main.go +++ b/main.go @@ -9,16 +9,31 @@ import ( ) func main() { - conf, output, err := pkg.ParseFlags(os.Args[0], os.Args[1:]) + // Load configuration from a YAML file + fileConfig, err := pkg.LoadConfig("schema.yaml") + if err != nil { + fmt.Println("Error loading config file:", err) + } + + // Parse CLI flags + flagConfig, output, err := pkg.ParseFlags(os.Args[0], os.Args[1:]) if err == flag.ErrHelp { fmt.Println(output) return } else if err != nil { - fmt.Println("Error:", output) + fmt.Println("Error parsing flags:", output) return } - err = pkg.GenerateJsonSchema(conf) + // Merge configurations, giving precedence to CLI flags + var finalConfig *pkg.Config + if fileConfig != nil { + finalConfig = pkg.MergeConfig(fileConfig, flagConfig) + } else { + finalConfig = flagConfig + } + + err = pkg.GenerateJsonSchema(finalConfig) if err != nil { fmt.Println("Error:", err) } diff --git a/main_test.go b/main_test.go index 5e0516e..9c45816 100644 --- a/main_test.go +++ b/main_test.go @@ -13,6 +13,8 @@ func TestMain(t *testing.T) { tests := []struct { name string args []string + setup func() + cleanup func() expectedError string expectedOut string }{ @@ -40,6 +42,29 @@ func TestMain(t *testing.T) { expectedOut: "error reading YAML file(s)", expectedError: "", }, + { + name: "ErrorLoadingConfigFile", + args: []string{"schema", "-input", "testdata/basic.yaml"}, + setup: func() { + if _, err := os.Stat("schema.yaml"); err == nil { + os.Rename("schema.yaml", "schema.yaml.bak") + } + + file, _ := os.Create("schema.yaml") + defer file.Close() + file.WriteString("draft: invalid\n") + }, + cleanup: func() { + if _, err := os.Stat("schema.yaml.bak"); err == nil { + os.Remove("schema.yaml") + os.Rename("schema.yaml.bak", "schema.yaml") + } else { + os.Remove("schema.yaml") + } + }, + expectedOut: "", + expectedError: "Error loading config file", + }, } for _, tt := range tests { @@ -47,7 +72,12 @@ func TestMain(t *testing.T) { originalArgs := os.Args originalStdout := os.Stdout - defer os.Remove("values.schema.json") + if tt.setup != nil { + tt.setup() + } + if tt.cleanup != nil { + defer tt.cleanup() + } r, w, _ := os.Pipe() os.Stdout = w @@ -70,9 +100,9 @@ func TestMain(t *testing.T) { out := buf.String() - assert.Contains(t, out, tt.expectedOut) - if tt.expectedError != "" { - assert.Contains(t, out, tt.expectedError) + assert.Contains(t, out, tt.expectedError) + if tt.expectedOut != "" { + assert.Contains(t, out, tt.expectedOut) } }) } diff --git a/pkg/cmd.go b/pkg/cmd.go index dd7e62a..5dd6f0e 100644 --- a/pkg/cmd.go +++ b/pkg/cmd.go @@ -4,32 +4,100 @@ import ( "bytes" "flag" "fmt" + "os" + + "gopkg.in/yaml.v3" ) // Parse flags -func ParseFlags(progname string, args []string) (config *Config, output string, err error) { +func ParseFlags(progname string, args []string) (*Config, string, error) { flags := flag.NewFlagSet(progname, flag.ContinueOnError) var buf bytes.Buffer flags.SetOutput(&buf) - var conf Config - flags.Var(&conf.input, "input", "Multiple yaml files as inputs (comma-separated)") - flags.StringVar(&conf.outputPath, "output", "values.schema.json", "Output file path") - flags.IntVar(&conf.draft, "draft", 2020, "Draft version (4, 6, 7, 2019, or 2020)") - flags.IntVar(&conf.indent, "indent", 4, "Indentation spaces (even number)") + conf := &Config{} + flags.Var(&conf.Input, "input", "Multiple yaml files as inputs (comma-separated)") + flags.StringVar(&conf.OutputPath, "output", "values.schema.json", "Output file path") + flags.IntVar(&conf.Draft, "draft", 2020, "Draft version (4, 6, 7, 2019, or 2020)") + flags.IntVar(&conf.Indent, "indent", 4, "Indentation spaces (even number)") // Nested SchemaRoot flags flags.StringVar(&conf.SchemaRoot.ID, "schemaRoot.id", "", "JSON schema ID") flags.StringVar(&conf.SchemaRoot.Title, "schemaRoot.title", "", "JSON schema title") flags.StringVar(&conf.SchemaRoot.Description, "schemaRoot.description", "", "JSON schema description") - flags.Var(&conf.SchemaRoot.AdditionalProperties, "schemaRoot.additionalProperties", "JSON schema additional properties (true/false)") + flags.Var(&conf.SchemaRoot.AdditionalProperties, "schemaRoot.additionalProperties", "Allow additional properties") - err = flags.Parse(args) + err := flags.Parse(args) if err != nil { - fmt.Println("Usage: helm schema [-input STR] [-draft INT] [-output STR]") + fmt.Println("Usage: helm schema [options...] ") return nil, buf.String(), err } - conf.args = flags.Args() - return &conf, buf.String(), nil + // Mark fields as set if they were provided as flags + flags.Visit(func(f *flag.Flag) { + switch f.Name { + case "output": + conf.OutputPathSet = true + case "draft": + conf.DraftSet = true + case "indent": + conf.IndentSet = true + } + }) + + conf.Args = flags.Args() + return conf, buf.String(), nil +} + +// LoadConfig loads configuration from a YAML file +func LoadConfig(configPath string) (*Config, error) { + data, err := os.ReadFile(configPath) + if err != nil { + if os.IsNotExist(err) { + // Return an empty config if the file does not exist + return &Config{}, nil + } + return nil, err + } + + var config Config + err = yaml.Unmarshal(data, &config) + if err != nil { + return nil, err + } + + return &config, nil +} + +// MergeConfig merges CLI flags into the configuration file values, giving precedence to CLI flags +func MergeConfig(fileConfig, flagConfig *Config) *Config { + mergedConfig := *fileConfig + + if len(flagConfig.Input) > 0 { + mergedConfig.Input = flagConfig.Input + } + if flagConfig.OutputPathSet || mergedConfig.OutputPath == "" { + mergedConfig.OutputPath = flagConfig.OutputPath + } + if flagConfig.DraftSet || mergedConfig.Draft == 0 { + mergedConfig.Draft = flagConfig.Draft + } + if flagConfig.IndentSet || mergedConfig.Indent == 0 { + mergedConfig.Indent = flagConfig.Indent + } + if flagConfig.SchemaRoot.ID != "" { + mergedConfig.SchemaRoot.ID = flagConfig.SchemaRoot.ID + } + if flagConfig.SchemaRoot.Title != "" { + mergedConfig.SchemaRoot.Title = flagConfig.SchemaRoot.Title + } + if flagConfig.SchemaRoot.Description != "" { + mergedConfig.SchemaRoot.Description = flagConfig.SchemaRoot.Description + } + if flagConfig.SchemaRoot.AdditionalProperties.IsSet() { + mergedConfig.SchemaRoot.AdditionalProperties = flagConfig.SchemaRoot.AdditionalProperties + } + mergedConfig.Args = flagConfig.Args + + return &mergedConfig } diff --git a/pkg/cmd_test.go b/pkg/cmd_test.go index 7eca145..cfa2b8b 100644 --- a/pkg/cmd_test.go +++ b/pkg/cmd_test.go @@ -2,9 +2,12 @@ package pkg import ( "flag" + "os" "reflect" "strings" "testing" + + "github.com/stretchr/testify/assert" ) func TestParseFlagsPass(t *testing.T) { @@ -13,19 +16,67 @@ func TestParseFlagsPass(t *testing.T) { conf Config }{ {[]string{"-input", "values.yaml"}, - Config{input: multiStringFlag{"values.yaml"}, outputPath: "values.schema.json", draft: 2020, indent: 4, args: []string{}}}, + Config{ + Input: multiStringFlag{"values.yaml"}, + OutputPath: "values.schema.json", + Draft: 2020, + Indent: 4, + Args: []string{}, + }, + }, {[]string{"-input", "values1.yaml values2.yaml", "-indent", "2"}, - Config{input: multiStringFlag{"values1.yaml values2.yaml"}, outputPath: "values.schema.json", draft: 2020, indent: 2, args: []string{}}}, + Config{ + Input: multiStringFlag{"values1.yaml values2.yaml"}, + OutputPath: "values.schema.json", + Draft: 2020, + Indent: 2, + OutputPathSet: false, + DraftSet: false, + IndentSet: true, + Args: []string{}, + }, + }, {[]string{"-input", "values.yaml", "-output", "my.schema.json", "-draft", "2019", "-indent", "2"}, - Config{input: multiStringFlag{"values.yaml"}, outputPath: "my.schema.json", draft: 2019, indent: 2, args: []string{}}}, + Config{ + Input: multiStringFlag{"values.yaml"}, + OutputPath: "my.schema.json", + Draft: 2019, Indent: 2, + OutputPathSet: true, + DraftSet: true, + IndentSet: true, + Args: []string{}, + }, + }, {[]string{"-input", "values.yaml", "-output", "my.schema.json", "-draft", "2019"}, - Config{input: multiStringFlag{"values.yaml"}, outputPath: "my.schema.json", draft: 2019, indent: 4, args: []string{}}}, + Config{ + Input: multiStringFlag{"values.yaml"}, + OutputPath: "my.schema.json", + Draft: 2019, + Indent: 4, + OutputPathSet: true, + DraftSet: true, + IndentSet: false, + Args: []string{}, + }, + }, {[]string{"-input", "values.yaml", "-schemaRoot.id", "http://example.com/schema", "-schemaRoot.title", "MySchema", "-schemaRoot.description", "My schema description"}, - Config{input: multiStringFlag{"values.yaml"}, outputPath: "values.schema.json", draft: 2020, indent: 4, SchemaRoot: SchemaRoot{ID: "http://example.com/schema", Title: "MySchema", Description: "My schema description"}, args: []string{}}}, + Config{ + Input: multiStringFlag{"values.yaml"}, + OutputPath: "values.schema.json", + Draft: 2020, + Indent: 4, + SchemaRoot: SchemaRoot{ + ID: "http://example.com/schema", + Title: "MySchema", + Description: "My schema description", + }, + Args: []string{}, + }, + }, } for _, tt := range tests { @@ -89,3 +140,258 @@ func TestParseFlagsFail(t *testing.T) { }) } } + +func TestLoadConfig(t *testing.T) { + tests := []struct { + name string + configContent string + expectedConf Config + expectedErr bool + }{ + { + name: "ValidConfig", + configContent: ` +input: + - testdata/empty.yaml + - testdata/meta.yaml +output: values.schema.json +draft: 2020 +indent: 2 +schemaRoot: + id: https://example.com/schema + title: Helm Values Schema + description: Schema for Helm values + additionalProperties: true +`, + expectedConf: Config{ + Input: multiStringFlag{"testdata/empty.yaml", "testdata/meta.yaml"}, + OutputPath: "values.schema.json", + Draft: 2020, + Indent: 2, + SchemaRoot: SchemaRoot{ + ID: "https://example.com/schema", + Title: "Helm Values Schema", + Description: "Schema for Helm values", + AdditionalProperties: BoolFlag{set: true, value: true}, + }, + }, + expectedErr: false, + }, + { + name: "InvalidConfig", + configContent: ` +input: "invalid" "input" +input: +`, + expectedConf: Config{}, + expectedErr: true, + }, + { + name: "InvalidYAML", + configContent: `draft: "invalid"`, + expectedConf: Config{}, + expectedErr: true, + }, + { + name: "MissingFile", + configContent: "", + expectedConf: Config{}, + expectedErr: false, + }, + { + name: "EmptyConfig", + configContent: `input: []`, + expectedConf: Config{ + Input: multiStringFlag{}, + OutputPath: "", + Draft: 0, + Indent: 0, + SchemaRoot: SchemaRoot{ + ID: "", + Title: "", + Description: "", + AdditionalProperties: BoolFlag{set: false, value: false}, + }, + }, + expectedErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var configFilePath string + if tt.configContent != "" { + tmpFile, err := os.CreateTemp("", "config-*.yaml") + assert.NoError(t, err) + defer os.Remove(tmpFile.Name()) + _, err = tmpFile.Write([]byte(tt.configContent)) + assert.NoError(t, err) + configFilePath = tmpFile.Name() + } else { + configFilePath = "nonexistent.yaml" + } + + conf, err := LoadConfig(configFilePath) + + if tt.expectedErr { + assert.Error(t, err) + assert.Nil(t, conf) + } else { + assert.NoError(t, err) + assert.NotNil(t, conf) + assert.Equal(t, tt.expectedConf, *conf) + } + }) + } +} + +func TestMergeConfig(t *testing.T) { + tests := []struct { + name string + fileConfig *Config + flagConfig *Config + expectedConfig *Config + }{ + { + name: "FlagConfigOverridesFileConfig", + fileConfig: &Config{ + Input: multiStringFlag{"fileInput.yaml"}, + OutputPath: "fileOutput.json", + Draft: 2020, + Indent: 4, + SchemaRoot: SchemaRoot{ + ID: "fileID", + Title: "fileTitle", + Description: "fileDescription", + AdditionalProperties: BoolFlag{set: true, value: false}, + }, + }, + flagConfig: &Config{ + Input: multiStringFlag{"flagInput.yaml"}, + OutputPath: "flagOutput.json", + Draft: 2019, + Indent: 2, + SchemaRoot: SchemaRoot{ + ID: "flagID", + Title: "flagTitle", + Description: "flagDescription", + AdditionalProperties: BoolFlag{set: true, value: true}, + }, + OutputPathSet: true, + DraftSet: true, + IndentSet: true, + }, + expectedConfig: &Config{ + Input: multiStringFlag{"flagInput.yaml"}, + OutputPath: "flagOutput.json", + Draft: 2019, + Indent: 2, + SchemaRoot: SchemaRoot{ + ID: "flagID", + Title: "flagTitle", + Description: "flagDescription", + AdditionalProperties: BoolFlag{set: true, value: true}, + }, + }, + }, + { + name: "FileConfigDefaultsUsed", + fileConfig: &Config{ + Input: multiStringFlag{"fileInput.yaml"}, + OutputPath: "fileOutput.json", + Draft: 2020, + Indent: 4, + SchemaRoot: SchemaRoot{ + ID: "fileID", + Title: "fileTitle", + Description: "fileDescription", + AdditionalProperties: BoolFlag{set: true, value: false}, + }, + }, + flagConfig: &Config{}, + expectedConfig: &Config{ + Input: multiStringFlag{"fileInput.yaml"}, + OutputPath: "fileOutput.json", + Draft: 2020, + Indent: 4, + SchemaRoot: SchemaRoot{ + ID: "fileID", + Title: "fileTitle", + Description: "fileDescription", + AdditionalProperties: BoolFlag{set: true, value: false}, + }, + }, + }, + { + name: "FlagConfigPartialOverride", + fileConfig: &Config{ + Input: multiStringFlag{"fileInput.yaml"}, + OutputPath: "fileOutput.json", + Draft: 2020, + Indent: 4, + SchemaRoot: SchemaRoot{ + ID: "fileID", + Title: "fileTitle", + Description: "fileDescription", + AdditionalProperties: BoolFlag{set: true, value: false}, + }, + }, + flagConfig: &Config{ + OutputPath: "flagOutput.json", + OutputPathSet: true, + }, + expectedConfig: &Config{ + Input: multiStringFlag{"fileInput.yaml"}, + OutputPath: "flagOutput.json", + Draft: 2020, + Indent: 4, + SchemaRoot: SchemaRoot{ + ID: "fileID", + Title: "fileTitle", + Description: "fileDescription", + AdditionalProperties: BoolFlag{set: true, value: false}, + }, + }, + }, + { + name: "FlagConfigWithEmptyFileConfig", + fileConfig: &Config{ + Input: multiStringFlag{}, + }, + flagConfig: &Config{ + Input: multiStringFlag{"flagInput.yaml"}, + OutputPath: "flagOutput.json", + Draft: 2019, + Indent: 2, + SchemaRoot: SchemaRoot{ + ID: "flagID", + Title: "flagTitle", + Description: "flagDescription", + AdditionalProperties: BoolFlag{set: true, value: true}, + }, + OutputPathSet: true, + DraftSet: true, + IndentSet: true, + }, + expectedConfig: &Config{ + Input: multiStringFlag{"flagInput.yaml"}, + OutputPath: "flagOutput.json", + Draft: 2019, + Indent: 2, + SchemaRoot: SchemaRoot{ + ID: "flagID", + Title: "flagTitle", + Description: "flagDescription", + AdditionalProperties: BoolFlag{set: true, value: true}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mergedConfig := MergeConfig(tt.fileConfig, tt.flagConfig) + assert.Equal(t, tt.expectedConfig, mergedConfig) + }) + } +} diff --git a/pkg/generator.go b/pkg/generator.go index 9b61228..1a92c1a 100644 --- a/pkg/generator.go +++ b/pkg/generator.go @@ -13,30 +13,30 @@ import ( // Generate JSON schema func GenerateJsonSchema(config *Config) error { // Check if the input flag is set - if len(config.input) == 0 { + if len(config.Input) == 0 { return errors.New("input flag is required") } // Determine the schema URL based on the draft version - schemaURL, err := getSchemaURL(config.draft) + schemaURL, err := getSchemaURL(config.Draft) if err != nil { return err } // Determine the indentation string based on the number of spaces - if config.indent <= 0 { + if config.Indent <= 0 { return errors.New("indentation must be a positive number") } - if config.indent%2 != 0 { + if config.Indent%2 != 0 { return errors.New("indentation must be an even number") } - indentString := strings.Repeat(" ", config.indent) + indentString := strings.Repeat(" ", config.Indent) // Initialize a Schema to hold the merged YAML data mergedSchema := &Schema{} // Iterate over the input YAML files - for _, filePath := range config.input { + for _, filePath := range config.Input { content, err := os.ReadFile(filePath) if err != nil { return errors.New("error reading YAML file(s)") @@ -99,7 +99,7 @@ func GenerateJsonSchema(config *Config) error { jsonBytes = append(jsonBytes, '\n') // Write the JSON schema to the output file - outputPath := config.outputPath + outputPath := config.OutputPath if err := os.WriteFile(outputPath, jsonBytes, 0644); err != nil { return errors.New("error writing schema to file") } diff --git a/pkg/generator_test.go b/pkg/generator_test.go index c30cf6d..c1c3d98 100644 --- a/pkg/generator_test.go +++ b/pkg/generator_test.go @@ -12,13 +12,13 @@ import ( func TestGenerateJsonSchema(t *testing.T) { config := &Config{ - input: []string{ + Input: []string{ "../testdata/full.yaml", "../testdata/empty.yaml", }, - outputPath: "../testdata/output.json", - draft: 2020, - indent: 4, + OutputPath: "../testdata/output.json", + Draft: 2020, + Indent: 4, SchemaRoot: SchemaRoot{ ID: "", Title: "", @@ -29,7 +29,7 @@ func TestGenerateJsonSchema(t *testing.T) { err := GenerateJsonSchema(config) assert.NoError(t, err) - generatedBytes, err := os.ReadFile(config.outputPath) + generatedBytes, err := os.ReadFile(config.OutputPath) assert.NoError(t, err) templateBytes, err := os.ReadFile("../testdata/full.schema.json") @@ -43,7 +43,7 @@ func TestGenerateJsonSchema(t *testing.T) { assert.Equal(t, templateSchema, generatedSchema, "Generated JSON schema does not match the template") - os.Remove(config.outputPath) + os.Remove(config.OutputPath) } func TestGenerateJsonSchema_Errors(t *testing.T) { @@ -57,66 +57,66 @@ func TestGenerateJsonSchema_Errors(t *testing.T) { { name: "Missing input flag", config: &Config{ - input: nil, - draft: 2020, - indent: 0, + Input: nil, + Draft: 2020, + Indent: 0, }, expectedErr: errors.New("input flag is required"), }, { name: "Invalid draft version", config: &Config{ - input: []string{"../testdata/basic.yaml"}, - draft: 5, + Input: []string{"../testdata/basic.yaml"}, + Draft: 5, }, expectedErr: errors.New("invalid draft version"), }, { name: "Negative indentation number", config: &Config{ - input: []string{"../testdata/basic.yaml"}, - draft: 2020, - outputPath: "testdata/failure/output_readonly_schema.json", - indent: 0, + Input: []string{"../testdata/basic.yaml"}, + Draft: 2020, + OutputPath: "testdata/failure/output_readonly_schema.json", + Indent: 0, }, expectedErr: errors.New("indentation must be a positive number"), }, { name: "Odd indentation number", config: &Config{ - input: []string{"../testdata/basic.yaml"}, - draft: 2020, - outputPath: "testdata/failure/output_readonly_schema.json", - indent: 1, + Input: []string{"../testdata/basic.yaml"}, + Draft: 2020, + OutputPath: "testdata/failure/output_readonly_schema.json", + Indent: 1, }, expectedErr: errors.New("indentation must be an even number"), }, { name: "Missing file", config: &Config{ - input: []string{"missing.yaml"}, - draft: 2020, - indent: 4, + Input: []string{"missing.yaml"}, + Draft: 2020, + Indent: 4, }, expectedErr: errors.New("error reading YAML file(s)"), }, { name: "Fail Unmarshal", config: &Config{ - input: []string{"../testdata/fail"}, - outputPath: "testdata/failure/output_readonly_schema.json", - draft: 2020, - indent: 4, + Input: []string{"../testdata/fail"}, + OutputPath: "testdata/failure/output_readonly_schema.json", + Draft: 2020, + Indent: 4, }, expectedErr: errors.New("error unmarshaling YAML"), }, { name: "Read-only filesystem", config: &Config{ - input: []string{"../testdata/basic.yaml"}, - outputPath: "testdata/failure/output_readonly_schema.json", - draft: 2020, - indent: 4, + Input: []string{"../testdata/basic.yaml"}, + OutputPath: "testdata/failure/output_readonly_schema.json", + Draft: 2020, + Indent: 4, }, expectedErr: errors.New("error writing schema to file"), }, @@ -178,10 +178,10 @@ func TestGenerateJsonSchema_AdditionalProperties(t *testing.T) { } config := &Config{ - input: []string{"../testdata/empty.yaml"}, - outputPath: "../testdata/empty.schema.json", - draft: 2020, - indent: 4, + Input: []string{"../testdata/empty.yaml"}, + OutputPath: "../testdata/empty.schema.json", + Draft: 2020, + Indent: 4, SchemaRoot: SchemaRoot{ ID: "", Title: "", @@ -193,7 +193,7 @@ func TestGenerateJsonSchema_AdditionalProperties(t *testing.T) { err := GenerateJsonSchema(config) assert.NoError(t, err) - generatedBytes, err := os.ReadFile(config.outputPath) + generatedBytes, err := os.ReadFile(config.OutputPath) assert.NoError(t, err) var generatedSchema map[string]interface{} @@ -207,7 +207,7 @@ func TestGenerateJsonSchema_AdditionalProperties(t *testing.T) { assert.Equal(t, tt.expected, generatedSchema["additionalProperties"], "additionalProperties value mismatch") } - os.Remove(config.outputPath) + os.Remove(config.OutputPath) }) } } diff --git a/pkg/utils.go b/pkg/utils.go index 26f3543..6e6d301 100644 --- a/pkg/utils.go +++ b/pkg/utils.go @@ -8,22 +8,26 @@ import ( // SchemaRoot struct defines root object of schema type SchemaRoot struct { - ID string - Title string - Description string - AdditionalProperties BoolFlag + ID string `yaml:"id"` + Title string `yaml:"title"` + Description string `yaml:"description"` + AdditionalProperties BoolFlag `yaml:"additionalProperties"` } // Save values of parsed flags in Config type Config struct { - input multiStringFlag - outputPath string - draft int - indent int + Input multiStringFlag `yaml:"input"` + OutputPath string `yaml:"output"` + Draft int `yaml:"draft"` + Indent int `yaml:"indent"` - SchemaRoot SchemaRoot + SchemaRoot SchemaRoot `yaml:"schemaRoot"` - args []string + Args []string `yaml:"-"` + + OutputPathSet bool + DraftSet bool + IndentSet bool } // Define a custom flag type to accept multiple yaml files @@ -66,16 +70,24 @@ func (b *BoolFlag) Set(value string) error { return nil } -// Accessor method to check if the flag was explicitly set func (b *BoolFlag) IsSet() bool { return b.set } -// Accessor method to get the value of the flag func (b *BoolFlag) Value() bool { return b.value } +func (b *BoolFlag) UnmarshalYAML(unmarshal func(interface{}) error) error { + var boolValue bool + if err := unmarshal(&boolValue); err != nil { + return err + } + b.value = boolValue + b.set = true + return nil +} + func uniqueStringAppend(dest []string, src ...string) []string { existingItems := make(map[string]bool) for _, item := range dest { diff --git a/pkg/utils_test.go b/pkg/utils_test.go index 4c69c4b..5b6b9cc 100644 --- a/pkg/utils_test.go +++ b/pkg/utils_test.go @@ -3,7 +3,10 @@ package pkg import ( "errors" "reflect" + "strings" "testing" + + "gopkg.in/yaml.v3" ) func TestMultiStringFlagString(t *testing.T) { @@ -244,3 +247,57 @@ func TestBoolFlag_GetValue(t *testing.T) { }) } } + +func TestBoolFlag_UnmarshalYAML(t *testing.T) { + tests := []struct { + name string + yamlData string + expectedValue bool + expectedSet bool + expectedErr string + }{ + { + name: "Unmarshal true", + yamlData: "true", + expectedValue: true, + expectedSet: true, + expectedErr: "", + }, + { + name: "Unmarshal false", + yamlData: "false", + expectedValue: false, + expectedSet: true, + expectedErr: "", + }, + { + name: "Unmarshal invalid", + yamlData: "invalid", + expectedValue: false, + expectedSet: false, + expectedErr: "cannot unmarshal !!str", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var b BoolFlag + + err := yaml.Unmarshal([]byte(tt.yamlData), &b) + + if tt.expectedErr == "" && err != nil { + t.Errorf("BoolFlag.UnmarshalYAML() unexpected error: %v", err) + return + } + if tt.expectedErr != "" && !strings.Contains(err.Error(), tt.expectedErr) { + t.Errorf("BoolFlag.UnmarshalYAML() error = %v, expected to contain %q", err, tt.expectedErr) + } + if b.value != tt.expectedValue { + t.Errorf("BoolFlag.UnmarshalYAML() value = %v, expected %v", b.value, tt.expectedValue) + } + if b.set != tt.expectedSet { + t.Errorf("BoolFlag.UnmarshalYAML() set = %v, expected %v", b.set, tt.expectedSet) + } + }) + } +} diff --git a/schema.yaml b/schema.yaml new file mode 100644 index 0000000..9d715a9 --- /dev/null +++ b/schema.yaml @@ -0,0 +1,12 @@ +input: + - schema.yaml + +draft: 2020 # @schema enum: [4, 6, 7, 2019, 2020]; default: 2020 +indent: 4 # @schema default: 4 +output: values.schema.json # @schema default: values.schema.json + +schemaRoot: + id: https://example.com/schema + title: Helm Values Schema + description: Schema for Helm values + additionalProperties: true diff --git a/values.schema.json b/values.schema.json new file mode 100644 index 0000000..5c6decc --- /dev/null +++ b/values.schema.json @@ -0,0 +1,51 @@ +{ + "$id": "https://example.com/schema", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": true, + "description": "Schema for Helm values", + "properties": { + "draft": { + "default": 2020, + "enum": [ + "4", + "6", + "7", + "2019", + "2020" + ], + "type": "integer" + }, + "indent": { + "default": 4, + "type": "integer" + }, + "input": { + "items": { + "type": "string" + }, + "type": "array" + }, + "output": { + "type": "string" + }, + "schemaRoot": { + "properties": { + "additionalProperties": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "type": "object" + } + }, + "title": "Helm Values Schema", + "type": "object" +} From c00ef81e0be9d87a17ddf2fbf11c9a1d394b55b3 Mon Sep 17 00:00:00 2001 From: Aleksandar Stojanov Date: Mon, 24 Jun 2024 16:58:22 +0200 Subject: [PATCH 2/5] fix golangci-lint Signed-off-by: Aleksandar Stojanov --- main_test.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/main_test.go b/main_test.go index 9c45816..ff805c7 100644 --- a/main_test.go +++ b/main_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" "io" + "log" "os" "testing" @@ -47,17 +48,23 @@ func TestMain(t *testing.T) { args: []string{"schema", "-input", "testdata/basic.yaml"}, setup: func() { if _, err := os.Stat("schema.yaml"); err == nil { - os.Rename("schema.yaml", "schema.yaml.bak") + if err := os.Rename("schema.yaml", "schema.yaml.bak"); err != nil { + log.Fatalf("Error renaming file: %v", err) + } } file, _ := os.Create("schema.yaml") defer file.Close() - file.WriteString("draft: invalid\n") + if _, err := file.WriteString("draft: invalid\n"); err != nil { + log.Fatalf("Error writing to file: %v", err) + } }, cleanup: func() { if _, err := os.Stat("schema.yaml.bak"); err == nil { os.Remove("schema.yaml") - os.Rename("schema.yaml.bak", "schema.yaml") + if err := os.Rename("schema.yaml.bak", "schema.yaml"); err != nil { + log.Fatalf("Error renaming file: %v", err) + } } else { os.Remove("schema.yaml") } From b24cc50fb392796b556d05f9d757403ab20bc8c6 Mon Sep 17 00:00:00 2001 From: Aleksandar Stojanov Date: Mon, 24 Jun 2024 17:47:27 +0200 Subject: [PATCH 3/5] fix some tests Signed-off-by: Aleksandar Stojanov --- pkg/cmd.go | 4 +++- pkg/cmd_test.go | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/pkg/cmd.go b/pkg/cmd.go index 5dd6f0e..874488e 100644 --- a/pkg/cmd.go +++ b/pkg/cmd.go @@ -50,8 +50,10 @@ func ParseFlags(progname string, args []string) (*Config, string, error) { } // LoadConfig loads configuration from a YAML file +var readFileFunc = os.ReadFile + func LoadConfig(configPath string) (*Config, error) { - data, err := os.ReadFile(configPath) + data, err := readFileFunc(configPath) if err != nil { if os.IsNotExist(err) { // Return an empty config if the file does not exist diff --git a/pkg/cmd_test.go b/pkg/cmd_test.go index cfa2b8b..6b7e8c2 100644 --- a/pkg/cmd_test.go +++ b/pkg/cmd_test.go @@ -245,6 +245,20 @@ input: } } +func TestLoadConfig_PermissionDenied(t *testing.T) { + restrictedDir := "/restricted" + configFilePath := restrictedDir + "/restricted.yaml" + + readFileFunc = func(filename string) ([]byte, error) { + return nil, os.ErrPermission + } + defer func() { readFileFunc = os.ReadFile }() + + conf, err := LoadConfig(configFilePath) + assert.ErrorIs(t, err, os.ErrPermission, "Expected permission denied error") + assert.Nil(t, conf, "Expected config to be nil for permission denied error") +} + func TestMergeConfig(t *testing.T) { tests := []struct { name string From 6535634c4c15d707da608fc2168d0cc0c10d3358 Mon Sep 17 00:00:00 2001 From: Aleksandar Stojanov Date: Mon, 24 Jun 2024 18:05:42 +0200 Subject: [PATCH 4/5] add documentation and bump version to 1.5.0 Signed-off-by: Aleksandar Stojanov --- .pre-commit-config.yaml | 2 +- README.md | 38 +++++++++++++++++++++++++++++++++----- plugin.yaml | 2 +- schema.yaml | 1 + 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bcf14c6..19fce8f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/losisin/helm-values-schema-json - rev: v1.4.1 + rev: v1.5.0 hooks: - id: helm-schema args: diff --git a/README.md b/README.md index 9319bdc..11646b7 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ First [install pre-commit](https://pre-commit.com/#install) and then create or u ```yaml repos: - repo: https://github.com/losisin/helm-values-schema-json - rev: v1.4.1 + rev: v1.5.0 hooks: - id: helm-schema args: ["-input", "values.yaml"] @@ -108,7 +108,35 @@ Usage: helm schema [options...] JSON schema title ``` -### Basic +### Configuration file + +This plugin will look for it's configuration file called `schema.yaml` in the current working directory. All options available from CLI can be set in this file. Example: + +```yaml +# Required +input: + - schema.yaml + +draft: 2020 +indent: 4 +output: values.schema.json + +schemaRoot: + id: https://example.com/schema + title: Helm Values Schema + description: Schema for Helm values + additionalProperties: true +``` + +Then, just run the plugin without any arguments: + +```bash +$ helm schema +``` + +### CLI + +#### Basic In most cases you will want to run the plugin with default options: @@ -118,9 +146,9 @@ $ helm schema -input values.yaml This will read `values.yaml`, set draft version to `2020-12` and save outpout to `values.schema.json`. -### Extended +#### Extended -#### Multiple values files +##### Multiple values files Merge multiple values files, set json-schema draft version explicitly and save output to `my.schema.json`: @@ -217,7 +245,7 @@ Output will be something like this: } ``` -#### Root JSON object properties +##### Root JSON object properties Adding ID, title and description to the schema: diff --git a/plugin.yaml b/plugin.yaml index e9f8a0e..cbcdae4 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,5 +1,5 @@ name: "schema" -version: "1.4.1" +version: "1.5.0" usage: "generate values.schema.json from values yaml" description: "Helm plugin for generating values.schema.json from multiple values files." ignoreFlags: false diff --git a/schema.yaml b/schema.yaml index 9d715a9..d17036d 100644 --- a/schema.yaml +++ b/schema.yaml @@ -1,3 +1,4 @@ +# Required input: - schema.yaml From a2adf796fe4df0712bd4c94651d6bbaf98258c75 Mon Sep 17 00:00:00 2001 From: Aleksandar Stojanov Date: Mon, 24 Jun 2024 18:10:31 +0200 Subject: [PATCH 5/5] fix goreleaser Signed-off-by: Aleksandar Stojanov --- .goreleaser.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 6d21f99..6861015 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -59,4 +59,4 @@ changelog: order: 999 filters: exclude: - - "^Merge pull request #" # exclude merge commits + - "^Merge" # exclude merge commits