From 74ba1980e3e73cd45ee35a3f32f8fb2c88f21ade Mon Sep 17 00:00:00 2001 From: Brandon Bloom Date: Wed, 16 Feb 2022 18:14:47 -0800 Subject: [PATCH] cluster environment Fixes ##541 --- internal/cli/cluster.go | 98 +++++++++------- internal/cli/cluster_env.go | 28 +++++ internal/cli/cluster_refresh.go | 40 +++++++ internal/cli/cluster_show.go | 1 - internal/cli/completion_install.go | 32 +---- internal/cli/env.go | 39 ++++--- internal/cli/exec.go | 8 +- internal/environment/os.go | 11 +- .../components/container/description.go | 8 +- internal/resolvers/cluster.go | 81 ++++++++++++- internal/resolvers/environment.go | 109 ++++++------------ internal/resolvers/migrate.go | 13 ++- internal/resolvers/schema.gql | 11 +- internal/resolvers/stack.go | 82 +++++++++++++ internal/resolvers/variable.go | 11 ++ internal/resolvers/workspace.go | 13 ++- internal/scalars/instant.go | 16 +++ internal/scalars/json.go | 10 +- internal/util/osutil/env.go | 21 +++- internal/util/shellutil/environment.go | 75 ++++++++++++ internal/util/shellutil/shellutil.go | 38 ++++++ 21 files changed, 557 insertions(+), 188 deletions(-) create mode 100644 internal/cli/cluster_env.go create mode 100644 internal/cli/cluster_refresh.go delete mode 100644 internal/cli/cluster_show.go create mode 100644 internal/util/shellutil/environment.go create mode 100644 internal/util/shellutil/shellutil.go diff --git a/internal/cli/cluster.go b/internal/cli/cluster.go index 70be9f59a..ceed778a0 100644 --- a/internal/cli/cluster.go +++ b/internal/cli/cluster.go @@ -26,50 +26,66 @@ from the current stack. If there is no current stack, the default cluster is used.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() - - type clusterFragment struct { - ID string `json:"id"` - Name string `json:"name"` - Default bool `json:"default"` + cluster, err := lookupCluster(cmd) + if err != nil { + return err } + cmdutil.PrintCueStruct(cluster) + return nil + }, +} + +type clusterFragment struct { + ID string + Name string + Default bool + Environment environmentFragment +} - var cluster *clusterFragment - if cmd.Flags().Lookup("cluster").Changed { - var q struct { - Cluster *clusterFragment `graphql:"clusterByRef(ref: $cluster)"` - } - if err := api.Query(ctx, svc, &q, map[string]interface{}{ - "cluster": rootPersistentFlags.Cluster, - }); err != nil { - return err - } - cluster = q.Cluster - if cluster == nil { - return fmt.Errorf("no such cluster: %q", rootPersistentFlags.Cluster) - } +func lookupCluster(cmd *cobra.Command) (*clusterFragment, error) { + ctx := cmd.Context() + if cmd.Flags().Lookup("cluster").Changed { + var q struct { + Cluster *clusterFragment `graphql:"clusterByRef(ref: $cluster)"` + } + if err := api.Query(ctx, svc, &q, map[string]interface{}{ + "cluster": rootPersistentFlags.Cluster, + }); err != nil { + return nil, err + } + if q.Cluster == nil { + return nil, fmt.Errorf("no such cluster: %q", rootPersistentFlags.Cluster) + } + return q.Cluster, nil + } else { + var q struct { + Stack *struct { + Cluster clusterFragment + } `graphql:"stackByRef(ref: $stack)"` + DefaultCluster clusterFragment `graphql:"defaultCluster"` + } + if err := api.Query(ctx, svc, &q, map[string]interface{}{ + "stack": currentStackRef(), + }); err != nil { + return nil, err + } + if q.Stack != nil { + return &q.Stack.Cluster, nil } else { - var q struct { - Stack *struct { - Cluster clusterFragment - } `graphql:"stackByRef(ref: $stack)"` - DefaultCluster *clusterFragment `graphql:"defaultCluster"` - } - if err := api.Query(ctx, svc, &q, map[string]interface{}{ - "stack": currentStackRef(), - }); err != nil { - return err - } - if q.Stack != nil { - cluster = &q.Stack.Cluster - } else if q.DefaultCluster != nil { - cluster = q.DefaultCluster - } else { - return fmt.Errorf("no current cluster") - } + return &q.DefaultCluster, nil } + } +} - cmdutil.PrintCueStruct(cluster) - return nil - }, +func showCluster(cluster *clusterFragment) { + env := make(map[string]interface{}, len(cluster.Environment.Variables)) + for _, v := range cluster.Environment.Variables { + env[v.Name] = v.Value + } + cmdutil.PrintCueStruct(map[string]interface{}{ + "id": cluster.ID, + "name": cluster.Name, + "default": cluster.Default, + "environment": env, + }) } diff --git a/internal/cli/cluster_env.go b/internal/cli/cluster_env.go new file mode 100644 index 000000000..b4d3acbef --- /dev/null +++ b/internal/cli/cluster_env.go @@ -0,0 +1,28 @@ +package cli + +import ( + "github.com/spf13/cobra" +) + +func init() { + clusterCmd.AddCommand(clusterEnvCmd) +} + +var clusterEnvCmd = &cobra.Command{ + Use: "env", + Short: "Show environment", + Long: `Show cluster environment. + +Finds a cluster as per the root "cluster" command, and prints exclusively that +cluster's environment, formats the environment with formatting as per the root +"env" command.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + cluster, err := lookupCluster(cmd) + if err != nil { + return err + } + showEnvironment(cluster.Environment) + return nil + }, +} diff --git a/internal/cli/cluster_refresh.go b/internal/cli/cluster_refresh.go new file mode 100644 index 000000000..544d26a35 --- /dev/null +++ b/internal/cli/cluster_refresh.go @@ -0,0 +1,40 @@ +package cli + +import ( + "github.com/deref/exo/internal/api" + "github.com/spf13/cobra" +) + +func init() { + clusterCmd.AddCommand(clusterRefreshCmd) +} + +var clusterRefreshCmd = &cobra.Command{ + Hidden: true, + Use: "refresh", + Short: "Refresh cluster model", + Long: `Forces a refreshes of a cluster's model. + +Targets a cluster as per the root "cluster" command. + +It is not normally necessary to call this command, since cluster models are +automatically refreshed at some periodic frequency.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + cluster, err := lookupCluster(cmd) + if err != nil { + return err + } + var m struct { + Cluster *clusterFragment `graphql:"refreshCluster(ref: $cluster)"` + } + if err := api.Mutate(ctx, svc, &m, map[string]interface{}{ + "cluster": cluster.ID, + }); err != nil { + return err + } + showCluster(m.Cluster) + return nil + }, +} diff --git a/internal/cli/cluster_show.go b/internal/cli/cluster_show.go deleted file mode 100644 index 7f1e458cd..000000000 --- a/internal/cli/cluster_show.go +++ /dev/null @@ -1 +0,0 @@ -package cli diff --git a/internal/cli/completion_install.go b/internal/cli/completion_install.go index bcbebcac9..e97151f8c 100644 --- a/internal/cli/completion_install.go +++ b/internal/cli/completion_install.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/deref/exo/internal/util/osutil" + "github.com/deref/exo/internal/util/shellutil" "github.com/deref/exo/internal/util/which" "github.com/spf13/cobra" ) @@ -36,7 +37,7 @@ zsh, may require additional steps to clear completion caches.`, } if shell == "" { - shell = inferShell() + shell = shellutil.IdentifyUserShell() } if shell == "" { return errors.New("cannot determine shell") @@ -99,38 +100,11 @@ func completionPathCandidates(shell string) []string { return paths[:dest] } -// Returns the path of the current user's default shell. -func getUserShell() string { - sudoUser, _ := os.LookupEnv("SUDO_USER") - if sudoUser != "" { - // TODO: Query the user's shell from getent or similar on Linux. - return "" - } - shell, _ := os.LookupEnv("SHELL") - return shell -} - -// Returns the name of the current user's default shell. -func inferShell() string { - shellPath := getUserShell() - for _, shellName := range []string{ - "bash", - "zsh", - "fish", - } { - if strings.HasSuffix(shellPath, "/"+shellName) { - return shellName - } - } - // TODO: Detect powershell. - return "" -} - const completionPathBashLinux = "/etc/bash_completion.d/exo" const completionPathBashMac = "/usr/local/etc/bash_completion.d/exo" func completionPathZsh() string { - shell := getUserShell() + shell := shellutil.GetUserShellPath() if !strings.HasSuffix(shell, "/zsh") { shell, _ = which.Which("zsh") } diff --git a/internal/cli/env.go b/internal/cli/env.go index 5fd335eca..2b1142c16 100644 --- a/internal/cli/env.go +++ b/internal/cli/env.go @@ -19,30 +19,39 @@ var envCmd = &cobra.Command{ Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - env := osutil.EnvMapToDotEnv(getEnvMap(ctx)) - for _, kvp := range env { - fmt.Println(kvp) - } + showEnvironment(getWorkspaceEnvironment(ctx)) return nil }, } -func getEnvMap(ctx context.Context) map[string]string { +func getWorkspaceEnvironment(ctx context.Context) environmentFragment { var q struct { Workspace *struct { - Environment struct { - Variables []struct { - Name string - Value string - } - } + Environment environmentFragment } `graphql:"workspaceByRef(ref: $currentWorkspace)"` } mustQueryWorkspace(ctx, &q, nil) - vars := q.Workspace.Environment.Variables - m := make(map[string]string, len(vars)) - for _, v := range vars { - m[v.Name] = v.Value + return q.Workspace.Environment +} + +func showEnvironment(env environmentFragment) { + for _, v := range env.Variables { + fmt.Println(osutil.FormatDotEnvEntry(v.Name, v.Value)) + } +} + +type environmentFragment struct { + Variables []struct { + Name string + Value string + } +} + +func environmentFragmentToMap(fragment environmentFragment) map[string]string { + variables := fragment.Variables + m := make(map[string]string, len(variables)) + for _, variable := range variables { + m[variable.Name] = variable.Value } return m } diff --git a/internal/cli/exec.go b/internal/cli/exec.go index 50f7de118..2f16ce5f5 100644 --- a/internal/cli/exec.go +++ b/internal/cli/exec.go @@ -22,12 +22,16 @@ var execCmd = &cobra.Command{ DisableFlagsInUseLine: true, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - env := osutil.EnvMapToEnvv(getEnvMap(ctx)) + environment := getWorkspaceEnvironment(ctx) + envv := make([]string, 0, len(environment.Variables)) + for _, variable := range environment.Variables { + envv = append(envv, osutil.FormatEnvvEntry(variable.Name, variable.Value)) + } program, err := which.Which(args[0]) if err != nil { return fmt.Errorf("resolving program: %w", err) } - err = syscall.Exec(program, args, env) + err = syscall.Exec(program, args, envv) cmdutil.Fatalf("%v", err) panic("unreachable") }, diff --git a/internal/environment/os.go b/internal/environment/os.go index 8459a61be..be311f5dd 100644 --- a/internal/environment/os.go +++ b/internal/environment/os.go @@ -2,7 +2,8 @@ package environment import ( "os" - "strings" + + "github.com/deref/exo/internal/util/osutil" ) type OS struct{} @@ -15,11 +16,9 @@ func (src *OS) ExtendEnvironment(b Builder) error { // TODO: This should probably somehow shell-out to get the user's current // environment, otherwise changes to shell profiles won't take effect until // the exo daemon is exited and restarted. - for _, assign := range os.Environ() { - parts := strings.SplitN(assign, "=", 2) - key := parts[0] - val := parts[1] - b.AppendVariable(src, key, val) + for _, entry := range os.Environ() { + name, value := osutil.ParseEnvvEntry(entry) + b.AppendVariable(src, name, value) } return nil } diff --git a/internal/providers/docker/components/container/description.go b/internal/providers/docker/components/container/description.go index c5c256a9e..f4c31fe92 100644 --- a/internal/providers/docker/components/container/description.go +++ b/internal/providers/docker/components/container/description.go @@ -4,12 +4,12 @@ import ( "context" "encoding/json" "fmt" - "strings" "time" "github.com/deref/exo/internal/core/api" "github.com/deref/exo/internal/providers/docker" "github.com/deref/exo/internal/util/jsonutil" + "github.com/deref/exo/internal/util/osutil" dockerclient "github.com/docker/docker/client" "github.com/moby/moby/errdefs" "golang.org/x/sync/errgroup" @@ -48,9 +48,9 @@ func GetProcessDescription(ctx context.Context, dockerClient *dockerclient.Clien process.CreateTime = &createTime process.EnvVars = map[string]string{} - for _, env := range containerInfo.Config.Env { - decomposedEnv := strings.SplitN(env, "=", 2) - process.EnvVars[decomposedEnv[0]] = decomposedEnv[1] + for _, entry := range containerInfo.Config.Env { + name, value := osutil.ParseEnvvEntry(entry) + process.EnvVars[name] = value } process.Ports = []uint32{} diff --git a/internal/resolvers/cluster.go b/internal/resolvers/cluster.go index a37debeec..8b75142a3 100644 --- a/internal/resolvers/cluster.go +++ b/internal/resolvers/cluster.go @@ -3,20 +3,28 @@ package resolvers import ( "context" "fmt" + "time" + + "github.com/deref/exo/internal/scalars" + "github.com/deref/exo/internal/util/shellutil" ) type ClusterResolver struct { - Q *QueryResolver + Q *RootResolver ClusterRow } type ClusterRow struct { - ID string `db:"id"` - Name string `db:"name"` + ID string `db:"id"` + Name string `db:"name"` + EnvironmentVariables scalars.JSONObject `db:"environment_variables"` + Updated scalars.Instant `db:"updated"` } func (r *QueryResolver) clusterByID(ctx context.Context, id *string) (*ClusterResolver, error) { - clus := &ClusterResolver{} + clus := &ClusterResolver{ + Q: r, + } err := r.getRowByKey(ctx, &clus.ClusterRow, ` SELECT * FROM cluster @@ -86,3 +94,68 @@ func (r *QueryResolver) DefaultCluster(ctx context.Context) (*ClusterResolver, e func (r *ClusterResolver) Default() bool { return r.Name == "local" } + +func (r *MutationResolver) UpdateCluster(ctx context.Context, args struct { + Ref string + Environment *scalars.JSONObject +}) (*ClusterResolver, error) { + return r.updateCluster(ctx, args.Ref, args.Environment) +} + +func (r *MutationResolver) updateCluster(ctx context.Context, ref string, environment *scalars.JSONObject) (*ClusterResolver, error) { + now := scalars.Now(ctx) + var row ClusterRow + if err := r.DB.GetContext(ctx, &row, ` + UPDATE cluster + SET + environment_variables = COALESCE(?, environment_variables), + updated = ? + WHERE id = ? OR name = ? + RETURNING * + `, + environment, + now, + ref, ref, + ); err != nil { + return nil, err + } + return &ClusterResolver{ + Q: r, + ClusterRow: row, + }, nil +} + +const clusterTTL = 3 * time.Second + +func (r *ClusterResolver) Environment(ctx context.Context) (*EnvironmentResolver, error) { + variables := r.EnvironmentVariables + + now := scalars.Now(ctx) + if r.EnvironmentVariables == nil || now.Sub(r.Updated) < clusterTTL { + r, err := r.Q.refreshCluster(ctx, r.ID) + if err != nil { + return nil, err + } + variables = r.EnvironmentVariables + } + + return JSONObjectToEnvironment(variables, "Cluster") +} + +func (r *MutationResolver) RefreshCluster(ctx context.Context, args struct { + Ref string +}) (*ClusterResolver, error) { + return r.refreshCluster(ctx, args.Ref) +} + +func (r *MutationResolver) refreshCluster(ctx context.Context, ref string) (*ClusterResolver, error) { + envMap, err := shellutil.GetUserEnvironment(ctx) + if err != nil { + return nil, fmt.Errorf("querying environment: %w", err) + } + envObj := make(scalars.JSONObject, len(envMap)) + for k, v := range envMap { + envObj[k] = v + } + return r.updateCluster(ctx, ref, &envObj) +} diff --git a/internal/resolvers/environment.go b/internal/resolvers/environment.go index 803950964..f506f585e 100644 --- a/internal/resolvers/environment.go +++ b/internal/resolvers/environment.go @@ -1,83 +1,48 @@ package resolvers import ( - "context" + "fmt" + "sort" + + "github.com/deref/exo/internal/scalars" ) type EnvironmentResolver struct { - Workspace *WorkspaceResolver + Variables []*VariableResolver } -// XXX This now does network requests and non-trivial parsing work. Therefore, -// it is no longer appropriate to call deep in the call stack. -func (r *EnvironmentResolver) Variables(ctx context.Context) ([]*VariableResolver, error) { - return []*VariableResolver{}, nil - // XXX implement me - /* - ws := r.Workspace - - var sources []environment.Source - - if manifest := ws.tryLoadManifest(ctx); manifest != nil { - manifestEnv := &exohcl.Environment{ - Blocks: manifest.Environment, - } - diags := exohcl.Analyze(ctx, manifestEnv) - if diags.HasErrors() { - return nil, diags - } - sources = append(sources, manifestEnv) - } - - sources = append(sources, - environment.Default, - &environment.OS{}, - ) - - envPath, err := ws.resolveWorkspacePath(ctx, ".env") - if err != nil { - return nil, fmt.Errorf("resolving env file path: %w", err) - } - if exists, _ := osutil.Exists(envPath); exists { - sources = append(sources, &environment.Dotenv{ - Path: envPath, - }) - } - - b := &environmentBuilder{ - Environment: make(map[string]api.VariableDescription), - } - - // TODO: Do not use DescribeVaults, instead build up sources from the - // environment blocks ASTs. For example, there maybe a `variables` block or - // some other environment sources that are not in the DescribeVaults output. - describeVaultsResult, err := ws.DescribeVaults(ctx, &api.DescribeVaultsInput{}) - if err != nil { - return nil, fmt.Errorf("getting vaults: %w", err) - } - - logger := logging.CurrentLogger(ctx) - for _, vault := range describeVaultsResult.Vaults { - derefSource := &environment.ESV{ - Client: ws.EsvClient, - Name: vault.URL, // XXX - URL: vault.URL, - } - if err := derefSource.ExtendEnvironment(b); err != nil { - // It's not appropriate to fail on error since this error could just - // indicate the user is offline and thus cannot retrieve this value from - // the secret provider. - // TODO: this should really alert the user in a more apparent way that - // fetching secrets from the vault has failed. - logger.Infof("Could not extend environment from vault %q: %v", vault.URL, err) - } +func JSONObjectToEnvironment(obj scalars.JSONObject, source string) (*EnvironmentResolver, error) { + variables := make([]*VariableResolver, 0, len(obj)) + for k, v := range obj { + vs, ok := v.(string) + if !ok { + return nil, fmt.Errorf("environment variable %q value is not a string", k) } + variables = append(variables, &VariableResolver{ + Name: k, + Value: vs, + Source: source, + }) + } + sort.Sort(VariablesByName(variables)) + environment := &EnvironmentResolver{ + Variables: variables, + } + return environment, nil +} - for _, source := range sources { - if err := source.ExtendEnvironment(b); err != nil { - return nil, fmt.Errorf("extending environment from %s: %w", source.EnvironmentSource(), err) - } - } - return b.Environment, nil - */ +func EnvMapToEnvironment(m map[string]string, source string) *EnvironmentResolver { + variables := make([]*VariableResolver, 0, len(m)) + for k, v := range m { + variables = append(variables, &VariableResolver{ + Name: k, + Value: v, + Source: source, + }) + } + sort.Sort(VariablesByName(variables)) + environment := &EnvironmentResolver{ + Variables: variables, + } + return environment } diff --git a/internal/resolvers/migrate.go b/internal/resolvers/migrate.go index 2595ef459..1bdab1e41 100644 --- a/internal/resolvers/migrate.go +++ b/internal/resolvers/migrate.go @@ -8,16 +8,21 @@ import ( "fmt" "github.com/deref/exo/internal/gensym" + "github.com/deref/exo/internal/scalars" "github.com/mattn/go-sqlite3" ) func (r *MutationResolver) Migrate(ctx context.Context) error { + now := scalars.Now(ctx) + // Cluster. if _, err := r.DB.ExecContext(ctx, ` CREATE TABLE IF NOT EXISTS cluster ( id TEXT NOT NULL PRIMARY KEY, - name TEXT NOT NULL + name TEXT NOT NULL, + environment_variables TEXT, + updated TEXT NOT NULL );`); err != nil { return fmt.Errorf("creating cluster table: %w", err) } @@ -31,9 +36,9 @@ func (r *MutationResolver) Migrate(ctx context.Context) error { // TODO: Don't do this as part of migrate. SEE NOTE [DEFAULT_CLUSTER]. if _, err := r.DB.ExecContext(ctx, ` - INSERT INTO cluster ( id, name ) - VALUES ( ?, ? ) - `, gensym.RandomBase32(), "local", + INSERT INTO cluster ( id, name, updated ) + VALUES ( ?, ?, ? ) + `, gensym.RandomBase32(), "local", now, ); err != nil { var sqlErr sqlite3.Error if !(errors.As(err, &sqlErr) && sqlErr.ExtendedCode == sqlite3.ErrConstraintUnique) { diff --git a/internal/resolvers/schema.gql b/internal/resolvers/schema.gql index 28c2a2cb2..a507f3b05 100644 --- a/internal/resolvers/schema.gql +++ b/internal/resolvers/schema.gql @@ -6,7 +6,7 @@ type Query { routes: Routes! allClusters: [Cluster!]! - defaultCluster: Cluster + defaultCluster: Cluster! clusterByRef(ref: String!): Cluster allProjects: [Project!]! @@ -41,6 +41,9 @@ type Query { type Mutation { stopDaemon(): Void + updateCluster(ref: String!, environment: JSONObject): Cluster! + refreshCluster(ref: String!): Cluster! + createProject(displayName: String): Project! createWorkspace(root: String!, projectId: String): Workspace! @@ -134,6 +137,7 @@ type Cluster { id: String! name: String! default: Boolean! + environment: Environment! } type Project { @@ -193,6 +197,8 @@ type Stack { resources: [Resource!]! configuration: String! + + environment: Environment! } type Component { @@ -227,10 +233,11 @@ type Environment { variables: [Variable!]! } +# TODO: Rename to EnvironmentVariable. type Variable { name: String! value: String! - source: String! + source: String! # TODO: EnvironmentVariableSource union/interface. } type Resource { diff --git a/internal/resolvers/stack.go b/internal/resolvers/stack.go index 964f919fa..0b97a420f 100644 --- a/internal/resolvers/stack.go +++ b/internal/resolvers/stack.go @@ -304,3 +304,85 @@ func (r *StackResolver) configuration(ctx context.Context) (exocue.Stack, error) } return b.Build(), nil } + +func (r *StackResolver) Environment(ctx context.Context) (*EnvironmentResolver, error) { + // XXX implement me + // XXX This now does network requests and non-trivial parsing work. Therefore, + // it is no longer appropriate to call deep in the call stack. + /* + ws := r.Workspace + + var sources []environment.Source + + if manifest := ws.tryLoadManifest(ctx); manifest != nil { + manifestEnv := &exohcl.Environment + Blocks: manifest.Environment, + } + diags := exohcl.Analyze(ctx, manifestEnv) + if diags.HasErrors() { + return nil, diags + } + sources = append(sources, manifestEnv) + } + + sources = append(sources, + environment.Default, + &environment.OS{}, + ) + + envPath, err := ws.resolveWorkspacePath(ctx, ".env") + if err != nil { + return nil, fmt.Errorf("resolving env file path: %w", err) + } + if exists, _ := osutil.Exists(envPath); exists { + sources = append(sources, &environment.Dotenv{ + Path: envPath, + }) + } + + b := &environmentBuilder{ + Environment: make(map[string]api.VariableDescription), + } + + // TODO: Do not use DescribeVaults, instead build up sources from the + // environment blocks ASTs. For example, there maybe a `variables` block or + // some other environment sources that are not in the DescribeVaults output. + describeVaultsResult, err := ws.DescribeVaults(ctx, &api.DescribeVaultsInput{}) + if err != nil { + return nil, fmt.Errorf("getting vaults: %w", err) + } + + logger := logging.CurrentLogger(ctx) + for _, vault := range describeVaultsResult.Vaults { + derefSource := &environment.ESV{ + Client: ws.EsvClient, + Name: vault.URL, // XXX + URL: vault.URL, + } + if err := derefSource.ExtendEnvironment(b); err != nil { + // It's not appropriate to fail on error since this error could just + // indicate the user is offline and thus cannot retrieve this value from + // the secret provider. + // TODO: this should really alert the user in a more apparent way that + // fetching secrets from the vault has failed. + logger.Infof("Could not extend environment from vault %q: %v", vault.URL, err) + } + } + + for _, source := range sources { + if err := source.ExtendEnvironment(b); err != nil { + return nil, fmt.Errorf("extending environment from %s: %w", source.EnvironmentSource(), err) + } + } + return b.Environment, nil + */ + + return &EnvironmentResolver{ + Variables: []*VariableResolver{ + { + Name: "X", + Value: "a\b", + }, + }, // XXX + }, nil +} diff --git a/internal/resolvers/variable.go b/internal/resolvers/variable.go index bfcad5212..1c1e8d898 100644 --- a/internal/resolvers/variable.go +++ b/internal/resolvers/variable.go @@ -1,7 +1,18 @@ package resolvers +import "strings" + type VariableResolver struct { Name string Value string Source string } + +type VariablesByName []*VariableResolver + +func (vars VariablesByName) Len() int { return len(vars) } +func (vars VariablesByName) Swap(i, j int) { vars[i], vars[j] = vars[j], vars[i] } + +func (vars VariablesByName) Less(i, j int) bool { + return strings.Compare(vars[i].Name, vars[j].Name) < 0 +} diff --git a/internal/resolvers/workspace.go b/internal/resolvers/workspace.go index 7e62dc931..53edc4130 100644 --- a/internal/resolvers/workspace.go +++ b/internal/resolvers/workspace.go @@ -153,10 +153,17 @@ func (r *WorkspaceResolver) componentByRef(ctx context.Context, ref string) (*Co return stack.componentByRef(ctx, ref) } -func (r *WorkspaceResolver) Environment(ctx context.Context) *EnvironmentResolver { - return &EnvironmentResolver{ - Workspace: r, +func (r *WorkspaceResolver) Environment(ctx context.Context) (*EnvironmentResolver, error) { + stack, err := r.Stack(ctx) + if err != nil { + return nil, fmt.Errorf("resolving stack: %w", err) + } + if stack == nil { + // TODO: When there is no current stack, it may be useful to return the + // environment of the workspace's cluster/host. + return nil, errors.New("no current stack") } + return stack.Environment(ctx) } func (r *WorkspaceResolver) FileSystem() *FileSystemResolver { diff --git a/internal/scalars/instant.go b/internal/scalars/instant.go index 6b89f2c6c..d9c3c3c3a 100644 --- a/internal/scalars/instant.go +++ b/internal/scalars/instant.go @@ -72,3 +72,19 @@ func (inst Instant) String() string { func (inst Instant) UnixMilli() int64 { return inst.t.UnixMilli() } + +func (inst Instant) Before(other Instant) bool { + return inst.GoTime().Before(other.GoTime()) +} + +func (inst Instant) After(other Instant) bool { + return inst.GoTime().After(other.GoTime()) +} + +func (inst Instant) Equal(other Instant) bool { + return inst.GoTime().Equal(other.GoTime()) +} + +func (inst Instant) Sub(other Instant) time.Duration { + return inst.GoTime().Sub(other.GoTime()) +} diff --git a/internal/scalars/json.go b/internal/scalars/json.go index 733c83cd2..f399be4ec 100644 --- a/internal/scalars/json.go +++ b/internal/scalars/json.go @@ -27,11 +27,15 @@ func (obj *JSONObject) UnmarshalGraphQL(input interface{}) (err error) { } func (obj *JSONObject) Scan(src interface{}) error { - s, ok := src.(string) - if !ok { + switch s := src.(type) { + case nil: + *obj = nil + return nil + case string: + return json.Unmarshal([]byte(s), (*map[string]interface{})(obj)) + default: return fmt.Errorf("expected string, got %T", src) } - return json.Unmarshal([]byte(s), (*map[string]interface{})(obj)) } func (obj JSONObject) Value() (driver.Value, error) { diff --git a/internal/util/osutil/env.go b/internal/util/osutil/env.go index 6fc8f3954..df48abc46 100644 --- a/internal/util/osutil/env.go +++ b/internal/util/osutil/env.go @@ -3,6 +3,7 @@ package osutil import ( "fmt" "sort" + "strings" "github.com/alessio/shellescape" ) @@ -13,22 +14,38 @@ func EnvMapToEnvv(m map[string]string) []string { s := make([]string, len(m)) i := 0 for name, value := range m { - s[i] = fmt.Sprintf("%s=%s", name, value) + s[i] = FormatEnvvEntry(name, value) i++ } sort.Strings(s) return s } +// Returns an entry in the form name=value. No escaping is performed. +func FormatEnvvEntry(name, value string) string { + return fmt.Sprintf("%s=%s", name, value) +} + +// Parses an name=value entry where the value is returned literally. +func ParseEnvvEntry(entry string) (name, value string) { + parts := strings.SplitN(entry, "=", 2) + return parts[0], parts[1] +} + // Convert an environment map to a slice of strings suitable for a .env file. // Entries are of the form name=value and sorted. Values are shell-quoted. func EnvMapToDotEnv(m map[string]string) []string { s := make([]string, len(m)) i := 0 for name, value := range m { - s[i] = fmt.Sprintf("%s=%s", name, shellescape.Quote(value)) + s[i] = FormatDotEnvEntry(name, value) i++ } sort.Strings(s) return s } + +// Creates entries of the form name=value. Values are shell-quoted. +func FormatDotEnvEntry(name, value string) string { + return fmt.Sprintf("%s=%s", name, shellescape.Quote(value)) +} diff --git a/internal/util/shellutil/environment.go b/internal/util/shellutil/environment.go new file mode 100644 index 000000000..46967f549 --- /dev/null +++ b/internal/util/shellutil/environment.go @@ -0,0 +1,75 @@ +package shellutil + +import ( + "bufio" + "bytes" + "context" + "fmt" + "os/exec" + "time" + + "github.com/deref/exo/internal/gensym" + "github.com/deref/exo/internal/util/osutil" +) + +// Gets the current user's default interactive/login shell environment. +func GetUserEnvironment(ctx context.Context) (map[string]string, error) { + ctx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + + shellPath := GetUserShellPath() + switch IdentifyShell(shellPath) { + case "sh", "bash", "zsh", "fish": + // All of these are POSIX-compatible and should support the necessary + // command line flags. + default: + // Fallback to POSIX-required standard shell. + // TODO: Handle Windows shells. + shellPath = "/bin/sh" + } + + // Use printenv within boundary lines, in case any gunk is printed during + // shell initialization. + boundary := gensym.RandomBase32() + begin := "BEGIN-" + boundary + end := "END-" + boundary + subcmd := fmt.Sprintf("echo %s; printenv; echo %s", begin, end) + + cmd := exec.CommandContext(ctx, shellPath, + "-ilc", // i=interactive, l=login, c=command. + subcmd, // Argument to 'c'. + ) + bs, err := cmd.Output() + if err != nil { + return nil, err + } + + env := make(map[string]string) + scanner := bufio.NewScanner(bytes.NewBuffer(bs)) + inside := false + for scanner.Scan() { + line := scanner.Text() + switch line { + case begin: + inside = true + case end: + inside = false + default: + if inside { + // NOTE: printenv uses envv, which will produce incorrect results in the + // presence of newlines or other special characters. + // TODO: Use an alternative utility that uses DotEnv format. + name, value := osutil.ParseEnvvEntry(line) + env[name] = value + } + } + } + if scanner.Err() != nil { + return nil, scanner.Err() + } + + // Do not include "printenv" or whatever the effective command name is. + delete(env, "_") + + return env, nil +} diff --git a/internal/util/shellutil/shellutil.go b/internal/util/shellutil/shellutil.go new file mode 100644 index 000000000..557dae2b8 --- /dev/null +++ b/internal/util/shellutil/shellutil.go @@ -0,0 +1,38 @@ +package shellutil + +import ( + "os" + "strings" +) + +// Returns the path of the current user's default shell. +func GetUserShellPath() string { + sudoUser, _ := os.LookupEnv("SUDO_USER") + if sudoUser != "" { + // TODO: Query the user's shell from getent or similar on Linux. + return "" + } + shell, _ := os.LookupEnv("SHELL") + return shell +} + +// Attempts to return the name of the current user's default shell. +func IdentifyUserShell() string { + return IdentifyShell(GetUserShellPath()) +} + +// Given a shell path, attempts to returns the name of a known shell +// implementation. +func IdentifyShell(shellPath string) string { + for _, shellName := range []string{ + "bash", + "zsh", + "fish", + } { + if strings.HasSuffix(shellPath, "/"+shellName) { + return shellName + } + } + // TODO: Detect powershell. + return "" +}