diff --git a/.drone.yml b/.drone.yml index dc8c6cf..87bcda3 100644 --- a/.drone.yml +++ b/.drone.yml @@ -14,9 +14,11 @@ steps: - name: staticcheck pull: always image: golang:1.19 + environment: + GO111MODULE: "on" # Explicitly enable Go modules commands: - - go get honnef.co/go/tools/cmd/staticcheck - - go run honnef.co/go/tools/cmd/staticcheck ./... + - go install honnef.co/go/tools/cmd/staticcheck@v0.4.3 + - staticcheck ./... volumes: - name: gopath path: /go diff --git a/go.mod b/go.mod index 47c2603..550108f 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,19 @@ go 1.19 require ( github.com/sirupsen/logrus v1.9.0 + github.com/stretchr/testify v1.7.0 github.com/urfave/cli/v2 v2.23.6 ) +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + require ( github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/harness/godotenv/v3 v3.0.1 github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect golang.org/x/sys v0.0.0-20220731174439-a90be440212d // indirect diff --git a/go.sum b/go.sum index 3c51b04..e4865a2 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46t github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/harness/godotenv/v3 v3.0.1 h1:7QPEOkpx6SLLrYRRzPBp50d6c0XIxq721iqoFxbz1Bs= +github.com/harness/godotenv/v3 v3.0.1/go.mod h1:UIXXJtTM7NkSYMYknHYOO2d8BfDlAWMYZRuRsXcDDR0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= @@ -12,10 +14,6 @@ github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVs github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/urfave/cli/v2 v2.11.1 h1:UKK6SP7fV3eKOefbS87iT9YHefv7iB/53ih6e+GNAsE= -github.com/urfave/cli/v2 v2.11.1/go.mod h1:f8iq5LtQ/bLxafbdBSLPPNsgaW0l/2fYYEHhAyPlwvo= -github.com/urfave/cli/v2 v2.23.5 h1:xbrU7tAYviSpqeR3X4nEFWUdB/uDZ6DE+HxmRU7Xtyw= -github.com/urfave/cli/v2 v2.23.5/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= github.com/urfave/cli/v2 v2.23.6 h1:iWmtKD+prGo1nKUtLO0Wg4z9esfBM4rAV4QRLQiEmJ4= github.com/urfave/cli/v2 v2.23.6/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= @@ -23,6 +21,8 @@ github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsr golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220731174439-a90be440212d h1:Sv5ogFZatcgIMMtBSTTAgMYsicp25MXBubjXNDKwm80= golang.org/x/sys v0.0.0-20220731174439-a90be440212d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/harness/variables.go b/harness/variables.go new file mode 100644 index 0000000..dc5e640 --- /dev/null +++ b/harness/variables.go @@ -0,0 +1,213 @@ +package harness + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + v3 "github.com/harness/godotenv/v3" +) + +const ( + // ErrorMessageKey is the key used to retrieve or store the error message content. + ErrorMessageKey = "ERROR_MESSAGE" + + // ErrorCodeKey is the key used to identify the specific error code associated with an error. + ErrorCodeKey = "ERROR_CODE" + + // ErrorCategoryKey is the key used to classify the category of the error, which can help in grouping similar types of errors. + ErrorCategoryKey = "ERROR_CATEGORY" + + // MetadataFile is the key for the file that stores metadata associated with an error, such as details about the error's source or context. + MetadataFile = "ERROR_METADATA_FILE" + + // DroneOutputFile is the key for the file where outputs can be exported and utilized in the subsequent steps in Harness CI pipeline. + DroneOutputFile = "DRONE_OUTPUT" + + // HarnessOutputSecretFile is the key for the file where secrets can be exported and utilized in the subsequent steps in Harness CI pipeline. + HarnessOutputSecretFile = "HARNESS_OUTPUT_SECRET_FILE" +) + +// SetSecret sets a new secret by adding it to the HARNESS_OUTPUT_SECRET_FILE file +func SetSecret(name, value string) error { + return UpdateOrRemoveKeyValue(HarnessOutputSecretFile, name, value, false) +} + +// UpdateSecret overwrites the value of an existing secret. +func UpdateSecret(name, value string) error { + return UpdateOrRemoveKeyValue(HarnessOutputSecretFile, name, value, false) +} + +// DeleteSecret removes a secret from the file entirely. +func DeleteSecret(name string) error { + return UpdateOrRemoveKeyValue(HarnessOutputSecretFile, name, "", true) +} + +// SetOutput sets a new secret by adding it to the DRONE_OUTPUT file +func SetOutput(name, value string) error { + return UpdateOrRemoveKeyValue(DroneOutputFile, name, value, false) +} + +// UpdateOutput overwrites the value of an existing output. +func UpdateOutput(name, value string) error { + return UpdateOrRemoveKeyValue(DroneOutputFile, name, value, false) +} + +// DeleteOutput removes an output from the file entirely. +func DeleteOutput(name string) error { + return UpdateOrRemoveKeyValue(DroneOutputFile, name, "", true) +} + +// SetErrorMetadata sets the error message, error code, and error category, writing them to the CI_ERROR_METADATA file +func SetErrorMetadata(message, code, category string) error { + // Write the error message + if err := UpdateOrRemoveKeyValue(MetadataFile, ErrorMessageKey, message, false); err != nil { + return err + } + + // Write the error code + if err := UpdateOrRemoveKeyValue(MetadataFile, ErrorCodeKey, code, false); err != nil { + return err + } + + // Write the error category + if err := UpdateOrRemoveKeyValue(MetadataFile, ErrorCategoryKey, category, false); err != nil { + return err + } + + return nil +} + +// UpdateOrRemoveKeyValue updates or deletes a key-value pair in the specified file. +func UpdateOrRemoveKeyValue(envVar, key, newValue string, deleteKey bool) error { + filePath := os.Getenv(envVar) + if filePath == "" { + return fmt.Errorf("environment variable %s is not set", envVar) + } + + // Ensure the file exists before reading + if _, err := os.Stat(filePath); os.IsNotExist(err) { + _, err := os.OpenFile(filePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + } + + // Trim trailing newline characters from newValue + newValue = strings.TrimRight(newValue, "\n") + + ext := strings.ToLower(filepath.Ext(filePath)) + + if ext == ".env" { + // Use godotenv for .env files + data, err := v3.Read(filePath) + if err != nil { + return fmt.Errorf("failed to parse .env file: %w", err) + } + + if deleteKey { + delete(data, key) + } else { + data[key] = newValue + } + + err = v3.Write(data, filePath) + if err != nil { + return fmt.Errorf("failed to write .env file: %w", err) + } + } else { + // For .out files, process manually + // For .out files, check for multiline values + if strings.Contains(newValue, "\n") { + return fmt.Errorf("multiline values are not allowed for key %s in .out file", key) + } + + lines, err := ReadLines(filePath) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + + var updatedLines []string + found := false + for _, line := range lines { + k, v := ParseKeyValue(line, ext) + if k == key { + found = true + if deleteKey { + continue + } + updatedLines = append(updatedLines, FormatKeyValue(k, newValue, ext)) + } else { + updatedLines = append(updatedLines, FormatKeyValue(k, v, ext)) + } + } + + if !found && !deleteKey { + updatedLines = append(updatedLines, FormatKeyValue(key, newValue, ext)) + } + + err = WriteLines(filePath, updatedLines) + if err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + } + + return nil +} + +// ReadLines reads lines from a file and returns them as a slice of strings. +func ReadLines(filename string) ([]string, error) { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + var lines []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + return lines, scanner.Err() +} + +// WriteLines writes a slice of strings to a file, each string being written to a new line. +func WriteLines(filename string, lines []string) error { + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer file.Close() + + for _, line := range lines { + _, err := file.WriteString(line + "\n") + if err != nil { + return fmt.Errorf("failed to write to file: %w", err) + } + } + return nil +} + +// ParseKeyValue parses a key-value pair from a string and returns the key and value. +func ParseKeyValue(line, ext string) (string, string) { + if ext == ".out" { + parts := strings.Fields(line) + if len(parts) > 1 { + return strings.TrimSpace(parts[0]), strings.TrimSpace(strings.Join(parts[1:], " ")) + } + return strings.TrimSpace(parts[0]), "" + } + // .env is handled by godotenv, so this is not used for .env files + return "", "" +} + +// FormatKeyValue handles formatting for .env and .out files. +func FormatKeyValue(key, value, ext string) string { + if ext == ".out" { + return fmt.Sprintf("%s %s", key, value) + } + // For .env files, use godotenv directly; this function won't apply + return "" +} diff --git a/harness/variables_test.go b/harness/variables_test.go new file mode 100644 index 0000000..a6be218 --- /dev/null +++ b/harness/variables_test.go @@ -0,0 +1,218 @@ +package harness + +import ( + "os" + "testing" + + v3 "github.com/harness/godotenv/v3" + "github.com/stretchr/testify/assert" +) + +// Helper function to create the .env file with the given content +func createEnvFile(t *testing.T, filePath string, content map[string]string) error { + file, err := os.Create(filePath) + if err != nil { + t.Fatalf("Failed to create file: %v", err) + return err + } + defer file.Close() + + for key, value := range content { + _, err := file.WriteString(key + "=" + value + "\n") + if err != nil { + t.Fatalf("Failed to write to file: %v", err) + return err + } + } + + return nil +} + +// Helper function to create an output file (.out) with the given content +func createOutFile(t *testing.T, filePath string, content []string) error { + file, err := os.Create(filePath) + if err != nil { + t.Fatalf("Failed to create file: %v", err) + return err + } + defer file.Close() + + // Write the content to the .out file manually + for _, line := range content { + _, err := file.WriteString(line + "\n") + if err != nil { + t.Fatalf("Failed to write to file: %v", err) + return err + } + } + return nil +} + +// Test Set, Update, and Delete functions for .env files +func TestSetUpdateDeleteEnvFile(t *testing.T) { + // Set the environment variable to point to the test.env file + envFilePath := "test.env" + os.Setenv("HARNESS_OUTPUT_SECRET_FILE", envFilePath) + + // Setup: Create the .env file at the path specified by HARNESS_OUTPUT_SECRET_FILE + envContent := map[string]string{ + "KEY1": "value1", + "KEY2": "value2", + } + err := createEnvFile(t, envFilePath, envContent) + assert.NoError(t, err) + + // Defer file removal after the test completes + defer os.Remove(envFilePath) + + // Set a new key-value pair + err = SetSecret("KEY3", "value3") + assert.NoError(t, err) + + // Update an existing key-value pair + err = UpdateSecret("KEY1", "new_value1") + assert.NoError(t, err) + + // Delete an existing key + err = DeleteSecret("KEY2") + assert.NoError(t, err) + + // Read the file content and verify the changes + data, err := v3.Read(envFilePath) + assert.NoError(t, err) + + // Assertions + assert.Equal(t, "new_value1", data["KEY1"]) + assert.Equal(t, "value3", data["KEY3"]) + assert.NotContains(t, data, "KEY2") + + // Clean up + defer os.Unsetenv("HARNESS_OUTPUT_SECRET_FILE") +} + +// Test Set, Update, and Delete functions for .out files (single-line values) +func TestSetUpdateDeleteOutFile(t *testing.T) { + // Set the environment variable to point to the test.out file + outFilePath := "test.out" + os.Setenv("DRONE_OUTPUT", outFilePath) + + // Setup: Create the .out file at the path specified by DRONE_OUTPUT + outContent := []string{ + "KEY1 value1", + "KEY2 value2", + } + err := createOutFile(t, outFilePath, outContent) + assert.NoError(t, err) + + // Defer file removal after the test completes + defer os.Remove(outFilePath) + + // Set a new key-value pair + err = SetOutput("KEY3", "value3") + assert.NoError(t, err) + + // Update an existing key-value pair + err = UpdateOutput("KEY1", "new_value1") + assert.NoError(t, err) + + // Delete an existing key + err = DeleteOutput("KEY2") + assert.NoError(t, err) + + // Verify changes + lines, err := ReadLines(outFilePath) + assert.NoError(t, err) + + // Assertions + assert.Contains(t, lines, "KEY1 new_value1") + assert.Contains(t, lines, "KEY3 value3") + assert.NotContains(t, lines, "KEY2") + + // Clean up + defer os.Unsetenv("DRONE_OUTPUT") +} + +// Test Set, Update, and Delete functions for multiline values in .env file +func TestSetUpdateDeleteMultilineEnvFile(t *testing.T) { + // Set the environment variable to point to the test.env file + envFilePath := "test.env" + os.Setenv("HARNESS_OUTPUT_SECRET_FILE", envFilePath) + + // Setup: Create a temporary .env file with multiline value for a key + envContent := map[string]string{} + err := createEnvFile(t, envFilePath, envContent) + assert.NoError(t, err) + + // Defer file removal after the test completes + defer os.Remove(envFilePath) + + // Set a new multiline value + err = SetSecret("KEY1", "line1\nline2\nline3") + assert.NoError(t, err) + + // Verify the multiline update + data, err := v3.Read(envFilePath) + assert.NoError(t, err) + assert.Equal(t, "line1\nline2\nline3", data["KEY1"]) + + err = UpdateSecret("KEY1", "line4\nline5\nline6") + assert.NoError(t, err) + + // Verify the multiline update + data, err = v3.Read(envFilePath) + assert.NoError(t, err) + assert.Equal(t, "line4\nline5\nline6", data["KEY1"]) + + // Delete the key + err = DeleteSecret("KEY1") + assert.NoError(t, err) + + // Verify deletion + data, err = v3.Read(envFilePath) + assert.NoError(t, err) + assert.NotContains(t, data, "KEY1") + + // Clean up + defer os.Unsetenv("HARNESS_OUTPUT_SECRET_FILE") +} + +// Test invalid cases for setting a secret in .out (should not allow multiline) +func TestSetInvalidMultilineOut(t *testing.T) { + // Set the environment variable to point to the test.out file + outFilePath := "test.out" + os.Setenv("DRONE_OUTPUT", outFilePath) + + // Setup: Create a temporary .out file + outContent := []string{ + "KEY1 value1", + "KEY2 value2", + } + err := createOutFile(t, outFilePath, outContent) + assert.NoError(t, err) + + // Defer file removal after the test completes + defer os.Remove(outFilePath) + + // Attempt to set a multiline value (should fail) + err = SetOutput("KEY3", "line1\nline2") + assert.Error(t, err) + + // Verify the content did not change + lines, err := ReadLines(outFilePath) + assert.NoError(t, err) + + // Assertions + assert.Contains(t, lines, "KEY1 value1") + assert.Contains(t, lines, "KEY2 value2") + + // Clean up + defer os.Unsetenv("DRONE_OUTPUT") +} + +// Test that the functions return the appropriate error when the file does not exist +func TestFileNotExistError(t *testing.T) { + // Attempt to set a value when the .env file is missing + err := SetSecret("KEY1", "value1") + assert.Error(t, err) + assert.Contains(t, err.Error(), "environment variable HARNESS_OUTPUT_SECRET_FILE is not set") +}