Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
58 changes: 39 additions & 19 deletions packages/cmd/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ var exportCmd = &cobra.Command{
util.HandleError(err, "Unable to parse flag")
}

secretsPath, err := cmd.Flags().GetString("path")
secretsPaths, err := cmd.Flags().GetStringArray("path")
if err != nil {
util.HandleError(err, "Unable to parse flag")
}
Expand All @@ -93,20 +93,16 @@ var exportCmd = &cobra.Command{
util.HandleError(err, "Unable to parse flag")
}

request := models.GetAllSecretsParameters{
Environment: environmentName,
TagSlugs: tagSlugs,
WorkspaceId: projectId,
SecretsPath: secretsPath,
IncludeImport: includeImports,
ExpandSecretReferences: shouldExpandSecrets,
IncludePersonalOverrides: secretOverriding,
}

if token != nil && token.Type == util.SERVICE_TOKEN_IDENTIFIER {
request.InfisicalToken = token.Token
} else if token != nil && token.Type == util.UNIVERSAL_AUTH_TOKEN_IDENTIFIER {
request.UniversalAuthAccessToken = token.Token
// TagSlugs is intentionally left unset: filtering server side would drop untagged
// secrets from each path before the paths are merged, so a secret that is tagged on
// an earlier path could survive even though a later path overrides it with an
// untagged one. Tags are applied client side once the merge has happened.
request := models.GetMultiPathSecretsParameters{
Environment: environmentName,
WorkspaceId: projectId,
SecretsPaths: secretsPaths,
IncludeImport: includeImports,
ExpandSecretReferences: shouldExpandSecrets,
}

if templatePath != "" {
Expand All @@ -133,14 +129,13 @@ var exportCmd = &cobra.Command{
return
}

secrets, err := util.GetAllEnvironmentVariables(request, "")
secrets, err := fetchSecrets(request, "", secretOverriding, token)
if err != nil {
util.HandleError(err, "Unable to fetch secrets")
}

var output string
secrets = util.FilterSecretsByTag(secrets, tagSlugs)
secrets = util.SortSecretsByKeys(secrets)
secrets = mergeAndFilterSecrets(secrets, tagSlugs)

output, err = formatEnvs(secrets, format)
if err != nil {
Expand Down Expand Up @@ -169,6 +164,31 @@ var exportCmd = &cobra.Command{
},
}

// mergeAndFilterSecrets turns the secrets fetched from every path into the final set to
// export. The merge has to run before the tag filter so that a key present on several
// paths resolves to the last path first, and only that winning secret is then checked
// against the requested tags.
func mergeAndFilterSecrets(secrets []models.SingleEnvironmentVariable, tagSlugs string) []models.SingleEnvironmentVariable {
secrets = mergeSecretsByKey(secrets)
secrets = util.FilterSecretsByTag(secrets, tagSlugs)

return util.SortSecretsByKeys(secrets)
}

// mergeSecretsByKey collapses secrets that share the same key, which can happen when
// secrets are fetched from multiple paths. The last path a key was found in wins.
// The returned order is not stable, so callers are expected to sort the result.
func mergeSecretsByKey(secrets []models.SingleEnvironmentVariable) []models.SingleEnvironmentVariable {
secretsByKey := getSecretsByKeys(secrets)

mergedSecrets := make([]models.SingleEnvironmentVariable, 0, len(secretsByKey))
for _, secret := range secretsByKey {
mergedSecrets = append(mergedSecrets, secret)
}

return mergedSecrets
}

// resolveOutputPath determines the final output path based on the provided path and format
func resolveOutputPath(outputFile, format string) (string, error) {
// Expand ~ to home directory if present
Expand Down Expand Up @@ -272,7 +292,7 @@ func init() {
exportCmd.Flags().String("token", "", "Fetch secrets using service token or machine identity access token")
exportCmd.Flags().StringP("tags", "t", "", "filter secrets by tag slugs")
exportCmd.Flags().String("projectId", "", "manually set the projectId to export secrets from")
exportCmd.Flags().String("path", "/", "get secrets within a folder path")
exportCmd.Flags().StringArray("path", []string{"/"}, "get secrets within a folder path (can be specified multiple times)")
exportCmd.Flags().String("template", "", "The path to the template file used to render secrets")
exportCmd.Flags().StringP("output-file", "o", "", "The path to write the output file to. Can be a full file path, directory, or filename. If not specified, output will be printed to stdout")
}
Expand Down
122 changes: 122 additions & 0 deletions packages/cmd/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"testing"

"github.com/Infisical/infisical-merge/packages/models"
"github.com/Infisical/infisical-merge/packages/util"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v2"
)
Expand Down Expand Up @@ -133,6 +135,126 @@ func TestFormatAsDotEnvEval(t *testing.T) {
}
}

func TestMergeSecretsByKey(t *testing.T) {
tests := []struct {
name string
input []models.SingleEnvironmentVariable
expected []models.SingleEnvironmentVariable
}{
{
name: "Empty input",
input: []models.SingleEnvironmentVariable{},
expected: []models.SingleEnvironmentVariable{},
},
{
name: "Distinct keys are all kept",
input: []models.SingleEnvironmentVariable{
{Key: "KEY1", Value: "VALUE1"},
{Key: "KEY2", Value: "VALUE2"},
},
expected: []models.SingleEnvironmentVariable{
{Key: "KEY1", Value: "VALUE1"},
{Key: "KEY2", Value: "VALUE2"},
},
},
{
name: "Duplicate keys across paths keep the last value",
input: []models.SingleEnvironmentVariable{
{Key: "KEY1", Value: "FROM_FIRST_PATH"},
{Key: "KEY2", Value: "VALUE2"},
{Key: "KEY1", Value: "FROM_SECOND_PATH"},
},
expected: []models.SingleEnvironmentVariable{
{Key: "KEY1", Value: "FROM_SECOND_PATH"},
{Key: "KEY2", Value: "VALUE2"},
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := util.SortSecretsByKeys(mergeSecretsByKey(tt.input))
assert.Equal(t, tt.expected, result)
})
}
}

func TestMergeAndFilterSecrets(t *testing.T) {
teamTag := []models.Tag{{Slug: "team"}}

tests := []struct {
name string
input []models.SingleEnvironmentVariable
tagSlugs string
expected []models.SingleEnvironmentVariable
}{
{
name: "An untagged secret on a later path drops the tagged one from an earlier path",
input: []models.SingleEnvironmentVariable{
{Key: "KEY1", Value: "old", Tags: teamTag},
{Key: "KEY2", Value: "kept", Tags: teamTag},
{Key: "KEY1", Value: "new"},
},
tagSlugs: "team",
expected: []models.SingleEnvironmentVariable{
{Key: "KEY2", Value: "kept", Tags: teamTag},
},
},
{
name: "A tagged secret on a later path overrides an untagged one from an earlier path",
input: []models.SingleEnvironmentVariable{
{Key: "KEY1", Value: "old"},
{Key: "KEY1", Value: "new", Tags: teamTag},
},
tagSlugs: "team",
expected: []models.SingleEnvironmentVariable{
{Key: "KEY1", Value: "new", Tags: teamTag},
},
},
{
name: "Without tags the merged result is returned as is",
input: []models.SingleEnvironmentVariable{
{Key: "KEY2", Value: "VALUE2"},
{Key: "KEY1", Value: "old"},
{Key: "KEY1", Value: "new"},
},
tagSlugs: "",
expected: []models.SingleEnvironmentVariable{
{Key: "KEY1", Value: "new"},
{Key: "KEY2", Value: "VALUE2"},
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, mergeAndFilterSecrets(tt.input, tt.tagSlugs))
})
}
}

