Skip to content

Commit

Permalink
env-loader: Added gha-env writer
Browse files Browse the repository at this point in the history
Signed-off-by: Fred Heinecke <[email protected]>
  • Loading branch information
fheinecke committed Dec 19, 2024
1 parent ff09953 commit 5055ee5
Show file tree
Hide file tree
Showing 3 changed files with 268 additions and 0 deletions.
103 changes: 103 additions & 0 deletions tools/env-loader/pkg/writers/gha-env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* Copyright 2024 Gravitational, Inc
*
* 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.
*/

package writers

import (
"fmt"
"strings"

"github.com/google/uuid"
"github.com/gravitational/shared-workflows/tools/env-loader/pkg/values"
"github.com/gravitational/trace"
)

const delimiterPrefix = "EOF"

// Outputs values in a format that can be parsed by GHA's `GITHUB_ENV` file.
// This is _almost_ the same as dotenv files, but also handles multiline
// environment values. For details, see
// https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#setting-an-environment-variable
type GHAEnvWriter struct{}

// Create a new GHA env writer
func NewGHAEnvWriter() *GHAEnvWriter {
return &GHAEnvWriter{}
}

// Generates a delimiter that is guaranteed to not contain the provided string.
// This is required for writing multiline values to `GITHUB_ENV`, per
// https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#multiline-strings
func generateMultilineDelimiter(value string) string {
valueLines := strings.Split(value, "\n")

// Start with no suffix to make this a little more readable
delimiter := delimiterPrefix
for {
// Check if there are any lines that match the delimiter exactly
foundMatch := false
for _, line := range valueLines {
if line == delimiter {
foundMatch = true
break
}
}

if foundMatch {
// Add a reasonably unique value to the delimiter
delimiter = fmt.Sprintf("%s_%s", delimiterPrefix, uuid.NewString())
continue
}

// If no line matches the delimiter exactly, then the delimiter can be
// used.
return delimiter
}
}

func (ew *GHAEnvWriter) FormatEnvironmentValues(values map[string]values.Value) (string, error) {
renderedValues := make([]string, 0, len(values))
for key, value := range values {
if key == "" {
return "", trace.Errorf("found empty key for log value %q", value.String())
}

// Don't format strings without new lines as multiline. This would be valid, but a little
// less readable.
var renderedValue string
if strings.Contains(value.UnderlyingValue, "\n") {
// Match GHA docs:
// https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#multiline-strings
// Formats values like:
// {name}<<{delimiter}
// {value}
// {delimiter}
//
delimiter := generateMultilineDelimiter(value.UnderlyingValue)
renderedValue = fmt.Sprintf("%s<<%s\n%s\n%s\n", key, delimiter, value.UnderlyingValue, delimiter)
} else {
renderedValue = fmt.Sprintf("%s=%s\n", key, value.UnderlyingValue)
}

renderedValues = append(renderedValues, renderedValue)
}

return strings.Join(renderedValues, ""), nil
}

func (*GHAEnvWriter) Name() string {
return "gha-env"
}
163 changes: 163 additions & 0 deletions tools/env-loader/pkg/writers/gha-env_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*
* Copyright 2024 Gravitational, Inc
*
* 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.
*/

package writers

import (
"fmt"
"testing"

"github.com/google/uuid"
"github.com/gravitational/shared-workflows/tools/env-loader/pkg/values"
"github.com/stretchr/testify/require"
)

func TestGenerateMultilineDelimiter(t *testing.T) {
testCases := []string{
"value",
"multiline\nvalue",
"multiline\nvalue\nwith\nnewline\n",
"\n",
"\n\n\n",
fmt.Sprintf("\n%s", delimiterPrefix),
fmt.Sprintf("\n%s\n", delimiterPrefix),
"",
delimiterPrefix,
fmt.Sprintf("%s_%s", delimiterPrefix, uuid.NewString()),
}

for _, testCase := range testCases {
actualDelimiter := generateMultilineDelimiter(testCase)
require.NotEqual(t, testCase, actualDelimiter)
}
}

