Skip to content

Commit

Permalink
Implement ResolveEnv() in runnerv2service
Browse files Browse the repository at this point in the history
  • Loading branch information
adambabik committed Feb 2, 2024
1 parent 7c72e38 commit c9cedb0
Show file tree
Hide file tree
Showing 14 changed files with 995 additions and 369 deletions.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ require (
golang.org/x/term v0.16.0
google.golang.org/genproto/googleapis/rpc v0.0.0-20240116215550-a9fa1716bcac
google.golang.org/protobuf v1.32.0
mvdan.cc/sh/v3 v3.7.0
)

require (
Expand Down
4 changes: 4 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/frankban/quicktest v1.14.5 h1:dfYrrRyLtiqT9GyKXgdh+k4inNeTvmGbuSgZ3lx3GhA=
github.com/frankban/quicktest v1.14.5/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gliderlabs/ssh v0.3.5 h1:OcaySEmAQJgyYcArR+gGGTHCyE7nvhEMTlYY+Dp8CpY=
Expand Down Expand Up @@ -312,3 +314,5 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
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=
mvdan.cc/sh/v3 v3.7.0 h1:lSTjdP/1xsddtaKfGg7Myu7DnlHItd3/M2tomOcNNBg=
mvdan.cc/sh/v3 v3.7.0/go.mod h1:K2gwkaesF/D7av7Kxl0HbF5kGOd2ArupNTX3X44+8l8=
41 changes: 24 additions & 17 deletions internal/api/runme/runner/v2alpha1/runner.proto
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,16 @@ message ResolveEnvRequest {
// that will be injected to the executed program.
repeated string env = 3;

// session_id indicates in which Session the program should execute.
// Executing in a Session might provide additional context like
// environment variables.
string session_id = 4;

// session_strategy is a strategy for selecting the session.
SessionStrategy session_strategy = 4;
SessionStrategy session_strategy = 5;

// project used to load environment variables from .env files.
optional Project project = 5;
optional Project project = 6;

message CommandList {
// commands are commands to be executed by the program.
Expand All @@ -168,34 +173,36 @@ message ResolveEnvRequest {
}
}

message ResolveEnvResponse {
// resolved_env is a list of resolved environment variables.
repeated ResolvedEnv resolved_env = 1;

// unresolved_env is a list of environment variables
// that couldn't be resolved.
repeated UnresolvedEnv unresolved_env = 2;
message ResolveEnvResult {
oneof result {
ResolvedEnv resolved_env = 1;
UnresolvedEnv unresolved_env = 2;
}

message ResolvedEnv {
string key = 1;
string name = 1;
string original_value = 2;
string resolved_value = 3;
Source source = 4;
ResolvedEnvSource source = 4;
}

message UnresolvedEnv {
string key = 1;
string name = 1;
string original_value = 2;
}

enum Source {
SOURCE_UNSPECIFIED = 0;
SOURCE_ENV = 1;
SOURCE_SESSION = 2;
SOURCE_PROJECT = 3;
enum ResolvedEnvSource {
RESOLVED_ENV_SOURCE_UNSPECIFIED = 0;
RESOLVED_ENV_SOURCE_ENV = 1;
RESOLVED_ENV_SOURCE_SESSION = 2;
RESOLVED_ENV_SOURCE_PROJECT = 3;
}
}

message ResolveEnvResponse {
repeated ResolveEnvResult items = 1;
}

service RunnerService {
rpc CreateSession(CreateSessionRequest) returns (CreateSessionResponse) {}
rpc GetSession(GetSessionRequest) returns (GetSessionResponse) {}
Expand Down
4 changes: 0 additions & 4 deletions internal/command/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ type NativeCommandOptions struct {
Logger *zap.Logger
}

func (o *NativeCommandOptions) GetEnv() []string { return o.Session.GetEnv() }

func NewNative(cfg *Config, options *NativeCommandOptions) (*NativeCommand, error) {
if options == nil {
options = &NativeCommandOptions{}
Expand All @@ -39,8 +37,6 @@ type VirtualCommandOptions struct {
Logger *zap.Logger
}

func (o *VirtualCommandOptions) GetEnv() []string { return o.Session.GetEnv() }

func NewVirtual(cfg *Config, options *VirtualCommandOptions) (*VirtualCommand, error) {
if options == nil {
options = &VirtualCommandOptions{}
Expand Down
159 changes: 159 additions & 0 deletions internal/command/env_resolver.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package command

import (
"io"
"strings"

"mvdan.cc/sh/v3/syntax"

runnerv2alpha1 "github.com/stateful/runme/internal/gen/proto/go/runme/runner/v2alpha1"
)

type (
ResolveEnvResult = runnerv2alpha1.ResolveEnvResult
)

type EnvResolverSource func() []string

func EnvResolverSourceFunc(env []string) EnvResolverSource {
return func() []string {
return env
}
}

// EnvResolver uses a list of EnvResolverSource to resolve environment variables
// found in a shell program. The result contains all found environment variables.
// If the env is in any source, it is considered resolved. Otherwise, it is makred
// as unresolved.
type EnvResolver struct {
sources []EnvResolverSource
envCache map[string]string
}

func NewEnvResolver(sources ...EnvResolverSource) *EnvResolver {
return &EnvResolver{sources: sources, envCache: nil}
}

func (r *EnvResolver) Resolve(reader io.Reader) ([]*ResolveEnvResult, error) {
decls, err := r.parse(reader)
if err != nil {
return nil, err
}

var result []*ResolveEnvResult

for _, decl := range decls {
if len(decl.Args) != 1 {
continue
}

arg := decl.Args[0]

name := arg.Name.Value
originalValue := r.findOriginalValue(decl)

value, ok := r.findEnvValue(name)
if ok {
result = append(result, &ResolveEnvResult{
Result: &runnerv2alpha1.ResolveEnvResult_ResolvedEnv_{
ResolvedEnv: &runnerv2alpha1.ResolveEnvResult_ResolvedEnv{
Name: name,
ResolvedValue: value,
OriginalValue: originalValue,
},
},
})
} else {
result = append(result, &ResolveEnvResult{
Result: &runnerv2alpha1.ResolveEnvResult_UnresolvedEnv_{
UnresolvedEnv: &runnerv2alpha1.ResolveEnvResult_UnresolvedEnv{
Name: name,
OriginalValue: originalValue,
},
},
})
}
}

return result, nil
}

func (r *EnvResolver) findOriginalValue(decl *syntax.DeclClause) string {
if len(decl.Args) != 1 {
return ""
}

arg := decl.Args[0]

if arg.Value == nil {
return ""
}

parts := arg.Value.Parts

if len(parts) != 1 {
return ""
}

switch part := parts[0].(type) {
case *syntax.Lit:
return part.Value
case *syntax.DblQuoted:
if len(part.Parts) == 1 {
return part.Parts[0].(*syntax.Lit).Value
}
case *syntax.SglQuoted:
return part.Value
case *syntax.ParamExp:
if part.Exp.Op == syntax.DefaultUnsetOrNull {
return part.Exp.Word.Lit()
}
}

return ""
}

func (r *EnvResolver) findEnvValue(name string) (string, bool) {
if r.envCache == nil {
r.envCache = make(map[string]string)
r.collectEnvFromSources()
}
val, ok := r.envCache[name]
return val, ok
}

func (r *EnvResolver) collectEnvFromSources() {
for _, source := range r.sources {
env := source()
for _, e := range env {
parts := strings.SplitN(e, "=", 2)
if len(parts) == 2 {
r.envCache[parts[0]] = parts[1]
}
}
}
}

func (r *EnvResolver) parse(reader io.Reader) ([]*syntax.DeclClause, error) {
f, err := syntax.NewParser().Parse(reader, "")
if err != nil {
return nil, err
}

var result []*syntax.DeclClause

syntax.Walk(f, func(node syntax.Node) bool {
switch x := node.(type) {
case *syntax.DeclClause:
if x.Variant.Value == "export" && len(x.Args) == 1 {
result = append(result, x)
return false
}
default:
// noop
}
return true
})

return result, nil
}
126 changes: 126 additions & 0 deletions internal/command/env_resolver_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package command

import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

runnerv2alpha1 "github.com/stateful/runme/internal/gen/proto/go/runme/runner/v2alpha1"
)

func TestEnvResolver_Parsing(t *testing.T) {
createResultWithUnresolvedEnv := func(name string, originalValue string) *ResolveEnvResult {
return &ResolveEnvResult{
Result: &runnerv2alpha1.ResolveEnvResult_UnresolvedEnv_{
UnresolvedEnv: &runnerv2alpha1.ResolveEnvResult_UnresolvedEnv{
Name: name,
OriginalValue: originalValue,
},
},
}
}

testCases := []struct {
name string
data string
source []EnvResolverSource
result []*ResolveEnvResult
}{
{
name: "no value",
data: `export TEST_NO_VALUE`,
result: []*ResolveEnvResult{
createResultWithUnresolvedEnv("TEST_NO_VALUE", ""),
},
},
{
name: "empty value",
data: `export TEST_EMPTY_VALUE=`,
result: []*ResolveEnvResult{
createResultWithUnresolvedEnv("TEST_EMPTY_VALUE", ""),
},
},
{
name: "string value",
data: `export TEST_STRING_VALUE=value`,
result: []*ResolveEnvResult{
createResultWithUnresolvedEnv("TEST_STRING_VALUE", "value"),
},
},
{
name: "string double quoted value empty",
data: `export TEST_STRING_DBL_QUOTED_VALUE_EMPTY=""`,
result: []*ResolveEnvResult{
createResultWithUnresolvedEnv("TEST_STRING_DBL_QUOTED_VALUE_EMPTY", ""),
},
},
{
name: "string double quoted value",
data: `export TEST_STRING_DBL_QUOTED_VALUE="value"`,
result: []*ResolveEnvResult{
createResultWithUnresolvedEnv("TEST_STRING_DBL_QUOTED_VALUE", "value"),
},
},
{
name: "string single quoted value empty",
data: `export TEST_STRING_SGL_QUOTED_VALUE_EMPTY=''`,
result: []*ResolveEnvResult{
createResultWithUnresolvedEnv("TEST_STRING_SGL_QUOTED_VALUE_EMPTY", ""),
},
},
{
name: "string single quoted value",
data: `export TEST_STRING_SGL_QUOTED_VALUE='value'`,
result: []*ResolveEnvResult{
createResultWithUnresolvedEnv("TEST_STRING_SGL_QUOTED_VALUE", "value"),
},
},
{
name: "value expression",
data: `export TEST_VALUE_EXPR=$(echo -n "value")`,
result: []*ResolveEnvResult{
createResultWithUnresolvedEnv("TEST_VALUE_EXPR", ""),
},
},
{
name: "default value",
data: `export TEST_DEFAULT_VALUE=${TEST_DEFAULT_VALUE:-value}`,
result: []*ResolveEnvResult{
createResultWithUnresolvedEnv("TEST_DEFAULT_VALUE", "value"),
},
},
}

for _, tc := range testCases {
tc := tc

t.Run(tc.name, func(t *testing.T) {
r := NewEnvResolver(tc.source...)
result, err := r.Resolve(strings.NewReader(tc.data))
assert.NoError(t, err)
assert.EqualValues(t, tc.result, result)
})
}
}

func TestEnvResolver(t *testing.T) {
createResultResolvedEnv := func(name, resolvedValue, originalValue string) *ResolveEnvResult {
return &ResolveEnvResult{
Result: &runnerv2alpha1.ResolveEnvResult_ResolvedEnv_{
ResolvedEnv: &runnerv2alpha1.ResolveEnvResult_ResolvedEnv{
Name: name,
ResolvedValue: resolvedValue,
OriginalValue: originalValue,
},
},
}
}

r := NewEnvResolver(EnvResolverSourceFunc([]string{"MY_ENV=resolved"}))
result, err := r.Resolve(strings.NewReader(`export MY_ENV=default`))
require.NoError(t, err)
require.Len(t, result, 1)
require.EqualValues(t, createResultResolvedEnv("MY_ENV", "resolved", "default"), result[0])
}
Loading

0 comments on commit c9cedb0

Please sign in to comment.