func newExportTestCmd() *cobra.Command {
c := &cobra.Command{Use: "export"}
c.Flags().StringArray("path", []string{"/"}, "")
return c
}

func TestExportPathFlagAcceptsMultipleValues(t *testing.T) {
// the export command must declare --path as a repeatable string array
paths, err := exportCmd.Flags().GetStringArray("path")
assert.NoError(t, err)
assert.Equal(t, []string{"/"}, paths)

// repeated --path values accumulate instead of overwriting one another
cmd := newExportTestCmd()
assert.NoError(t, cmd.Flags().Set("path", "/first"))
assert.NoError(t, cmd.Flags().Set("path", "/second"))

paths, err = cmd.Flags().GetStringArray("path")
assert.NoError(t, err)
assert.Equal(t, []string{"/first", "/second"}, paths)
}

func TestPosixShellQuote(t *testing.T) {
tests := []struct {
input string
Expand Down
7 changes: 5 additions & 2 deletions packages/util/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package util

import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -69,8 +70,10 @@ func HandleError(err error, messages ...string) {
}

func PrintErrorAndExit(exitCode int, err error, messages ...string) {
// Check if it's an API error for special formatting
if apiErr, ok := err.(*api.APIError); ok {
// Check if it's an API error for special formatting. errors.As is used so that
// API errors still get pretty printed when callers wrap them with additional context.
var apiErr *api.APIError
if errors.As(err, &apiErr) {
if len(messages) > 0 {
apiErr.ExtraMessages = messages
}
Expand Down