func TestGHAEnvFormat(t *testing.T) {
testCases := []struct {
desc string
values map[string]values.Value
expectedOutputs []string
checkError require.ErrorAssertionFunc
}{
{
desc: "single value",
values: map[string]values.Value{
"key": {UnderlyingValue: "value"},
},
expectedOutputs: []string{
"key=value\n",
},
},
{
desc: "multiple values",
values: map[string]values.Value{
"key1": {UnderlyingValue: "value1"},
"key2": {UnderlyingValue: "value2"},
},
expectedOutputs: []string{
"key1=value1\nkey2=value2\n",
"key2=value2\nkey1=value1\n",
},
},
{
desc: "key with empty value",
values: map[string]values.Value{
"key": {UnderlyingValue: ""},
},
expectedOutputs: []string{
"key=\n",
},
},
{
desc: "no values",
},
{
desc: "empty key",
values: map[string]values.Value{
"": {UnderlyingValue: "value"},
},
checkError: require.Error,
},
{
desc: "multiline value",
values: map[string]values.Value{
"key": {UnderlyingValue: "multiline\nvalue"},
},
expectedOutputs: []string{
"key<<EOF\nmultiline\nvalue\nEOF\n",
},
},
{
desc: "multiline value with new line at end",
values: map[string]values.Value{
"key": {UnderlyingValue: "multiline\nvalue\n"},
},
expectedOutputs: []string{
"key<<EOF\nmultiline\nvalue\n\nEOF\n",
},
},
{
desc: "multiple multiline values",
values: map[string]values.Value{
"key1": {UnderlyingValue: "multiline\nvalue1\n"},
"key2": {UnderlyingValue: "multiline\nvalue2"},
},
expectedOutputs: []string{
"key1<<EOF\nmultiline\nvalue1\n\nEOF\nkey2<<EOF\nmultiline\nvalue2\nEOF\n",
"key2<<EOF\nmultiline\nvalue2\nEOF\nkey1<<EOF\nmultiline\nvalue1\n\nEOF\n",
},
},
{
desc: "multiple mixed multiline values",
values: map[string]values.Value{
"key1": {UnderlyingValue: "multiline\nvalue1\n"},
"key2": {UnderlyingValue: "value2"},
"key3": {UnderlyingValue: "multiline\nvalue3"},
},
expectedOutputs: []string{
"key1<<EOF\nmultiline\nvalue1\n\nEOF\nkey2=value2\nkey3<<EOF\nmultiline\nvalue3\nEOF\n",
"key1<<EOF\nmultiline\nvalue1\n\nEOF\nkey3<<EOF\nmultiline\nvalue3\nEOF\nkey2=value2\n",
"key2=value2\nkey1<<EOF\nmultiline\nvalue1\n\nEOF\nkey3<<EOF\nmultiline\nvalue3\nEOF\n",
"key2=value2\nkey3<<EOF\nmultiline\nvalue3\nEOF\nkey1<<EOF\nmultiline\nvalue1\n\nEOF\n",
"key3<<EOF\nmultiline\nvalue3\nEOF\nkey1<<EOF\nmultiline\nvalue1\n\nEOF\nkey2=value2\n",
"key3<<EOF\nmultiline\nvalue3\nEOF\nkey2=value2\nkey1<<EOF\nmultiline\nvalue1\n\nEOF\n",
},
},
}

writer := NewGHAEnvWriter()
for _, testCase := range testCases {
formattedStr, err := writer.FormatEnvironmentValues(testCase.values)

if testCase.checkError == nil {
testCase.checkError = require.NoError
}

testCase.checkError(t, err, "writer failed with test case %q", testCase.desc)

// Using separate functions makes debugging easier
switch len(testCase.expectedOutputs) {
case 0:
require.Empty(t, formattedStr, "output value was not empty for test case %q", testCase.desc)
case 1:
require.Equal(t, testCase.expectedOutputs[0], formattedStr,
"output value did not match expected value for test case %q", testCase.desc)
default:
require.Contains(t, testCase.expectedOutputs, formattedStr,
"output value did not match any expected for test case %q", testCase.desc)
}
}
}
2 changes: 2 additions & 0 deletions tools/env-loader/pkg/writers/writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,14 @@ type Writer interface {

var (
dotenvWriter = NewDotenvWriter()
ghaEnvWriter = NewGHAEnvWriter()
ghaMaskWriter = NewGHAMaskWriter()
DefaultWriter = dotenvWriter

// A map of all writers available.
FromName = map[string]Writer{
dotenvWriter.Name(): dotenvWriter,
ghaEnvWriter.Name(): ghaEnvWriter,
ghaMaskWriter.Name(): ghaMaskWriter,
}
)

0 comments on commit 5055ee5

Please sign in to comment.